diff --git a/settings.yml b/settings.yml index f6839808..468cc7ef 100644 --- a/settings.yml +++ b/settings.yml @@ -6,4 +6,10 @@ # This can be replaced with an ABSOLUTE path to a local ImageJ2 or Fiji installation. # If left null, napari-imagej will instead launch a vanilla ImageJ2, downloading # the latest available ImageJ2 if necessary. -imagej_installation: null \ No newline at end of file +imagej_installation: null + +# This can be used to identify whether transferred data between ImageJ2 and napari +# should be selected via activation or by user selection via a dialog. +# By default, the active layer/window is chosen for transfer between applications. +# By setting this value to false, a popup will be shown instead. +choose_active_layer: true \ No newline at end of file diff --git a/src/napari_imagej/setup_imagej.py b/src/napari_imagej/setup_imagej.py index 38b00dbb..47920bdf 100644 --- a/src/napari_imagej/setup_imagej.py +++ b/src/napari_imagej/setup_imagej.py @@ -1,6 +1,7 @@ import logging import os import sys +from functools import lru_cache from multiprocessing.pool import AsyncResult, ThreadPool from typing import Callable @@ -41,8 +42,7 @@ def imagej_init(): log_debug("Completed JVM Configuration") # Parse imagej settings - settings: dict = yaml.safe_load(open("settings.yml", "r")) - ij_dir = settings.get("imagej_installation", None) + ij_dir = setting("imagej_installation") _ij = imagej.init(ij_dir_or_version_or_endpoint=ij_dir, mode=get_mode()) log_debug(f"Initialized at version {_ij.getVersion()}") @@ -54,6 +54,15 @@ def imagej_init(): return _ij +def setting(value: str): + return settings().get(value, None) + + +@lru_cache(maxsize=None) +def settings(): + return yaml.safe_load(open("settings.yml", "r")) + + def _disable_jvm_shutdown_on_gui_quit(ij_instance): # We can't quit the gui running headlessly! if running_headless(): diff --git a/src/napari_imagej/widget_IJ2.py b/src/napari_imagej/widget_IJ2.py index 1445ca44..d7c4b0cf 100644 --- a/src/napari_imagej/widget_IJ2.py +++ b/src/napari_imagej/widget_IJ2.py @@ -1,6 +1,6 @@ from enum import Enum from threading import Thread -from typing import List +from typing import Optional from magicgui.widgets import request_values from napari import Viewer @@ -11,7 +11,14 @@ from qtpy.QtWidgets import QHBoxLayout, QMessageBox, QPushButton, QWidget from napari_imagej._module_utils import _get_layers_hack -from napari_imagej.setup_imagej import ensure_jvm_started, ij, jc, running_headless +from napari_imagej.setup_imagej import ( + ensure_jvm_started, + ij, + jc, + log_debug, + running_headless, + setting, +) class GUIWidget(QWidget): @@ -44,19 +51,33 @@ def _showUI(self): class ToIJButton(QPushButton): - def __init__(self, viewer): + def __init__(self, viewer: Viewer): super().__init__() + self.viewer = viewer + self.setEnabled(False) icon = QColoredSVGIcon.from_resources("long_right_arrow") self.setIcon(icon.colored(theme=viewer.theme)) - self.setToolTip("Send layers to ImageJ2") - self.clicked.connect(self.send_layers) + self.setToolTip("Export active napari layer to ImageJ2") + if setting("choose_active_layer"): + self.clicked.connect(self.send_active_layer) + else: + self.clicked.connect(self.send_chosen_layer) def _set_icon(self, path: str): icon: QIcon = QIcon(QPixmap(path)) self.setIcon(icon) - def send_layers(self): + def send_active_layer(self): + active_layer: Optional[Layer] = self.viewer.layers.selection.active + if active_layer: + name = active_layer.name + data = ij().py.to_java(active_layer.data) + ij().ui().show(name, data) + else: + log_debug("There is no active layer to export to ImageJ2") + + def send_chosen_layer(self): layers: dict = request_values( title="Send layers to ImageJ2", layers={"annotation": Layer, "options": {"choices": _get_layers_hack}}, @@ -77,8 +98,11 @@ def __init__(self, viewer: Viewer): self.setEnabled(False) icon = QColoredSVGIcon.from_resources("long_left_arrow") self.setIcon(icon.colored(theme=viewer.theme)) - self.setToolTip("Get layers from ImageJ2") - self.clicked.connect(self.get_layers) + self.setToolTip("Import active ImageJ2 Dataset to napari") + if setting("choose_active_layer"): + self.clicked.connect(self.get_active_layer) + else: + self.clicked.connect(self.get_chosen_layer) def _set_icon(self, path: str): icon: QIcon = QIcon(QPixmap(path)) @@ -89,13 +113,15 @@ def _get_objects(self, t): compatibleInputs.addAll(ij().object().getObjects(t)) return list(compatibleInputs) - def get_layers(self) -> List[Layer]: + def get_chosen_layer(self) -> None: images = self._get_objects(jc.RandomAccessibleInterval) names = [ij().object().getName(i) for i in images] + # Ask the user to pick a layer choices: dict = request_values( - title="Send layers to ImageJ2", + title="Send layers to napari", dataset={"annotation": Enum, "options": {"choices": names}}, ) + # Parse the returned dict for the Layer selection if choices is not None: for _, name in choices.items(): i = names.index(name) @@ -108,6 +134,24 @@ def get_layers(self) -> List[Layer]: else: raise ValueError(f"{image} cannot be displayed in napari!") + def get_active_layer(self) -> None: + # Choose the active Dataset + image = ij().get("net.imagej.display.ImageDisplayService").getActiveDataset() + if image is None: + log_debug("There is no active window to export to napari") + return + # Get the stuff needed for a new layer + py_image = ij().py.from_java(image) + name = ij().object().getName(image) + # Create and add the layer + if isinstance(py_image, Layer): + py_image.name = name + self.viewer.add_layer(py_image) + elif ij().py._is_arraylike(py_image): + self.viewer.add_image(data=py_image, name=name) + else: + raise ValueError(f"{image} cannot be displayed in napari!") + class GUIButton(QPushButton): def __init__(self): @@ -126,11 +170,13 @@ def _set_icon(self, path: str): def _setup_headful(self): self._set_icon("resources/16x16-flat-disabled.png") self.setToolTip("Display ImageJ2 GUI (loading)") + def post_setup(): ensure_jvm_started() self._set_icon("resources/16x16-flat.png") self.setEnabled(True) self.setToolTip("Display ImageJ2 GUI") + Thread(target=post_setup).start() def _setup_headless(self): diff --git a/tests/conftest.py b/tests/conftest.py index 97a7be6d..7d5dba53 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import pytest from napari import Viewer +import napari_imagej.widget_IJ2 from napari_imagej.widget import ImageJWidget from napari_imagej.widget_IJ2 import GUIWidget @@ -17,7 +18,6 @@ def ij(): @pytest.fixture def imagej_widget(make_napari_viewer) -> Generator[ImageJWidget, None, None]: # Create widget - viewer: Viewer = make_napari_viewer() ij_widget: ImageJWidget = ImageJWidget(viewer) @@ -30,7 +30,24 @@ def imagej_widget(make_napari_viewer) -> Generator[ImageJWidget, None, None]: @pytest.fixture def gui_widget(make_napari_viewer) -> Generator[GUIWidget, None, None]: # Create widget + viewer: Viewer = make_napari_viewer() + widget: GUIWidget = GUIWidget(viewer) + yield widget + + # Cleanup -> Close the widget, trigger ImageJ shutdown + widget.close() + + +@pytest.fixture +def gui_widget_chooser(make_napari_viewer) -> Generator[GUIWidget, None, None]: + # monkeypatch settings + def mock_setting(value: str): + return {"imagej_installation": None, "choose_active_layer": False}[value] + + napari_imagej.widget_IJ2.setting = mock_setting + + # Create widget viewer: Viewer = make_napari_viewer() widget: GUIWidget = GUIWidget(viewer) diff --git a/tests/test_IJ2GUIWidget.py b/tests/test_IJ2GUIWidget.py index dd2ea523..14280e14 100644 --- a/tests/test_IJ2GUIWidget.py +++ b/tests/test_IJ2GUIWidget.py @@ -1,10 +1,12 @@ +from threading import Thread +from typing import Callable import numpy import pytest from napari.layers import Image from napari.viewer import current_viewer from qtpy.QtCore import Qt, QTimer from qtpy.QtGui import QPixmap -from qtpy.QtWidgets import QApplication, QDialog, QMessageBox, QPushButton, QHBoxLayout +from qtpy.QtWidgets import QApplication, QDialog, QHBoxLayout, QMessageBox, QPushButton from napari_imagej.setup_imagej import JavaClasses, running_headless from napari_imagej.widget_IJ2 import FromIJButton, GUIButton, GUIWidget, ToIJButton @@ -63,8 +65,13 @@ def test_widget_layout(gui_widget: GUIWidget): subwidgets = gui_widget.children() assert len(subwidgets) == 4 assert isinstance(subwidgets[0], QHBoxLayout) + assert isinstance(subwidgets[1], FromIJButton) + assert not subwidgets[1].isEnabled() + assert isinstance(subwidgets[2], ToIJButton) + assert not subwidgets[2].isEnabled() + assert isinstance(subwidgets[3], GUIButton) @@ -72,7 +79,7 @@ def test_widget_layout(gui_widget: GUIWidget): running_headless(), reason="Only applies when not running headlessly" ) def test_GUIButton_layout_headful(qtbot, ij, gui_widget: GUIWidget): - """Tests headless-specific settings of GUIButton""" + """Tests headful-specific settings of GUIButton""" button: GUIButton = gui_widget.gui_button expected: QPixmap = QPixmap("resources/16x16-flat.png") @@ -90,6 +97,20 @@ def test_GUIButton_layout_headful(qtbot, ij, gui_widget: GUIWidget): qtbot.waitUntil(ij.ui().isVisible) +def _button_popup_handler(qtbot, button: QPushButton, handler: Callable[[], None]) -> Thread: + # Grab the original window + original = QApplication.activeWindow() + # Start the handler in a new thread + t: Thread = Thread(target=handler) + t.start() + # Click the button to trigger the popup + qtbot.mouseClick(button, Qt.LeftButton, delay=1) + # Wait for the handler function to complete + t.join() + # Assert that we are back to the original window i.e. that the popup was handled + qtbot.waitUntil(lambda: QApplication.activeWindow() == original) + + @pytest.mark.skipif( not running_headless(), reason="Only applies when running headlessly" ) @@ -108,6 +129,7 @@ def test_GUIButton_layout_headless(qtbot, gui_widget: GUIWidget): # Test popup when running headlessly def handle_dialog(): + qtbot.waitUntil(lambda: isinstance(QApplication.activeWindow(), QMessageBox)) msg = QApplication.activeWindow() expected_text = ( "The ImageJ2 user interface cannot be opened " @@ -120,20 +142,17 @@ def handle_dialog(): assert Qt.RichText == msg.textFormat() assert Qt.TextBrowserInteraction == msg.textInteractionFlags() - assert isinstance(msg, QMessageBox) ok_button = msg.button(QMessageBox.Ok) qtbot.mouseClick(ok_button, Qt.LeftButton, delay=1) - original = QApplication.activeWindow() - QTimer.singleShot(100, handle_dialog) - qtbot.mouseClick(button, Qt.LeftButton, delay=1) - qtbot.waitUntil(lambda: QApplication.activeWindow() == original) + # Handle the popup from the button click + _button_popup_handler(qtbot, button, handler=handle_dialog) @pytest.mark.skipif( running_headless(), reason="Only applies when not running headlessly" ) -def test_data_to_ImageJ(qtbot, ij, gui_widget: GUIWidget): +def test_active_data_send(qtbot, ij, gui_widget: GUIWidget): button: ToIJButton = gui_widget.to_ij assert not button.isEnabled() @@ -146,6 +165,57 @@ def test_data_to_ImageJ(qtbot, ij, gui_widget: GUIWidget): image: Image = Image(data=sample_data, name="test_to") current_viewer().add_layer(image) + # Press the button, handle the Dialog + qtbot.mouseClick(button, Qt.LeftButton, delay=1) + + # Assert that the data is now in Fiji + active_display = ij.display().getActiveDisplay() + assert isinstance(active_display, jc.ImageDisplay) + assert "test_to" == active_display.getName() + + +@pytest.mark.skipif( + running_headless(), reason="Only applies when not running headlessly" +) +def test_active_data_receive(qtbot, ij, gui_widget: GUIWidget): + button: FromIJButton = gui_widget.from_ij + assert not button.isEnabled() + + # Show the button + qtbot.mouseClick(gui_widget.gui_button, Qt.LeftButton, delay=1) + qtbot.waitUntil(lambda: button.isEnabled()) + + # Add some data to ImageJ + sample_data = jc.ArrayImgs.bytes(10, 10, 10) + ij.ui().show("test_from", sample_data) + + # Press the button, handle the Dialog + assert 0 == len(button.viewer.layers) + qtbot.mouseClick(button, Qt.LeftButton, delay=1) + + # Assert that the data is now in napari + assert 1 == len(button.viewer.layers) + layer = button.viewer.layers[0] + assert isinstance(layer, Image) + assert (10, 10, 10) == layer.data.shape + + +@pytest.mark.skipif( + running_headless(), reason="Only applies when not running headlessly" +) +def test_chosen_data_send(qtbot, ij, gui_widget_chooser): + button: ToIJButton = gui_widget_chooser.to_ij + assert not button.isEnabled() + + # Show the button + qtbot.mouseClick(gui_widget_chooser.gui_button, Qt.LeftButton, delay=1) + qtbot.waitUntil(lambda: button.isEnabled()) + + # Add some data to the viewer + sample_data = numpy.ones((100, 100, 3)) + image: Image = Image(data=sample_data, name="test_to") + current_viewer().add_layer(image) + def handle_dialog(): qtbot.waitUntil(lambda: isinstance(QApplication.activeWindow(), QDialog)) dialog = QApplication.activeWindow() @@ -158,8 +228,7 @@ def handle_dialog(): pytest.fail("Could not find the Ok button!") # Press the button, handle the Dialog - QTimer.singleShot(100, handle_dialog) - qtbot.mouseClick(button, Qt.LeftButton, delay=1) + _button_popup_handler(qtbot, button, handler=handle_dialog) # Assert that the data is now in Fiji active_display = ij.display().getActiveDisplay() @@ -170,7 +239,7 @@ def handle_dialog(): @pytest.mark.skipif( running_headless(), reason="Only applies when not running headlessly" ) -def test_data_from_ImageJ(qtbot, ij, gui_widget: GUIWidget): +def test_chosen_data_receive(qtbot, ij, gui_widget: GUIWidget): button: FromIJButton = gui_widget.from_ij assert not button.isEnabled() @@ -195,8 +264,7 @@ def handle_dialog(): # Press the button, handle the Dialog assert 0 == len(button.viewer.layers) - QTimer.singleShot(100, handle_dialog) - qtbot.mouseClick(button, Qt.LeftButton, delay=1) + _button_popup_handler(qtbot, button, handler=handle_dialog) # Assert that the data is now in napari assert 1 == len(button.viewer.layers)