diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 4b7b4c11..b2392781 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -40,6 +40,10 @@ jobs: python-version: "3.11" steps: + # these libraries enable testing on Qt on linux + - uses: pyvista/setup-headless-display-action@v2 + with: + qt: true - name: Cache Test Data uses: actions/cache@v4 with: diff --git a/MANIFEST.in b/MANIFEST.in index 0fbedce8..d4a32d4d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ include LICENSE include *.md include CITATION.CFF +include movement/napari/napari.yaml exclude .pre-commit-config.yaml exclude .cruft.json diff --git a/docs/source/user_guide/installation.md b/docs/source/user_guide/installation.md index 404acceb..e813ac98 100644 --- a/docs/source/user_guide/installation.md +++ b/docs/source/user_guide/installation.md @@ -20,6 +20,8 @@ Create and activate an environment with movement installed: conda create -n movement-env -c conda-forge movement conda activate movement-env ``` +This will install all dependencies, including [napari](napari:), +which provides the GUI for movement. ::: :::{tab-item} Pip Create and activate an environment with some prerequisites: @@ -27,10 +29,15 @@ Create and activate an environment with some prerequisites: conda create -n movement-env -c conda-forge python=3.11 pytables conda activate movement-env ``` -Install the latest movement release from PyPI: +Install the core package from the latest release on PyPI: ```sh pip install movement ``` +If you wish to use the GUI, which additionally requires [napari](napari:), +you should instead run: +```sh +pip install movement[napari] +``` ::: :::: @@ -45,6 +52,16 @@ movement info You should see a printout including the version numbers of movement and some of its dependencies. +To test the GUI installation, you can run: + +```sh +napari -w movement +``` + +This should open a new `napari` window with the `movement` plugin loaded +on the right side. + + ## Update the package To update movement to the latest version, we recommend installing it in a new environment, as this prevents potential compatibility issues caused by changes in dependency versions. diff --git a/movement/napari/__init__.py b/movement/napari/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/movement/napari/_loader_widget.py b/movement/napari/_loader_widget.py new file mode 100644 index 00000000..7da5c3ce --- /dev/null +++ b/movement/napari/_loader_widget.py @@ -0,0 +1,32 @@ +from napari.utils.notifications import show_info +from napari.viewer import Viewer +from qtpy.QtWidgets import ( + QFormLayout, + QPushButton, + QWidget, +) + + +class Loader(QWidget): + """Widget for loading data from files.""" + + def __init__(self, napari_viewer: Viewer, parent=None): + """Initialize the loader widget.""" + super().__init__(parent=parent) + self.viewer = napari_viewer + self.setLayout(QFormLayout()) + # Create widgets + self._create_hello_widget() + + def _create_hello_widget(self): + """Create the hello widget. + + This widget contains a button that, when clicked, shows a greeting. + """ + hello_button = QPushButton("Say hello") + hello_button.clicked.connect(self._on_hello_clicked) + self.layout().addRow("Greeting", hello_button) + + def _on_hello_clicked(self): + """Show a greeting.""" + show_info("Hello, world!") diff --git a/movement/napari/_meta_widget.py b/movement/napari/_meta_widget.py new file mode 100644 index 00000000..3ed09575 --- /dev/null +++ b/movement/napari/_meta_widget.py @@ -0,0 +1,27 @@ +"""The main napari widget for the ``movement`` package.""" + +from brainglobe_utils.qtpy.collapsible_widget import CollapsibleWidgetContainer +from napari.viewer import Viewer + +from movement.napari._loader_widget import Loader + + +class MovementMetaWidget(CollapsibleWidgetContainer): + """The widget to rule all ``movement`` napari widgets. + + This is a container of collapsible widgets, each responsible + for handing specific tasks in the movement napari workflow. + """ + + def __init__(self, napari_viewer: Viewer, parent=None): + """Initialize the meta-widget.""" + super().__init__() + + self.add_widget( + Loader(napari_viewer, parent=self), + collapsible=True, + widget_title="Load data", + ) + + self.loader = self.collapsible_widgets[0] + self.loader.expand() # expand the loader widget by default diff --git a/movement/napari/napari.yaml b/movement/napari/napari.yaml new file mode 100644 index 00000000..15d956fd --- /dev/null +++ b/movement/napari/napari.yaml @@ -0,0 +1,10 @@ +name: movement +display_name: movement +contributions: + commands: + - id: movement.make_widget + python_name: movement.napari._meta_widget:MovementMetaWidget + title: movement + widgets: + - command: movement.make_widget + display_name: movement diff --git a/pyproject.toml b/pyproject.toml index 27348c29..c805d72e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,9 @@ classifiers = [ "License :: OSI Approved :: BSD License", ] +# Entry point for napari plugin +entry-points."napari.manifest".movement = "movement.napari:napari.yaml" + [project.urls] "Homepage" = "https://github.com/neuroinformatics-unit/movement" "Bug Tracker" = "https://github.com/neuroinformatics-unit/movement/issues" @@ -43,9 +46,18 @@ classifiers = [ "User Support" = "https://neuroinformatics.zulipchat.com/#narrow/stream/406001-Movement" [project.optional-dependencies] +napari = [ + "napari[all]>=0.4.19", + # the rest will be replaced by brainglobe-utils[qt]>=0.6 after release + "brainglobe-atlasapi>=2.0.7", + "brainglobe-utils>=0.5", + "qtpy", + "superqt", +] dev = [ "pytest", "pytest-cov", + "pytest-mock", "coverage", "tox", "mypy", @@ -58,6 +70,8 @@ dev = [ "check-manifest", "types-PyYAML", "types-requests", + "pytest-qt", + "movement[napari]", ] [project.scripts] @@ -156,6 +170,13 @@ conda_deps = pytables conda_channels = conda-forge +passenv = + CI + GITHUB_ACTIONS + DISPLAY + XAUTHORITY + NUMPY_EXPERIMENTAL_ARRAY_FUNCTION + PYVISTA_OFF_SCREEN extras = dev commands = diff --git a/tests/test_integration/test_napari_plugin.py b/tests/test_integration/test_napari_plugin.py new file mode 100644 index 00000000..e7225dc5 --- /dev/null +++ b/tests/test_integration/test_napari_plugin.py @@ -0,0 +1,61 @@ +import pytest +from qtpy.QtWidgets import QPushButton, QWidget + +from movement.napari._loader_widget import Loader +from movement.napari._meta_widget import MovementMetaWidget + + +@pytest.fixture +def meta_widget(make_napari_viewer_proxy) -> MovementMetaWidget: + """Fixture to expose the MovementMetaWidget for testing. + Simultaneously acts as a smoke test that the widget + can be instantiated without crashing. + """ + viewer = make_napari_viewer_proxy() + return MovementMetaWidget(viewer) + + +@pytest.fixture +def loader_widget(meta_widget) -> QWidget: + """Fixture to expose the Loader widget for testing.""" + loader = meta_widget.loader.content() + return loader + + +def test_meta_widget(meta_widget): + """Test that the meta widget is properly instantiated.""" + assert meta_widget is not None + assert len(meta_widget.collapsible_widgets) == 1 + + first_widget = meta_widget.collapsible_widgets[0] + assert first_widget._text == "Load data" + assert first_widget.isExpanded() + + +def test_loader_widget(loader_widget): + """Test that the loader widget is properly instantiated.""" + assert loader_widget is not None + assert loader_widget.layout().rowCount() == 1 + + +def test_hello_button_calls_on_hello_clicked(make_napari_viewer_proxy, mocker): + """Test that clicking the hello button calls _on_hello_clicked. + + Here we have to create a new Loader widget after mocking the method. + We cannot reuse the existing widget fixture because then it would be too + late to mock (the widget has already "decided" which method to call). + """ + mock_method = mocker.patch( + "movement.napari._loader_widget.Loader._on_hello_clicked" + ) + loader = Loader(make_napari_viewer_proxy) + hello_button = loader.findChildren(QPushButton)[0] + hello_button.click() + mock_method.assert_called_once() + + +def test_on_hello_clicked_outputs_message(loader_widget, capsys): + """Test that _on_hello_clicked outputs the expected message.""" + loader_widget._on_hello_clicked() + captured = capsys.readouterr() + assert "INFO: Hello, world!" in captured.out