From e3235c1007f0c507aa92aa441fdee8b1aa8714c0 Mon Sep 17 00:00:00 2001 From: Will Graham <32364977+willGraham01@users.noreply.github.com> Date: Wed, 10 Jul 2024 13:54:00 +0100 Subject: [PATCH] ARC dev branch (#4) * Pull sample blobs code * Strip away everything except the open-able widget * Some basic structures that track the existing layers * BROKEN: Image layers don't play nice but I did combine the emitter objects * HACK: Napari will now load up, but this hasn't really fixed the naming issue. I've just hacked in a missing attribute to all of the GroupLayer children - they need to have a Node mixin to behave properly * I can see when layers are added, and have some kind of list. Still get SEG-faults or index errors when moving groups within groups, particularly empty group into empty group. * Recognises add/remove layers from viewer and auto-adds to the group layers view. Still seg-fault bug when nesting two empty group layers * Rename classes to be closer to development language * Rename and move items to break things up * Link up group layer selection with layer tab * Reliable testing framework * Fix our failing test suite (#13) * Edit file name to be specific to our use case * Rename single test * Add checks on napari `main` (#18) * Add workflow step to run tests on head of napari * Add optional dependency on napari-latest which fetches napari from the git repo directly * Create an optional install that uses HEAD of napari, add it to list of tests * Add note that napari-latest install is available to README * Update README.md Co-authored-by: Alessandro Felder --------- Co-authored-by: Alessandro Felder * Add conftest and some fixtures for testing (#15) * Add conftest.py for shared fixtures and do a quick model check for our QtGroupLayerModel * Typehints don't carry through fixtures * Allow renaming of layers / group layers (#19) * allow renaming of groups and layers * keep prefix for group layers and update setRoot * remove unused tree_model * add initial tests for renaming * add test for editing state on double click * move tests into test_group_layer_widget * Correctly Subclass our `GroupLayer` (#20) * Ensure GroupLayer subclasses from our own subclass of Node * Define ambiguously inherited attributes (IE ensure I don't break KM's tests) * Actually use the nested structure fixture so ruff doesn't get angry * Reorganise the test suite fixture layout since it's starting to get bloated * Basic tests to check is_group and strict typing * Self-review * Allow _check_if_already_tracking to be optionally recursive. Write test for method. * Update src/napari_experimental/group_layer.py Co-authored-by: Kimberly Meechan <24316371+K-Meech@users.noreply.github.com> * Dammit linter --------- Co-authored-by: Kimberly Meechan <24316371+K-Meech@users.noreply.github.com> * Adds independent layer controls to widget (#22) * add group layer controls * fix controls for latest changes to group layer * remove syncing of selection * add tests for group layer controls * fix TypeError * expand docstrings * react to sub-groups * add overview and todo sections to readme (#31) * Sync GroupLayer and Main Viewer layer orders (#26) * Allow group_layer to return a flat index * Enforce layer order updates through the GroupLayer events * Rework how items are added to GroupLayers to make things more explicit * Remove unused function * Docstring Tidy (#27) * Some docstrings for _widget * Docstrings for GroupLayerNode class * docstrings for group_layer_qt * Docstrings for group_layer.py * Add attributes and methods to classes where necessary * Missed a rename * Add tests for widget activity * Test for layer deletion sync * Ruff for 3.9 disagrees with 3.11 * strict keyword not in Py3.9 * Update test_and_deploy.yml (#32) (#33) * Fix Errors when Moving Layers (#28) * Allow group_layer to return a flat index * Enforce layer order updates through the GroupLayer events * Rework how items are added to GroupLayers to make things more explicit * Remove unused function * Docstring Tidy (#27) * Some docstrings for _widget * Docstrings for GroupLayerNode class * docstrings for group_layer_qt * Docstrings for group_layer.py * Add attributes and methods to classes where necessary * Missed a rename * Write method that fixes the min failing example * Hey look, a docstring! * Remove double-counting of positions after destination * Test revise index method, remove redundant variable passed * Maybe this is correct if I have a flat index sort a priori * Fix ordering issue and allow for Groups to be assigned a flatindex * Add 2nd test to check moving items out of different subgroups still tracks order correctly * Remove un-necessary variable * Remove commented-out experimental code * Apply suggestions from @K-Meech code review * Add `Implementation` section to README (#34) * MDLint + alessandrofelder -> brainglobe in README * Create implementation deatils section * Group and Node subclassing explanations * Notes on indexing conventions * Update README.md Co-authored-by: Kimberly Meechan <24316371+K-Meech@users.noreply.github.com> --------- Co-authored-by: Kimberly Meechan <24316371+K-Meech@users.noreply.github.com> * Add right click menu and styling (#29) * add group layer delegate * rename group layer delegate * add context for right click menu * working minimal right click menu * fix syncing of selection * update docstrings and remove unused functions * fix double click edit * simplify right click actions and context to fix tests * update docstrings * Force widget in tests to have a parent (#35) * Allows toggling visibility of group layers (#30) * toggle visiblity for group layers * add tests for visiblity * update docstrings * fix failing tests * remove logger and thumbnail role * fix view controls when switching from group to layer --------- Co-authored-by: Will Graham <32364977+willGraham01@users.noreply.github.com> * `main` and `ARC-dev-branch` merge commit reconcile (#37) * Fix bugs relating to empty `GroupLayers` (#38) * Fix seg-faults and broken drag-and-drop via overwriting index method * Switch to unique ID trackers over reimplementing method. Allows safe overwride of __eq__ and __hash__ * Remove one bug from the README broken features section --------- Co-authored-by: Alessandro Felder * Add docs build and populate content (#39) * Basic Sphinx build and docs structure * Workflow to publish docs on pushes to main * Separate sphinx requirements from package requirements * Add building the docs section * Reorganise group_layers docs * Docs API for Groups & Nodes, some source docstring updates for text rendering * Placeholder for widget docs * Write widget docs page * Add note on hashing things * expand docs for group layers, delegates and context menu (#40) --------- Co-authored-by: Kimberly Meechan <24316371+K-Meech@users.noreply.github.com> --------- Co-authored-by: Alessandro Felder Co-authored-by: Kimberly Meechan <24316371+K-Meech@users.noreply.github.com> --- .github/workflows/build_docs.yaml | 29 + .github/workflows/test_and_deploy.yml | 28 +- .gitignore | 1 + MANIFEST.in | 5 + README.md | 157 +++-- docs/Makefile | 20 + docs/make.bat | 35 ++ docs/requirements.txt | 1 + docs/source/conf.py | 31 + .../classes/group_layer_and_nodes.md | 82 +++ docs/source/group_layers/classes/widget.md | 84 +++ docs/source/group_layers/implementation.md | 41 ++ docs/source/group_layers/index.md | 51 ++ docs/source/index.md | 70 +++ pyproject.toml | 18 +- src/napari_experimental/__init__.py | 12 +- src/napari_experimental/_widget.py | 271 +++++---- src/napari_experimental/group_layer.py | 571 ++++++++++++++++++ .../group_layer_actions.py | 105 ++++ .../group_layer_controls.py | 156 +++++ .../group_layer_delegate.py | 118 ++++ src/napari_experimental/group_layer_node.py | 87 +++ src/napari_experimental/group_layer_qt.py | 172 ++++++ src/napari_experimental/napari.yaml | 28 +- .../resources/folder-open.svg | 1 + src/napari_experimental/resources/folder.svg | 1 + src/napari_experimental/styles/tree.qss | 81 +++ tests/blobs.py | 27 + tests/conftest.py | 51 ++ tests/fixtures/__init__.py | 0 tests/fixtures/conftest_group_layers.py | 52 ++ tests/fixtures/conftest_layers.py | 79 +++ tests/test_group_layer.py | 328 ++++++++++ tests/test_group_layer_controls.py | 89 +++ tests/test_group_layer_qt_model.py | 12 + tests/test_group_layer_widget.py | 285 +++++++++ tests/test_widget.py | 65 -- 37 files changed, 2998 insertions(+), 246 deletions(-) create mode 100644 .github/workflows/build_docs.yaml create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/requirements.txt create mode 100644 docs/source/conf.py create mode 100644 docs/source/group_layers/classes/group_layer_and_nodes.md create mode 100644 docs/source/group_layers/classes/widget.md create mode 100644 docs/source/group_layers/implementation.md create mode 100644 docs/source/group_layers/index.md create mode 100644 docs/source/index.md create mode 100644 src/napari_experimental/group_layer.py create mode 100644 src/napari_experimental/group_layer_actions.py create mode 100644 src/napari_experimental/group_layer_controls.py create mode 100644 src/napari_experimental/group_layer_delegate.py create mode 100644 src/napari_experimental/group_layer_node.py create mode 100644 src/napari_experimental/group_layer_qt.py create mode 100644 src/napari_experimental/resources/folder-open.svg create mode 100644 src/napari_experimental/resources/folder.svg create mode 100644 src/napari_experimental/styles/tree.qss create mode 100644 tests/blobs.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/conftest_group_layers.py create mode 100644 tests/fixtures/conftest_layers.py create mode 100644 tests/test_group_layer.py create mode 100644 tests/test_group_layer_controls.py create mode 100644 tests/test_group_layer_qt_model.py create mode 100644 tests/test_group_layer_widget.py delete mode 100644 tests/test_widget.py diff --git a/.github/workflows/build_docs.yaml b/.github/workflows/build_docs.yaml new file mode 100644 index 0000000..521e1c5 --- /dev/null +++ b/.github/workflows/build_docs.yaml @@ -0,0 +1,29 @@ +on: + push: + branches: + - main + tags: + - '*' + pull_request: + workflow_dispatch: + +jobs: + build_sphinx_docs: + name: Build Sphinx Docs + runs-on: ubuntu-latest + steps: + - uses: neuroinformatics-unit/actions/build_sphinx_docs@main + with: + check-links: false + + deploy_sphinx_docs: + name: Deploy Sphinx Docs + needs: build_sphinx_docs + permissions: + contents: write + if: (github.event_name == 'push' || github.event_name == 'tag') && github.ref_name == 'main' + runs-on: ubuntu-latest + steps: + - uses: neuroinformatics-unit/actions/deploy_sphinx_docs@v2 + with: + secret_input: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 66fcdaf..6a25292 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -55,9 +55,35 @@ jobs: python-version: ${{ matrix.python-version }} secret-codecov-token: ${{ secrets.CODECOV_TOKEN }} + napari-latest-test: + needs: [linting, manifest] + name: Run tests using HEAD of napari main branch + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup qtpy libraries + uses: tlambert03/setup-qt-libs@v1 + + - name: Setup VTK headless display + uses: pyvista/setup-headless-display-action@v2 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: "pip" + + - name: Install tox + run: pip install tox + + - name: Run tox on napari-latest + run: tox -vv -e napari-latest + build_sdist_wheel: name: Build source distribution wheel - needs: [test] + needs: [test, napari-latest-test] if: github.event_name == 'push' && github.ref_type == 'tag' runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 73d56d3..63310cb 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ instance/ # Sphinx documentation docs/_build/ +docs/build # MkDocs documentation /site/ diff --git a/MANIFEST.in b/MANIFEST.in index 9ab4e2c..496a827 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,5 +6,10 @@ exclude .pre-commit-config.yaml recursive-exclude * __pycache__ recursive-exclude * *.py[co] +recursive-include src *.qss +recursive-include src *.svg + + prune .napari-hub +prune docs prune tests diff --git a/README.md b/README.md index 64a7562..7f90f0e 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,148 @@ # napari-experimental -[![License BSD-3](https://img.shields.io/pypi/l/napari-experimental.svg?color=green)](https://github.com/alessandrofelder/napari-experimental/raw/main/LICENSE) +[napari]: https://github.com/napari/napari +[file an issue]: https://github.com/brainglobe/napari-experimental/issues +[tox]: https://tox.readthedocs.io/en/latest/ +[pip]: https://pypi.org/project/pip/ + +[![License BSD-3](https://img.shields.io/pypi/l/napari-experimental.svg?color=green)](https://github.com/brainglobe/napari-experimental/raw/main/LICENSE) [![PyPI](https://img.shields.io/pypi/v/napari-experimental.svg?color=green)](https://pypi.org/project/napari-experimental) [![Python Version](https://img.shields.io/pypi/pyversions/napari-experimental.svg?color=green)](https://python.org) -[![tests](https://github.com/alessandrofelder/napari-experimental/workflows/tests/badge.svg)](https://github.com/alessandrofelder/napari-experimental/actions) -[![codecov](https://codecov.io/gh/alessandrofelder/napari-experimental/branch/main/graph/badge.svg)](https://codecov.io/gh/alessandrofelder/napari-experimental) +[![tests](https://github.com/brainglobe/napari-experimental/workflows/tests/badge.svg)](https://github.com/brainglobe/napari-experimental/actions) +[![codecov](https://codecov.io/gh/brainglobe/napari-experimental/branch/main/graph/badge.svg)](https://codecov.io/gh/brainglobe/napari-experimental) [![napari hub](https://img.shields.io/endpoint?url=https://api.napari-hub.org/shields/napari-experimental)](https://napari-hub.org/plugins/napari-experimental) -A place to experiment with core-independent features without breaking core napari +## Overview + +### Features + +This plugin allows [napari] layers to be organised into 'layer groups'. +Groups can hold any number of layers, in addition to further sub-groups. +This follows suggestions [from the corresponding issue](https://github.com/napari/napari/issues/6345) on the main napari repository. + +Main features: + +- Creation of group layers +- Drag and drop layers/groups to re-organise them +- Sync changes in layer order from the plugin to the main napari `LayerList` +- Toggle visibility of layers and entire groups through the 'eye' icon or right click menu + +### Ethos + +The aim of this plugin is to provide an entirely separate way to interact with layers in napari. +While using it, the main `layer list` should only be used to add/remove layers, with all re-ordering, re-naming etc done directly within the plugin itself. + +To aid this, the plugin contains its own independent layer controls, as well as right click menus and other features. + +### Outlook + +Ultimately, the goal of this plugin is to provide a way to experiment with group layers independent from the main napari codebase. +Hopefully, parts of this plugin widget will be incorporated back into napari, replacing the existing `LayerList`. + +## Implementation Details + +This section is a halfway point between full developer documentation and throwing docstrings in a contributor's face. +We have been diligent in adding docstrings to our methods, however we recommend you first read about [model/view programming](https://doc.qt.io/qt-6/model-view-programming.html) and in particular tree models and views. +We also recommend you read the docstrings for `Group`s and `Node`s [in the napari codebase](https://github.com/napari/napari/blob/main/napari/utils/tree), for context on how napari currently handles such structures. +You may also want to [review this article](https://refactoring.guru/design-patterns/composite) on composite design patterns. + +The docstrings, plus the explanations in this section, should then afford you enough information into how the plugin is operating. + +Additionally, the "enter debugger" button has been left within the plugin. +This is intended for developer use and should be removed when the plugin is ready for release! +Adding a breakpoint within the `GroupLayerWidget._enter_debug` method allows a developer to enter a debug context with the plugin's widget as `self`, whilst running napari. + +### Key Classes + +The key classes implemented in this plugin are + +- `GroupLayerNode`, `GroupLayer`: These are the Python classes that provide the tree-structure that group layers require. We expand on these below. +- `QtGroupLayerControls`, `QtGroupLayerControlsContainer`: These classes are used to build the "control box" that the plugin provides when selecting a layer within the plugin. For all intents and purposes it mimics the existing napari layer viewer context window, but also reacts when selecting a group layer. +- `QtGroupLayerModel`, `QtGroupLayerView`: These subclass from the appropriate Qt abstract classes, and provide the model/tree infrastructure for working with `GroupLayers`. Beyond this, they do not contain any remarkable functionality beyond patching certain methods for consistency with the data being handled / displayed. + +#### `Group`s and `Node`s ----------------------------------- +`GroupLayerNode` (GLN) and `GroupLayer` (GL) inherit from the base napari classes `Node` and `Group` (respectively). +The GLNs act as wrappers for `Layer`s - each one tracks (a pointer to) one particular `Layer` through the `.layer` attribute and uses its information when rendering in the GUI. +GLs allow us to organise GLNs into groups, but they themselves must also be instances of GLN. +For this reason, the convention we have adopted is that GLs have the `.layer` attribute set to `None` (since they do not track an individual layer that is associated to _them_ specifically). +Furthermore, the `is_group` method is explicitly defined on both GLNs and GLs, which provides a way to distinguish between the two classes when traversing the tree structure. -This [napari] plugin was generated with [Cookiecutter] using [@napari]'s [cookiecutter-napari-plugin] template. +It should also be noted that we have made the decision not to make GLNs inherit from `Layer`, nor make the existing `Layer` class subclass from `Node`. - +#### Differences in Indexing Conventions + +A key difference between a flat `LayerList` and our GL structure is how they are indexed, and how the structures are rendered in the napari viewer based on this indexing. +`LayerList` is indexed from 0 ascending, and renders in the napari viewer with the layer at index 0 at the _bottom_ of the `LayerList`. +`Layer`s with higher indices are rendered _on top_ of those with a lower index, with the item assigned the highest index appearing at the top of the layer viewer window. + +By contrast, GL uses `NestedIndex`es to track the position of GLNs; these are tuples of integers that can be of arbitrary length (so long as the GL has the structure to match), and should be interpreted as subsequent accesses of objects in the tree: + +- (0, 1) refers to the item at index 1 of the (sub)-GL at index 0 of the root GL. +- (2,) refers to the item at index 2 of the root GL. + +Note that an "item" can be a GLN or a GL (use the `is_group` method to distinguish if necessary)! +Again, items are added to a GL using the lowest available index. +GLs can also assign a flat index order to their elements by starting from the root tree and counting upwards from 0, descending into sub-GLs and exhausting their items when they are encountered (see `GroupLayer.flat_index_order`). + +However GLs render with index 0 _at the top_ of the view, and higher indices below them. +In order to preserve the user's intuitive understanding of "layers higher up in the display appear on top of lower layers", it is thus necessary to pass the _reversed_ order of layers back to the viewer after a rearrangement event in the GL viewer. + +## Todo + +### Desirable features + +- Expand right click menu options to include: + - Add selected items to new group + - Add selected items to existing group + - All existing right-click options for layers from the main `LayerList` +- Add independent buttons to add/remove layers to the plugin (this would make it fully independent of the main `LayerList`). Also, style the `Add empty layer group` button to match these. + +### Known bugs and issues (non-breaking, but poor UX) + +- The open/closed state of group layers doesn't persist on drag and drop + +### Known bugs and issues (breaking) + +### Development Tasks + +- Create a standalone docs site that expands on the implementation details section. + - Remove said section and transfer its contents to the docs site + - Link to the docs site in the README. ## Installation You can install `napari-experimental` via [pip]: - pip install napari-experimental +```bash +pip install napari-experimental +``` +To install the latest development version: +```bash +pip install git+https://github.com/brainglobe/napari-experimental.git +``` -To install latest development version : - - pip install git+https://github.com/alessandrofelder/napari-experimental.git +You can also install a version of the package that uses the latest version of napari (fetched from ): +```bash +pip install napari-experimental[napari-latest] +``` ## Contributing -Contributions are very welcome. Tests can be run with [tox], please ensure -the coverage at least stays the same before you submit a pull request. +Contributions are very welcome. +Tests can be run with [tox], please ensure the coverage at least stays the same before you submit a pull request. ## License -Distributed under the terms of the [BSD-3] license, -"napari-experimental" is free and open source software +Distributed under the terms of the [BSD-3] license, "napari-experimental" is free and open source software ## Issues If you encounter any problems, please [file an issue] along with a detailed description. - -[napari]: https://github.com/napari/napari -[Cookiecutter]: https://github.com/audreyr/cookiecutter -[@napari]: https://github.com/napari -[MIT]: http://opensource.org/licenses/MIT -[BSD-3]: http://opensource.org/licenses/BSD-3-Clause -[GNU GPL v3.0]: http://www.gnu.org/licenses/gpl-3.0.txt -[GNU LGPL v3.0]: http://www.gnu.org/licenses/lgpl-3.0.txt -[Apache Software License 2.0]: http://www.apache.org/licenses/LICENSE-2.0 -[Mozilla Public License 2.0]: https://www.mozilla.org/media/MPL/2.0/index.txt -[cookiecutter-napari-plugin]: https://github.com/napari/cookiecutter-napari-plugin - -[file an issue]: https://github.com/alessandrofelder/napari-experimental/issues - -[napari]: https://github.com/napari/napari -[tox]: https://tox.readthedocs.io/en/latest/ -[pip]: https://pypi.org/project/pip/ -[PyPI]: https://pypi.org/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..dc1312a --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..9e4694f --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1 @@ +myst_parser diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..0be9b65 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,31 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "napari-experimental" +copyright = "2024, Alessandro Felder, William Graham, Kimberly Meechan" +author = "Alessandro Felder, William Graham, Kimberly Meechan" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "myst_parser", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.napoleon", +] + +templates_path = ["_templates"] +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "alabaster" +html_static_path = ["_static"] diff --git a/docs/source/group_layers/classes/group_layer_and_nodes.md b/docs/source/group_layers/classes/group_layer_and_nodes.md new file mode 100644 index 0000000..baa84e7 --- /dev/null +++ b/docs/source/group_layers/classes/group_layer_and_nodes.md @@ -0,0 +1,82 @@ +# `GroupLayer`s and `GroupLayerNode`s + +`GroupLayerNode` (GLN) and `GroupLayer` (GL) inherit from the base napari classes `Node` and `Group` (respectively). +The GLNs act as wrappers for `Layer`s - each one tracks (a pointer to) one particular `Layer` through the `.layer` attribute and uses its information when rendering in the GUI. +GLs allow us to organise GLNs into groups, but they themselves must also be instances of GLN. +For this reason, the convention we have adopted is that GLs have the `.layer` attribute set to `None` (since they do not track an individual layer that is associated to _them_ specifically). +Furthermore, the `is_group` method is explicitly defined on both GLNs and GLs, which provides a way to distinguish between the two classes when traversing the tree structure. + +It should also be noted that we have made the decision not to make GLNs inherit from `Layer`, nor make the existing `Layer` class subclass from `Node`. + +- The latter would require changes to core napari code, so is outside the scope of this plugin. +- The former option then introduces event conflicts (within the constructors) when GL inherits from both `Group` and `Layer`. Again, fixing these issues requires either edits to the core napari codebase, or regurgitating a lot of the core napari codebase inside a new class for just a couple of line changes. +- From a separation of concerns perspective, it is cleaner to have the group layer functionality impose a structure on top of the existing `LayerList` that napari tracks, rather than duplicate this information so it can be stored in the GL, and then have to deal with changes to one of the objects being reflected back on the "original" it came from. + +```{contents} +--- +local: +--- +``` + +## Differences from the `LayerList` Indexing Convention + +A key difference between a flat `LayerList` and our GL structure is how they are indexed, and how the structures are rendered in the napari viewer based on this indexing. +`LayerList` is indexed from 0 ascending, and renders in the napari viewer with the layer at index 0 at the _bottom_ of the `LayerList`. +`Layer`s with higher indices are rendered _on top_ of those with a lower index, with the item assigned the highest index appearing at the top of the layer viewer window. + +By contrast, GL uses `NestedIndex`es to track the position of GLNs; these are tuples of integers that can be of arbitrary length (so long as the GL has the structure to match), and should be interpreted as subsequent accesses of objects in the tree: + +- (0, 1) refers to the item at index 1 of the (sub)-GL at index 0 of the root GL. +- (2,) refers to the item at index 2 of the root GL. + +Note that an "item" can be a GLN or a GL (use the `is_group` method to distinguish if necessary)! +Again, items are added to a GL using the lowest available index. +GLs can also assign a flat index order to their elements by starting from the root tree and counting upwards from 0, descending into sub-GLs and exhausting their items when they are encountered (see `GroupLayer.flat_index_order`). + +However GLs render with index 0 _at the top_ of the view, and higher indices below them. +In order to preserve the user's intuitive understanding of "layers higher up in the display appear on top of lower layers", it is thus necessary to pass the _reversed_ order of layers back to the viewer after a rearrangement event in the GL viewer. + +## Hashing and Equality + +`GroupLayer`s need to be hashable due to how the `QtGroupLayerControlsContainer` stores the references between a `GroupLayer` and its controls; which is a dictionary of `GroupLayer` keys to `GroupLayerControls` values. +However, `GroupLayer`s also need to be able to check for equality with other `GroupLayer` instances. +Since `GroupLayer`s attributes are inherently mutable, this presents some problems. + +The solution is that `GroupLayer` instances are assigned a unique integer ID (from 0 upwards) when they are created, and this value serves as the `__hash__` value of the instance. +Equality between `GroupLayer`s then checks that the IDs of the two (pointers to) instances are identical. +This is a safe property to use as the hash value since it fulfils all necessary requirements for a hash, however does lead to the rather odd behaviour that no two instances of `GroupLayer` will be "equal". +This in itself is a (semi-)desirable situation for us though: even if two `GroupLayer`s contain the same nested structure, name, etc, this does not mean they are identical in terms of how the user has decided to organise them within the tree structure. +However, care should be taken when comparing for identical `GroupLayer` instances - if you want to check equality of `GroupLayer` attributes, you will need to explicitly check these via `GroupLayer1. == GroupLayer2.`. + +## Propagation of selection + +Each `GroupLayer` within a tree has its own `.selection` containing a list of `GroupLayer` or `GroupLayerNode`. +These selected items can be at any level within the tree (not just restricted to direct children). + +To keep selection changes synced at all levels in the tree, the `GroupLayer`'s `propagate_selection` function is called every time the selection changes. +This propagates the selection to any nested `Group Layer`s inside of it. +Note that this propagation only happens in one direction (from parents to children)! +Also, only the selected items are currently updated with `propagate_selection`. +This means that, for example, the 'active' selected item might not be synced correctly to other levels in the tree. + +## API Reference + +### `GroupLayerNode` + +```{currentmodule} napari_experimental.group_layer_node +``` + +```{eval-rst} +.. autoclass:: GroupLayerNode + :members: +``` + +### `GroupLayer` + +```{currentmodule} napari_experimental.group_layer +``` + +```{eval-rst} +.. autoclass:: GroupLayer + :members: +``` diff --git a/docs/source/group_layers/classes/widget.md b/docs/source/group_layers/classes/widget.md new file mode 100644 index 0000000..55578b4 --- /dev/null +++ b/docs/source/group_layers/classes/widget.md @@ -0,0 +1,84 @@ +# The `GroupLayerWidget` + +```{currentmodule} napari_experimental._widget +``` + +The `GroupLayerWidget` instance is the parent of everything concerning the plugin. +It contains the `GroupLayer` data structure which informs the corresponding model and view, a reference to the standard `LayerList` (through it's `.global_layers` property), and is responsible for linking relevant events. + +```{contents} +--- +local: +--- +``` + +## Linking Events Back to the Main `LayerList` + +A number of the widget's functions and interactions with the event system are in place to ensure that the standard `LayerList` and the corresponding `GroupLayer` data structure do not go out of sync, as the napari viewer still uses the `LayerList` ordering to render the display. +As such, whilst the intention is that the user will not interact with the standard `LayerList` controls whilst using this plugin, the necessary functionality is there to ensure that anything they do does not introduce any breakages or inconsistencies: + +- Adding a new layer via the `LayerList` will result in it appearing in the `GroupLayer` view. Note that the new item in the `GroupLayer` tree will be placed in a position consistent with the ordering of the `LayerList`. +- Deleting a layer from either view will remove the corresponding item from the other. +- Re-ordering layers in the `GroupLayer` view will cause the `LayerList` to reorder, in turn updating the view. Note that the converse direction does not have changes applied, since there is an ambiguity when applying a reordering that does not care for the group structure. + +If this plugin gets incorporated into core napari, these functions will become obsolete since the plugin will replace the standard `LayerList` display. +The underlying `LayerList` will need to be preserved to store the `Layer`s themselves, but the user will no longer be able to interact with it. + +## Debugging + +The `tests/blobs.py` file in the repository contains a script that starts a napari instance with a few layers populated, and the plugin activated; + +```python +import os + +import napari +import numpy as np +from skimage import data + +os.environ["ALLOW_LAYERGROUPS"] = "1" + +points = np.random.rand(10, 3) * (1, 2, 3) * 100 +colors = np.random.rand(10, 3) + +blobs = data.binary_blobs(length=100, volume_fraction=0.05, n_dim=2) + +viewer = napari.Viewer(ndisplay=3) + +pl = napari.layers.Points(points, face_color=colors) +il = napari.layers.Image(blobs, scale=(1, 2), translate=(20, 15)) + +viewer.add_layer(pl) +viewer.add_layer(il) + +dock_widget, plugin_widget = viewer.window.add_plugin_dock_widget( + "napari-experimental", "Show Grouped Layers" +) + +napari.run() +``` + +Running this script using a debugger will allow you to place breakpoints in your code for testing features that you are adding or changing. +Additionally, the plugin still retains its "enter debugger" button and corresponding function (`GroupLayerWidget._enter_debug`) which can have a breakpoint placed within it to allow you to enter a debug context with the widget as `self`. + +If you are using VSCode, you can add the following configuration to your `launch.json` to launch the script above in the integrated debugger: + +```json +"configurations": [ + { + "name": "Python Debugger: Blobs.py", + "type": "debugpy", + "request": "launch", + "program": "tests/blobs.py", + "console": "integratedTerminal", + "justMyCode": false, + }, +] +``` + +## API Reference + +```{eval-rst} +.. autoclass:: napari_experimental._widget.GroupLayerWidget + :members: + :private-members: +``` diff --git a/docs/source/group_layers/implementation.md b/docs/source/group_layers/implementation.md new file mode 100644 index 0000000..20c4266 --- /dev/null +++ b/docs/source/group_layers/implementation.md @@ -0,0 +1,41 @@ +# Implementation + +```{contents} +--- +local: +--- +``` + +This section is intended as a bridge between the documentation and docstrings, and the implementation of the Group Layers plugin. +Before you begin, we recommend a few articles that provide an introduction to the concepts this plugin relies on: + +- [Model/view programming](https://doc.qt.io/qt-6/model-view-programming.html), and in particular tree models and views. +- We recommend you read the docstrings for `Group`s and `Node`s [in the napari codebase](https://github.com/napari/napari/blob/main/napari/utils/tree), for context on how napari currently handles such structures. +- You may also want to [review this article](https://refactoring.guru/design-patterns/composite) on composite design patterns. + +The docstrings, plus the explanations in this section, should then afford you enough information into how the plugin is operating. + +Additionally, the "enter debugger" button has been left within the plugin. +This is intended for developer use and should be removed when the plugin is ready for release! +Adding a breakpoint within the `GroupLayerWidget._enter_debug` method allows a developer to enter a debug context with the plugin's widget as `self`, whilst running napari. + +## Key Classes + +The key classes implemented in this plugin are + +- `GroupLayerNode`, `GroupLayer`: These are the Python classes that provide the tree-structure that group layers require. We expand on these below. +- `QtGroupLayerControls`, `QtGroupLayerControlsContainer`: These classes are used to build the "control box" that the plugin provides when selecting a layer within the plugin. For all intents and purposes it mimics the existing napari layer viewer context window, but also reacts when selecting a group layer. +- `QtGroupLayerModel`, `QtGroupLayerView`: These subclass from the appropriate Qt abstract classes, and provide the model/tree infrastructure for working with `GroupLayers`. Beyond this, they do not contain any remarkable functionality beyond patching certain methods for consistency with the data being handled / displayed. +- `GroupLayerDelegate`: handles display of thumbnails / icons on layers and group layers, as well as displaying the right click context menu. +- `GroupLayerActions`, `ContextMenu`: These classes are used to build the right click context menu. `GroupLayerActions` can be expanded to add more options to this menu. + +You can navigate to the respective pages for further information on each of these classes: + +```{toctree} +--- +maxdepth: 1 +--- + +classes/group_layer_and_nodes +classes/widget +``` diff --git a/docs/source/group_layers/index.md b/docs/source/group_layers/index.md new file mode 100644 index 0000000..610aedc --- /dev/null +++ b/docs/source/group_layers/index.md @@ -0,0 +1,51 @@ +# Group Layers + +This plugin allows [napari](https://github.com/napari/napari) layers to be organised into 'layer groups'. +Groups can hold any number of layers, in addition to further sub-groups. +This follows suggestions [from the corresponding issue](https://github.com/napari/napari/issues/6345) on the main napari repository. + +Main features: + +- Creation of group layers +- Drag and drop layers/groups to re-organise them +- Sync changes in layer order from the plugin to the main napari `LayerList` +- Toggle visibility of layers and entire groups through the 'eye' icon or right click menu + +```{toctree} +--- +maxdepth: 1 +caption: More information about this plugin: +--- +implementation +``` + +## Ethos and Outlook + +The aim of this plugin is to provide an entirely separate way to interact with layers in napari. +While using it, the main `layer list` should only be used to add/remove layers, with all re-ordering, re-naming etc done directly within the plugin itself. +To aid this, the plugin contains its own independent layer controls, as well as right-click menus and other features. + +Ultimately, the goal of this plugin is to provide a way to experiment with group layers independent from the main napari codebase. +Hopefully, parts of this plugin widget will be incorporated back into napari, replacing the existing `LayerList`. + +## Installation + +After installing `napari-experimental`, select the "Show Group Layers" plugin from the dropdown menu in `napari`. + +## Todo + +### Desirable features + +- Expand right click menu options to include: + - Add selected items to new group + - Add selected items to existing group + - All existing right-click options for layers from the main `LayerList` +- Add independent buttons to add/remove layers to the plugin (this would make it fully independent of the main `LayerList`). Also, style the `Add empty layer group` button to match these. + +### Known bugs and issues (non-breaking, but poor UX) + +- The open/closed state of group layers doesn't persist on drag and drop + +### Known bugs and issues (breaking) + +:partying_face: diff --git a/docs/source/index.md b/docs/source/index.md new file mode 100644 index 0000000..9b8ef84 --- /dev/null +++ b/docs/source/index.md @@ -0,0 +1,70 @@ +# napari-experimental + +```{contents} +--- +local: +--- +``` + +`napari-experimental` is a project that allows for experimental implementation of non-core napari features, separate from the napari codebase. +New features are implemented as plugins, with a view to later being incorporated into the core napari code once the features they provide are stable and widely-used enough to warrant integration. + +At present, the project provides the following plugins: + +```{toctree} +--- +maxdepth: 2 +--- + +group_layers/index +``` + +## Installation + +You can install `napari-experimental` via [pip](https://pypi.org/project/pip/): + +```bash +pip install napari-experimental +``` + +Or to install the latest development version: + +```bash +pip install git+https://github.com/brainglobe/napari-experimental.git +``` + +You can also install a version of the package that uses the latest version of napari (fetched from ): + +```bash +pip install napari-experimental[napari-latest] +``` + +## Building the Documentation + +You can build the documentation locally by (in your desired Python environment); + +- Installing [sphinx](https://www.sphinx-doc.org/en/master/) via `pip install sphinx`. +- Installing the documentation requirements listed in `docs/requirements.txt`, via `pip install -r docs/requirements.txt`. +- Running (at the repository root), `sphinx-build -M html docs/source docs/build`. +- This will place the rendered documentation in `.html` format inside `docs/build.html`. + +CI will check that the documentation can always be built successfully whenever a PR is opened and on a push to `main`. +When a new version is released (tag on `main`) or a push to `main` occurs with changes to the documentation, it will be rebuilt and published to the repository's github pages site. + +## Contributing + +You can report bugs and request features by [raising an issue](https://github.com/brainglobe/napari-experimental/issues/) on the GitHub repository - we have a few templates to help you fill out an issue if you haven't done so before. + +We follow the same [contribution guidelines](https://napari.org/stable/developers/index.html) as the napari project. +Contributions are welcome to any of the plugins via pull request, and will be subject to review from appropriate maintainers. +Tests can be run with [tox](https://tox.readthedocs.io/en/latest/), please ensure the coverage at least stays the same before you submit a pull request. + +## License + +Distributed under the terms of the [BSD-3] license, "napari-experimental" is free and open source software + +## Indices and Tables + +- {ref}`genindex` +- {ref}`modindex` +- {ref}`search` diff --git a/pyproject.toml b/pyproject.toml index afa1fc5..2e377cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,16 @@ dynamic = ["version"] napari-experimental = "napari_experimental:napari.yaml" [project.optional-dependencies] -dev = ["tox", "pytest", "pytest-cov", "pytest-qt"] +dev = [ + "sphinx", + "tox", + "pytest", + "pytest-cov", + "pytest-qt", + "pytest-mock", + "pre-commit", +] +napari-latest = ["napari @ git+https://github.com/napari/napari.git"] [project.urls] "Bug Tracker" = "https://github.com/alessandrofelder/napari-experimental/issues" @@ -66,7 +75,9 @@ fix = true [tool.tox] legacy_tox_ini = """ [tox] -envlist = py{39,310,311}-{linux,macos,windows} +envlist = + py{39,310,311}-{linux,macos,windows}, + napari-latest isolated_build=true [gh-actions] @@ -96,4 +107,7 @@ passenv = extras = dev commands = pytest -v --color=yes --cov=napari_experimental --cov-report=xml + +[testenv:napari-latest] +extras = dev, napari-latest """ diff --git a/src/napari_experimental/__init__.py b/src/napari_experimental/__init__.py index 1dc0236..3fab8c6 100644 --- a/src/napari_experimental/__init__.py +++ b/src/napari_experimental/__init__.py @@ -1,13 +1,5 @@ from ._widget import ( - ExampleQWidget, - ImageThreshold, - threshold_autogenerate_widget, - threshold_magic_widget, + GroupLayerWidget, ) -__all__ = ( - "ExampleQWidget", - "ImageThreshold", - "threshold_autogenerate_widget", - "threshold_magic_widget", -) +__all__ = ("GroupLayerWidget",) diff --git a/src/napari_experimental/_widget.py b/src/napari_experimental/_widget.py index b4f9058..f384820 100644 --- a/src/napari_experimental/_widget.py +++ b/src/napari_experimental/_widget.py @@ -1,129 +1,184 @@ """ -This module contains four napari widgets declared in -different ways: - -- a pure Python function flagged with `autogenerate: true` - in the plugin manifest. Type annotations are used by - magicgui to generate widgets for each parameter. Best - suited for simple processing tasks - usually taking - in and/or returning a layer. -- a `magic_factory` decorated function. The `magic_factory` - decorator allows us to customize aspects of the resulting - GUI, including the widgets associated with each parameter. - Best used when you have a very simple processing task, - but want some control over the autogenerated widgets. If you - find yourself needing to define lots of nested functions to achieve - your functionality, maybe look at the `Container` widget! -- a `magicgui.widgets.Container` subclass. This provides lots - of flexibility and customization options while still supporting - `magicgui` widgets and convenience methods for creating widgets - from type annotations. If you want to customize your widgets and - connect callbacks, this is the best widget option for you. -- a `QWidget` subclass. This provides maximal flexibility but requires - full specification of widget layouts, callbacks, events, etc. - -References: -- Widget specification: https://napari.org/stable/plugins/guides.html?#widgets -- magicgui docs: https://pyapp-kit.github.io/magicgui/ - -Replace code below according to your needs. """ from typing import TYPE_CHECKING -from magicgui import magic_factory -from magicgui.widgets import CheckBox, Container, create_widget -from qtpy.QtWidgets import QHBoxLayout, QPushButton, QWidget -from skimage.util import img_as_float +from napari.components import LayerList +from napari.utils.events import Event +from qtpy.QtWidgets import QPushButton, QVBoxLayout, QWidget + +from napari_experimental.group_layer import GroupLayer +from napari_experimental.group_layer_controls import ( + QtGroupLayerControlsContainer, +) +from napari_experimental.group_layer_qt import ( + QtGroupLayerView, +) if TYPE_CHECKING: import napari -# Uses the `autogenerate: true` flag in the plugin manifest -# to indicate it should be wrapped as a magicgui to autogenerate -# a widget. -def threshold_autogenerate_widget( - img: "napari.types.ImageData", - threshold: "float", -) -> "napari.types.LabelsData": - return img_as_float(img) > threshold +class GroupLayerWidget(QWidget): + """ + Main plugin widget for interacting with GroupLayers. + Parameters + ---------- + viewer : napari.viewer.Viewer + Main viewer instance containing (in particular) the LayerList. + """ -# the magic_factory decorator lets us customize aspects of our widget -# we specify a widget type for the threshold parameter -# and use auto_call=True so the function is called whenever -# the value of a parameter changes -@magic_factory( - threshold={"widget_type": "FloatSlider", "max": 1}, auto_call=True -) -def threshold_magic_widget( - img_layer: "napari.layers.Image", threshold: "float" -) -> "napari.types.LabelsData": - return img_as_float(img_layer.data) > threshold - + @property + def global_layers(self) -> LayerList: + return self.viewer.layers -# if we want even more control over our widget, we can use -# magicgui `Container` -class ImageThreshold(Container): def __init__(self, viewer: "napari.viewer.Viewer"): super().__init__() - self._viewer = viewer - # use create_widget to generate widgets from type annotations - self._image_layer_combo = create_widget( - label="Image", annotation="napari.layers.Image" + + self.viewer = viewer + + self.group_layers = GroupLayer(*self.global_layers.__reversed__()) + self.group_layers_view = QtGroupLayerView( + self.group_layers, parent=self ) - self._threshold_slider = create_widget( - label="Threshold", annotation=float, widget_type="FloatSlider" + self.group_layers_controls = QtGroupLayerControlsContainer( + self.viewer, self.group_layers ) - self._threshold_slider.min = 0 - self._threshold_slider.max = 1 - # use magicgui widgets directly - self._invert_checkbox = CheckBox(text="Keep pixels below threshold") - # connect your own callbacks - self._threshold_slider.changed.connect(self._threshold_im) - self._invert_checkbox.changed.connect(self._threshold_im) + # Consistency when adding / removing layers from main viewer, + # changes are reflected in the GroupLayer view too + self.global_layers.events.inserted.connect( + self._new_layer_in_main_viewer + ) + self.global_layers.events.removed.connect( + self._removed_layer_in_main_viewer + ) + self.group_layers.events.removed.connect( + self._removed_layer_in_group_layers + ) + # Impose layer order whenever layers get moved + self.group_layers.events.moved.connect(self._on_layer_moved) + + self.add_group_button = QPushButton("Add empty layer group") + self.add_group_button.clicked.connect(self._new_layer_group) + + self.enter_debugger = QPushButton("ENTER DEBUGGER") + self.enter_debugger.clicked.connect(self._enter_debug) + + self.setLayout(QVBoxLayout()) + self.layout().addWidget(self.group_layers_controls) + self.layout().addWidget(self.enter_debugger) + self.layout().addWidget(self.add_group_button) + self.layout().addWidget(self.group_layers_view) + + def _new_layer_group(self) -> None: + """ + Action taken when creating a new, empty layer group in the widget. + """ + self.group_layers.add_new_group() + + # BEGIN FUNCTIONS TO ENSURE CONSISTENCY BETWEEN + # MAIN VIEWER AND GROUP LAYERS VIEWER. + # Functions in this block are un-necessary if the + # group layers widget later replaces the main layer viewer. + + def _new_layer_in_main_viewer(self, event: Event) -> None: + """ + When a new layer is added via the global_layers controls, + the GroupLayers instance needs to update to include it. + + The new layer is inserted into the appropriate position in + the Tree. + + Parameters + ---------- + event : Event + With attributes + - index (in the LayerList the Layer was inserted), + - value (layer that was inserted). + """ + # New layers in the main viewer can come in either at the top, + # or next to another layer (from duplications, etc). + # As such, we need to take the index that the layer was inserted + # at in the LayerList, and insert the new layer into our GroupLayer + # structure in the correct position. + old_order = self.group_layers.flat_index_order() + # Due to LayerList and Tree structures having reversed indices + insert_at_flat_index = len(old_order) - event.index + # Potentially could have been added to the end of the tree, in which + # case a new index would have to be created at the end of the tree + # and there would be no nested index to lookup + insert_at_nested_index = ( + old_order[insert_at_flat_index] + if insert_at_flat_index < len(old_order) + else len(self.group_layers) + ) + self.group_layers.add_new_layer( + layer_ptr=event.value, location=insert_at_nested_index + ) - # append into/extend the container with your widgets - self.extend( + def _on_layer_moved(self, event: Event) -> None: + """ + Actions taken when Nodes with the GroupLayer object are reordered: + - Impose layer order on the main viewer after an update. + + Parameters + ---------- + event : Event + Unused, but contains the old and new indices of the moved item. + """ + new_order = self.group_layers.flat_index_order() + # Since the LayerList viewer indexes in the reverse to our Tree model, + # we must reverse the order provided. + new_order.reverse() + + for new_position, layer in enumerate( [ - self._image_layer_combo, - self._threshold_slider, - self._invert_checkbox, + self.group_layers[nested_index].layer + for nested_index in new_order ] - ) - - def _threshold_im(self): - image_layer = self._image_layer_combo.value - if image_layer is None: - return - - image = img_as_float(image_layer.data) - name = image_layer.name + "_thresholded" - threshold = self._threshold_slider.value - if self._invert_checkbox.value: - thresholded = image < threshold - else: - thresholded = image > threshold - if name in self._viewer.layers: - self._viewer.layers[name].data = thresholded - else: - self._viewer.add_labels(thresholded, name=name) - - -class ExampleQWidget(QWidget): - # your QWidget.__init__ can optionally request the napari viewer instance - # use a type annotation of 'napari.viewer.Viewer' for any parameter - def __init__(self, viewer: "napari.viewer.Viewer"): - super().__init__() - self.viewer = viewer - - btn = QPushButton("Click me!") - btn.clicked.connect(self._on_click) - - self.setLayout(QHBoxLayout()) - self.layout().addWidget(btn) - - def _on_click(self): - print("napari has", len(self.viewer.layers), "layers") + ): + currently_at = self.global_layers.index(layer) + self.global_layers.move(currently_at, dest_index=new_position) + + def _removed_layer_in_main_viewer(self, event: Event) -> None: + """ + Action taken when a layer is removed using the main LayerList + viewer. + + Parameters + ---------- + event : Event + Emitted event with attributes + - index (of removed layer in LayerList), + - value (the layer that was removed). + """ + self.group_layers.remove_layer_item(layer_ptr=event.value) + + def _removed_layer_in_group_layers(self, event: Event) -> None: + """ + Action taken when a layer is removed using the GroupLayers view. + + Parameters + ---------- + event : Event + Emitted event with attributes + - index (of the layer that was removed), + - value (the layer that was removed). + """ + layer_to_remove = event.value.layer + if layer_to_remove in self.global_layers: + self.global_layers.remove(layer_to_remove) + + # END FUNCTION BLOCK + + def _enter_debug(self) -> None: + """ + Placeholder method that allows the developer to + enter a DEBUG context with the widget as self. + + Place a breakpoint at the pass statement below to + utilise. + """ + pass diff --git a/src/napari_experimental/group_layer.py b/src/napari_experimental/group_layer.py new file mode 100644 index 0000000..59ab897 --- /dev/null +++ b/src/napari_experimental/group_layer.py @@ -0,0 +1,571 @@ +from __future__ import annotations + +import random +import string +from collections import defaultdict +from typing import Dict, Iterable, List, Literal, Optional + +from napari.layers import Layer +from napari.utils.events import Event +from napari.utils.events.containers._nested_list import ( + NestedIndex, + split_nested_index, +) +from napari.utils.translations import trans +from napari.utils.tree import Group + +from napari_experimental.group_layer_node import GroupLayerNode + + +def random_string(str_length: int = 5) -> str: + return "".join( + random.choices(string.ascii_uppercase + string.digits, k=str_length) + ) + + +class GroupLayer(Group[GroupLayerNode], GroupLayerNode): + """ + A Group item for a tree-like data structure whose nodes have a dedicated + attribute for tracking a single Layer. See ``napari.utils.tree`` for more + information about Nodes and Groups. + + GroupLayers are the "complex" component of the Tree structure that is used + to organise Layers into Groups. A GroupLayer contains GroupLayerNodes and + other GroupLayers (which are, in particular, a subclass of GroupLayerNode). + By convention, GroupLayers themselves do not track individual Layers, and + hence their ``.layer`` property is always set to ``None``. Contrastingly, + their ``.is_group()`` method always returns ``True`` compared to a + GroupLayerNode's method returning ``False``. + + Since the Nodes in the tree map 1:1 with the Layers, the docstrings and + comments within this class often use the words interchangeably. This may + give rise to phrases such as "Layers in the model" even though - strictly + speaking - there are no Layers in the model, only GroupLayerNodes which + track the Layers. Such phrases should be taken to mean "Layers which are + tracked by one GroupLayerNode in the model", and typically serve to save on + the verbosity of comments. In places where this may give rise to ambiguity, + the precise language is used. + + Parameters + ---------- + *items_to_include : Layer | GroupLayerNode | GroupLayer + Items to be added (in the order they are given as arguments) to the + Group when it is instantiated. Layers will have a Node created to + track them, GroupLayerNodes will simply be added to the GroupLayer, + as will other GroupLayers. + """ + + __next_uid: int = -1 + _uid: int + + @property + def name(self) -> str: + """ + Name of the GroupLayer. + """ + return self._name + + @name.setter + def name(self, value: str) -> None: + self._name = value + + @property + def visible(self): + return self._visible + + @visible.setter + def visible(self, value: bool) -> None: + for item in self.traverse(): + if item.is_group(): + item._visible = value + else: + item.layer.visible = value + self._visible = value + + @property + def uid(self) -> int: + """ + Unique ID of this instance of GroupLayer. + Assigned on instantiation and cannot be overwritten. + """ + return self._uid + + def __eq__(self, other: GroupLayer) -> bool: + """ + GroupLayers are only equal if we are pointing to the same object. + """ + return isinstance(other, GroupLayer) and self.uid == other.uid + + def __hash__(self) -> int: + """ + Since GroupLayers are assigned a unique ID on creation, we can use + this value as the hash of a particular instance. + """ + return self._uid + + def __init__( + self, + *items_to_include: Layer | GroupLayerNode | GroupLayer, + ): + # Assign me a unique uid + self._uid = GroupLayer._next_uid() + # Python seems to understand that since GroupLayerNode inherits from + # Node, and Group also inherits from Node, that GroupLayerNode + # "wins". + # We have to be careful about calling super() on methods inherited from + # Node though. + GroupLayerNode.__init__(self, layer_ptr=None) + assert self.layer is None, ( + "GroupLayers do not track individual " + "layers through the .layer property." + ) + + items_after_casting_layers = [ + GroupLayerNode(item) if isinstance(item, Layer) else item + for item in items_to_include + ] + assert all( + isinstance(item, GroupLayerNode) + for item in items_after_casting_layers + ), ( + "GroupLayers can only contain " + "GroupLayerNodes (or other GroupLayers)." + ) + Group.__init__( + self, + children=items_after_casting_layers, + name=random_string(), + basetype=GroupLayerNode, + ) + + # If selection changes on this node, propagate changes to any children + self.selection.events.changed.connect(self.propagate_selection) + + # Default to group being visible + self._visible = True + + @classmethod + def _next_uid(cls) -> int: + """ + Return the next free unique ID that can be assigned to an instance of + this class, then increment the counter to the next available index. + """ + cls.__next_uid += 1 + return cls.__next_uid + + @staticmethod + def _revise_indices_based_on_previous_moves( + original_index: NestedIndex, + original_dest: NestedIndex, + previous_moves: Dict[NestedIndex, List[int]], + ) -> NestedIndex: + """ + Intended for use during the multi_move process. + Given an index referencing a position in the tree prior to the + start of a multi-move, return the new index of the original + object that was referenced. + + Parameters + ---------- + original_index : NestedIndex + The original index of the item in the tree. + original_dest : NestedIndex + The original destination index of the item. + previous_moves : Dict[NestedIndex, List[int]] + A dictionary whose keys are indices of groups that have had items + moved prior to this one, and whose values are the indices in that + group which were moved. All indices (for Groups and Nodes) should + use the original indexing convention (prior to the application of + any moves). + """ + moves_prior_to_this_one = sum( + len(list_of_indices) for list_of_indices in previous_moves.values() + ) + revised_index = list(original_index) + for ii in range(len(revised_index)): + examining = original_index[: ii + 1] + nested_group_ind, nested_ind = split_nested_index(examining) + old_position_in_this_group = nested_ind + + if nested_group_ind in previous_moves: + # Every move that took out an item above this one will decrease + # the effective index by 1 + nested_ind -= sum( + 1 + for index in previous_moves[nested_group_ind] + if index < old_position_in_this_group + ) + # If this item lives in the same group as the destination index, + # and it is BELOW or AT the destination index, it will now have + # moved down a number of indices equal to the number of previous + # moves. + orig_dest_group_ind, orig_dest_ind = split_nested_index( + original_dest + ) + if ( + nested_group_ind == orig_dest_group_ind + and old_position_in_this_group >= orig_dest_ind + ): + nested_ind += moves_prior_to_this_one + + # Update this part of the group index with its new position + revised_index[ii] = nested_ind + return tuple(revised_index) + + def _add_new_item( + self, + item_type: Literal["Node", "Group"], + location: Optional[NestedIndex | int] = None, + layer_ptr: Optional[Layer] = None, + group_items: Optional[ + Iterable[Layer | GroupLayer | GroupLayerNode] + ] = None, + ) -> None: + """ + Abstract method handling the addition of Nodes and Groups to the + tree structure. See also `add_new_layer` and `add_new_group`. + + Parameters + ---------- + item_type: Literal + The type of item to add to the tree. + Must be one of: "Node", "Group". + location: NestedIndex | int, optional + Location in the tree to insert the new item. + Items are added to the end of the top level of the tree by default. + layer_ptr: Layer, optional + If creating a new Node, the Layer that the Node should track. + group_items: Iterable[GroupLayer | GroupLayerNode], optional + If creating a new Group, the items that should be added to said + Group upon its creation. + """ + if item_type not in ["Node", "Group"]: + raise ValueError( + f"Unknown item type to insert into Tree: {item_type} " + "(expected 'Node' or 'Group')" + ) + if location is None: + location = () + insert_to_group, insertion_index = split_nested_index(location) + + insertion_group = ( + self if not insert_to_group else self[insert_to_group] + ) + if not insertion_group.is_group(): + raise ValueError( + f"Item at {insert_to_group} is not a Group, " + "so cannot have an item inserted!" + ) + if insertion_index == -1: + insertion_index = len(insertion_group) + + if item_type == "Node": + if layer_ptr is None: + raise ValueError( + "A Layer must be provided when " + "adding a Node to the GroupLayers tree." + ) + elif insertion_group.check_already_tracking(layer_ptr=layer_ptr): + raise RuntimeError( + f"Group {insertion_group} is already tracking {layer_ptr}" + ) + insertion_group.insert( + insertion_index, GroupLayerNode(layer_ptr=layer_ptr) + ) + elif item_type == "Group": + if group_items is None: + group_items = () + insertion_group.insert(insertion_index, GroupLayer(*group_items)) + + def _move_plan( + self, sources: Iterable[NestedIndex], dest_index: NestedIndex + ): + """Prepare indices for a multi-move. + + Given a set of ``sources`` from anywhere in the list, + and a single ``dest_index``, this function computes and yields + ``(from_index, to_index)`` tuples that can be used sequentially in + single move operations. It keeps track of what has moved where and + updates the source and destination indices to reflect the model at each + point in the process. + + This is useful for a drag-drop operation with a QtModel/View. + + Parameters + ---------- + sources : Iterable[NestedIndex] + An iterable of NestedIndex that should be moved to ``dest_index``. + dest_index : Tuple[int] + The destination for sources. + """ + if isinstance(dest_index, slice): + raise TypeError( + trans._( + "Destination index may not be a slice", + deferred=True, + ) + ) + + to_move: List[NestedIndex] = [] + for idx in sources: + if isinstance(idx, tuple): + to_move.append(idx) + elif isinstance(idx, int): + to_move.append((idx,)) + elif isinstance(idx, slice): + raise NotImplementedError( + "Slices should not be sent to a " + "GroupLayer when multi-moving" + ) + to_move.extend(list(range(*idx.indices(len(self))))) + else: + raise TypeError( + trans._( + "Can only move NestedIndices indices and ints which " + "can be cast to NestedIndices, not {t}", + deferred=True, + t=type(idx), + ) + ) + + # (Relative) flat index order must be preserved when moving multiple + # items, so sort the order of the sources here to ensure consistency. + flat_order = self.flat_index_order(include_groups=True) + to_move = sorted(to_move, key=lambda x: flat_order.index(x)) + + dest_group_ind, dest_ind = split_nested_index(dest_index) + dest_group = self[dest_group_ind] + assert dest_group.is_group() + if dest_ind < 0: + dest_ind += len(dest_group) + 1 + dest_index = dest_group_ind + (dest_ind,) + # dest_group_ind + (dest_ind,) is now the target insertion point for + # the first item. + + previous_moves = defaultdict(list) + for src in to_move: + revised_source = self._revise_indices_based_on_previous_moves( + original_index=src, + original_dest=dest_index, + previous_moves=previous_moves, + ) + revised_dest = self._revise_indices_based_on_previous_moves( + original_index=dest_index, + original_dest=dest_index, + previous_moves=previous_moves, + ) + yield revised_source, revised_dest + + # Record that a move occurred in the source group. + # Note that the sum of the lengths of the values of this + # dict is equal to the number of moves in the multi-move + # we have done so far. + previous_moves[src[:-1]].append(src[-1]) + + def _node_name(self) -> str: + """Will be used when rendering node tree as string.""" + return f"GL-{self.name}" + + def add_new_layer( + self, + layer_ptr: Layer, + location: Optional[NestedIndex | int] = None, + ) -> None: + """ + Add a new (Node tracking a) Layer to the model. + New Nodes are by default added at the bottom of the tree. + + Parameters + ---------- + layer_ptr : napari.layers.Layer + Layer to add and track with a Node + location : NestedIndex | int, optional + Location at which to insert the new (Node tracking the) Layer. + """ + self._add_new_item("Node", location=location, layer_ptr=layer_ptr) + + def add_new_group( + self, + *items: Layer | GroupLayer | GroupLayerNode, + location: Optional[NestedIndex | int] = None, + ) -> None: + """ + Add a new Group (of Layers) to the model. + New Groups are by default added at the bottom of the tree. + + Parameters + ---------- + location: NestedIndex | int, optional + Location at which to insert the new GroupLayer. + items: Layer | GroupLayer | GroupLayerNode, optional + Items to add to the new GroupLayer upon its creation. + """ + self._add_new_item("Group", location=location, group_items=items) + + def check_already_tracking( + self, layer_ptr: Layer, recursive: bool = True + ) -> bool: + """ + Return TRUE if the Layer provided is already being tracked + by a Node in this tree. + + Layer equality is determined by the IS keyword, to confirm that + a Node is pointing to the Layer object in memory. + + Parameters + ---------- + layer_ptr : Layer + The Layer to determine is in the tree (or not). + recursive: bool, default = True + If True, then all sub-trees of the tree will be checked for the + given Layer, returning True if it is found at any depth. + """ + for item in self: + if not item.is_group() and item.layer is layer_ptr: + return True + elif item.is_group() and recursive: + item: GroupLayer + if item.check_already_tracking(layer_ptr, recursive=True): + return True + return False + + def flat_index_order( + self, include_groups: bool = False + ) -> List[NestedIndex]: + """ + Return a list of NestedIndex-es, whose order corresponds to + the flat order of the Nodes in the tree. + + The flat order of the Nodes counts up from 0 at the root of the + tree, and descends down into the tree, exhausting branches it + encounters before continuing. + + For the tree below; + + * Node_0 + * Node_1 + * Group_A + * Node_A0 + * Node_A1 + * Group_AA + * Node_AA0 + * Node_A2 + * Node_2 + + we have the following flat order and corresponding indices: + + ======== ========== ================== + Item Flat Index ``include_groups`` + ======== ========== ================== + Node_0 0 0 + Node_1 1 1 + Group_A n/a 2 + Node_A0 2 3 + Node_A1 3 4 + Group_AA n/a 5 + Node_AA0 4 6 + Node_A2 5 7 + Node_2 6 8 + ======== ========== ================== + + Parameters + ---------- + include_groups : bool, default = False + Whether to assign groups their own place in the order, + or to skip over them. + """ + order: List[NestedIndex] = [] + for item in self: + if item.is_group(): + # This is a group, descend into it and append + # its ordering to our current ordering + item: GroupLayer + if include_groups: + order.append(item.index_from_root()) + order += item.flat_index_order(include_groups=include_groups) + else: + # This is just a node, and it is the next one in + # the order + order.append(item.index_from_root()) + return order + + def is_group(self) -> bool: + """ + Determines if this item is a genuine Node, or a branch containing + further nodes. + + This method is explicitly defined to ensure that we can distinguish + between genuine GroupLayerNodes and GroupLayers when traversing the + tree. + """ + return True # A GroupLayer is ALWAYS a branch. + + def remove_layer_item(self, layer_ptr: Layer, prune: bool = True) -> None: + """ + Removes (all instances of) GroupLayerNodes tracking the given + Layer from the tree model. + + If removing a layer would result in one of the Group being empty, + then the empty Group is also removed from the model. This can be + toggled with the `prune` argument. + + Note that the `is` keyword is used to determine equality between + the layer provided and the layers that are tracked by the Nodes. + This ensures that we only remove references to layers we are + tracking from the model, rather than removing the Layer from + memory itself (as there may still be hanging references to it). + + Parameters + ---------- + layer_ptr : Layer + All Nodes tracking this Layer will be removed from the model. + prune : bool, default = True + If True, branches that are empty after removing the Layer in + question will also be removed. + """ + for node in self: + if node.is_group(): + node.remove_layer_item(layer_ptr) + if prune and len(node) == 0: + self.remove(node) + elif node.layer is layer_ptr: + self.remove(node) + + def propagate_selection( + self, + event: Optional[Event] = None, + new_selection: Optional[list[GroupLayer | GroupLayerNode]] = None, + ) -> None: + """ + Propagate selection from this node to all its children. This is + necessary to keep the .selection consistent at all levels in the tree. + + This prevents scenarios where e.g. a tree like + + * ``Root`` + * ``Points_0`` + * ``Group_A`` + * ``Points_A0`` + + could have ``Points_A0`` selected on ``Root`` (appearing in its + ``.selection``), but not on ``Group_A`` (not appearing in its + ``.selection``). + + Parameters + ---------- + event: Event, optional + Selection changed event that triggers this propagation + new_selection: list[GroupLayer | GroupLayerNode], optional + List of group layer / group layer node to be selected. + If none, it will use the current selection on this node. + """ + if new_selection is None: + new_selection = self.selection + + self.selection.intersection_update(new_selection) + self.selection.update(new_selection) + + for g in [group for group in self if group.is_group()]: + # filter for things in this group + relevent_selection = [node for node in new_selection if node in g] + g.propagate_selection(event=None, new_selection=relevent_selection) diff --git a/src/napari_experimental/group_layer_actions.py b/src/napari_experimental/group_layer_actions.py new file mode 100644 index 0000000..5709b3b --- /dev/null +++ b/src/napari_experimental/group_layer_actions.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, List + +from app_model.types import Action +from qtpy.QtCore import QPoint +from qtpy.QtWidgets import QAction, QMenu + +from napari_experimental.group_layer import GroupLayer + +if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + + +class GroupLayerActions: + """Class holding all GroupLayerActions to be shown in the right click + context menu. Based on structure in napari/layers/_layer_actions and + napari/app_model/actions/_layerlist_context_actions + + Parameters + ---------- + group_layers: GroupLayer + Group layers to apply actions to + """ + + def __init__(self, group_layers: GroupLayer) -> None: + self.group_layers = group_layers + + self.actions: List[Action] = [ + Action( + id="napari:grouplayer:toggle_visibility", + title="toggle_visibility", + callback=self._toggle_visibility, + ) + ] + + def _toggle_visibility(self): + """Toggle the visibility of all selected groups and layers. If some + selected groups/layers are inside others, then prioritise those + highest in the tree.""" + + # Remove any selected items that are inside other selected groups + # e.g. if a group is selected and also a layer inside it, toggling the + # visibility will give odd results as toggling the group will toggle + # the layer, then toggling the layer will toggle it again. We're + # assuming groups higher up the tree have priority. + items_to_toggle = self.group_layers.selection.copy() + items_to_keep = [] + for sel_item in self.group_layers.selection: + if sel_item.is_group(): + for item in items_to_toggle: + if item not in sel_item or item == sel_item: + items_to_keep.append(item) + items_to_toggle = items_to_keep + items_to_keep = [] + + # Toggle the visibility of the relevant selection + for item in items_to_toggle: + if not item.is_group(): + visibility = item.layer.visible + item.layer.visible = not visibility + else: + item.visible = not item.visible + + +class ContextMenu(QMenu): + """Simplified context menu for the right click options. All actions are + populated from GroupLayerActions. + + Parameters + ---------- + group_layer_actions: GroupLayerActions + Group layer actions used to populate actions in this menu + title: str, optional + Optional title for the menu + parent: QWidget, optional + Optional parent widget + """ + + def __init__( + self, + group_layer_actions: GroupLayerActions, + title: str | None = None, + parent: QWidget | None = None, + ): + QMenu.__init__(self, parent) + self.group_layer_actions = group_layer_actions + if title is not None: + self.setTitle(title) + self._populate_actions() + + def _populate_actions(self): + """Populate menu actions from GroupLayerActions""" + for gl_action in self.group_layer_actions.actions: + action = QAction(gl_action.title, parent=self) + action.triggered.connect(gl_action.callback) + self.addAction(action) + + def exec_(self, pos: QPoint): + """For now, rebuild actions every time the menu is shown. Otherwise, + it doesn't react properly when items have been added/removed from + the group_layer root""" + self.clear() + self._populate_actions() + super().exec_(pos) diff --git a/src/napari_experimental/group_layer_controls.py b/src/napari_experimental/group_layer_controls.py new file mode 100644 index 0000000..334173b --- /dev/null +++ b/src/napari_experimental/group_layer_controls.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from napari._qt.layer_controls import QtLayerControlsContainer +from napari._qt.layer_controls.qt_layer_controls_container import ( + create_qt_layer_controls, +) +from napari.utils.events import Event +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel + +from napari_experimental.group_layer import GroupLayer +from napari_experimental.group_layer_node import GroupLayerNode + +if TYPE_CHECKING: + import napari + + +class QtGroupLayerControls(QFrame): + """Group layer controls - for now, this just displays a message to the + user""" + + def __init__(self) -> None: + super().__init__() + self.setLayout(QHBoxLayout()) + + label = QLabel("Select individual layer to view layer controls") + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + icon = QLabel() + icon.setObjectName("info_icon") + icon.setMaximumHeight(50) + + self.layout().addWidget(icon) + self.layout().addWidget(label) + + +class QtGroupLayerControlsContainer(QtLayerControlsContainer): + """Container for layer control widgets. + + Parameters + ---------- + viewer : napari.components.ViewerModel + Napari viewer + group_layers : GroupLayer + Current group layers + + Attributes + ---------- + empty_widget : qtpy.QtWidgets.QFrame + Empty placeholder frame for when no layer is selected. + viewer : napari.components.ViewerModel + Napari viewer containing the rendered scene, layers, and controls. + widgets : dict + Dictionary of key value pairs matching GroupLayer or GroupLayerNode + with their widget controls. widgets[item] = controls + """ + + def __init__( + self, viewer: "napari.viewer.Viewer", group_layers: GroupLayer + ) -> None: + super().__init__(viewer) + + # Disconnect controls from any layer events from the viewer - + # we want to only use events from group_layers + self.viewer.layers.events.inserted.disconnect(self._add) + self.viewer.layers.events.removed.disconnect(self._remove) + self.viewer.layers.selection.events.active.disconnect(self._display) + + # Initialise controls for any layers already present in group_layers + self._initialise_controls(group_layers) + self._display_item(group_layers.selection.active) + + # Sync with changes to group layers + group_layers.events.inserted.connect(self._add) + group_layers.events.removed.connect(self._remove) + group_layers.selection.events.active.connect(self._display) + + def _initialise_controls(self, group_layers: GroupLayer) -> None: + """Initialise controls for any items already present in group + layers""" + for item in group_layers: + if item.is_group(): + self._initialise_controls(item) + + self._add_item(item) + + def _add(self, event: Event) -> None: + """Add the controls target item to the list of control widgets. + + Parameters + ---------- + event : Event + Event with the target item at `event.value`. + """ + item = event.value + self._add_item(item) + + def _add_item(self, item: GroupLayer | GroupLayerNode) -> None: + """Add the controls target item to the list of control widgets. + + Parameters + ---------- + item : GroupLayer or GroupLayerNode + Item to add control widget for. + """ + if item.is_group(): + controls = QtGroupLayerControls() + else: + layer = item.layer + controls = create_qt_layer_controls(layer) + + controls.ndisplay = self.viewer.dims.ndisplay + self.addWidget(controls) + self.widgets[item] = controls + + def _display(self, event: Event) -> None: + """Change the displayed controls to be those of the target item. + + Parameters + ---------- + event : Event + Event with the target item at `event.value`. + """ + item = event.value + self._display_item(item) + + def _display_item(self, item: GroupLayer | GroupLayerNode | None) -> None: + """Change the displayed controls to be those of the target item. + + Parameters + ---------- + item : GroupLayer or GroupLayerNode + Item to display controls for. + """ + if item is None: + self.setCurrentWidget(self.empty_widget) + else: + controls = self.widgets[item] + self.setCurrentWidget(controls) + + def _remove(self, event: Event) -> None: + """Remove the controls target item from the list of control widgets. + + Parameters + ---------- + event : Event + Event with the target item at `event.value`. + """ + item = event.value + controls = self.widgets[item] + self.removeWidget(controls) + controls.hide() + controls.deleteLater() + controls = None + del self.widgets[item] diff --git a/src/napari_experimental/group_layer_delegate.py b/src/napari_experimental/group_layer_delegate.py new file mode 100644 index 0000000..782e471 --- /dev/null +++ b/src/napari_experimental/group_layer_delegate.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from napari._qt.containers._base_item_model import ItemRole +from napari._qt.containers._layer_delegate import LayerDelegate +from napari._qt.containers.qt_layer_model import ThumbnailRole +from napari._qt.qt_resources import QColoredSVGIcon +from qtpy.QtCore import QPoint, QSize, Qt +from qtpy.QtGui import QMouseEvent, QPainter, QPixmap + +from napari_experimental.group_layer_actions import ( + ContextMenu, + GroupLayerActions, +) + +if TYPE_CHECKING: + from qtpy import QtCore + from qtpy.QtWidgets import QStyleOptionViewItem + + from napari_experimental.group_layer_qt import ( + QtGroupLayerModel, + QtGroupLayerView, + ) + + +class GroupLayerDelegate(LayerDelegate): + """A QItemDelegate specialized for painting group layer objects.""" + + def get_layer_icon( + self, option: QStyleOptionViewItem, index: QtCore.QModelIndex + ): + """Add the appropriate QIcon to the item based on the layer type. + Same as LayerDelegate, but pulls folder icons from inside this plugin. + """ + item = index.data(ItemRole) + if item is None: + return + if item.is_group(): + expanded = option.widget.isExpanded(index) + icon_name = "folder-open" if expanded else "folder" + icon_path = ( + Path(__file__).parent / "resources" / f"{icon_name}.svg" + ) + icon = QColoredSVGIcon(str(icon_path)) + else: + icon_name = f"new_{item.layer._type_string}" + try: + icon = QColoredSVGIcon.from_resources(icon_name) + except ValueError: + return + # guessing theme rather than passing it through. + bg = option.palette.color(option.palette.ColorRole.Window).red() + option.icon = icon.colored(theme="dark" if bg < 128 else "light") + option.decorationSize = QSize(18, 18) + option.decorationPosition = ( + option.Position.Right + ) # put icon on the right + option.features |= option.ViewItemFeature.HasDecoration + + def _paint_thumbnail( + self, + painter: QPainter, + option: QStyleOptionViewItem, + index: QtCore.QModelIndex, + ): + """paint the layer thumbnail - same as in LayerDelegate, but allows + there to be no thumbnail for group layers""" + thumb_rect = option.rect.translated(-2, 2) + h = index.data(Qt.ItemDataRole.SizeHintRole).height() - 4 + thumb_rect.setWidth(h) + thumb_rect.setHeight(h) + image = index.data(ThumbnailRole) + if image is not None: + painter.drawPixmap(thumb_rect, QPixmap.fromImage(image)) + + def editorEvent( + self, + event: QtCore.QEvent, + model: QtCore.QAbstractItemModel, + option: QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> bool: + """Called when an event has occurred in the editor""" + # if the user clicks quickly on the visibility checkbox, we *don't* + # want it to be interpreted as a double-click. Ignore this event. + if event.type() == QMouseEvent.MouseButtonDblClick: + self.initStyleOption(option, index) + style = option.widget.style() + check_rect = style.subElementRect( + style.SubElement.SE_ItemViewItemCheckIndicator, + option, + option.widget, + ) + if check_rect.contains(event.pos()): + return True + + # refer all other events to LayerDelegate + return super().editorEvent(event, model, option, index) + + def show_context_menu( + self, + index: QtCore.QModelIndex, + model: QtGroupLayerModel, + pos: QPoint, + parent: QtGroupLayerView, + ): + """Show the group layer context menu. + To add a new item to the menu, update the GroupLayerActions. + """ + if not hasattr(self, "_context_menu"): + self._group_layer_actions = GroupLayerActions(model._root) + self._context_menu = ContextMenu( + self._group_layer_actions, parent=parent + ) + + self._context_menu.exec_(pos) diff --git a/src/napari_experimental/group_layer_node.py b/src/napari_experimental/group_layer_node.py new file mode 100644 index 0000000..0c87154 --- /dev/null +++ b/src/napari_experimental/group_layer_node.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import Optional + +from napari.layers import Layer +from napari.utils.tree import Node + + +class GroupLayerNode(Node): + """ + A Node item for a tree-like data structure that has a dedicated attribute + for tracking a single Layer. See `napari.utils.tree` for more information + about Nodes. + + GroupLayerNodes are the core building block of the GroupLayer display. By + wrapping a Layer inside a Node in this way, Layers can be organised into a + tree-like structure (allowing for grouping and nesting) without the hassle + of subclassing or mixing-in the Node class (which would require widespread + changes to the core napari codebase). + + Parameters + ---------- + layer_ptr : Layer, optional + The Layer object that this Node should initially track. + name: str, optional + Name to be given to the Node upon creation. The Layer retains its name. + """ + + __default_name: str = "Node[None]" + + _tracking_layer: Layer | None + + @property + def is_tracking(self) -> bool: + """ + Returns True if the Node is currently tracking a Layer + (self.Layer is not None), else False. + """ + return self.layer is not None + + @property + def layer(self) -> Layer: + """ + The (pointer to the) Layer that the Node is currently tracking. + """ + return self._tracking_layer + + @layer.setter + def layer(self, new_ptr: Layer) -> None: + assert ( + isinstance(new_ptr, Layer) or new_ptr is None + ), f"{type(new_ptr)} is not a layer or None!" + self._tracking_layer = new_ptr + + @property + def name(self) -> str: + """ + Name of the Node. + + If the Node is tracking a Layer, returns the name of the Layer. + Otherwise, returns the internal name of the Node. + """ + if self.is_tracking: + return self.layer.name + else: + return self.__default_name + + @name.setter + def name(self, value: str) -> None: + if self.is_tracking: + self.layer.name = value + + def __init__( + self, + layer_ptr: Optional[Layer] = None, + name: Optional[str] = None, + ): + name = name if name else self.__default_name + Node.__init__(self, name=name) + + self.layer = layer_ptr + + def __str__(self) -> str: + return f"Node[{self.name}]" + + def __repr__(self) -> str: + return self.__str__() diff --git a/src/napari_experimental/group_layer_qt.py b/src/napari_experimental/group_layer_qt.py new file mode 100644 index 0000000..c76a100 --- /dev/null +++ b/src/napari_experimental/group_layer_qt.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from napari._qt.containers import QtNodeTreeModel, QtNodeTreeView +from napari._qt.containers.qt_layer_model import ThumbnailRole +from napari._qt.qt_resources import get_current_stylesheet +from qtpy.QtCore import QModelIndex, QSize, Qt +from qtpy.QtGui import QDropEvent, QImage + +from napari_experimental.group_layer import GroupLayer +from napari_experimental.group_layer_delegate import GroupLayerDelegate + +if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + + +class QtGroupLayerModel(QtNodeTreeModel[GroupLayer]): + """ + A QTreeModel that works with the GroupLayer tree structure. + See `napari._qt.containers.QtNodeTreeModel` for more information. + + Parameters + ---------- + root : GroupLayer + The root object from which to form the model. + parent : QWidget, optional + Parent QObject for the instance. + """ + + def __init__(self, root: GroupLayer, parent: QWidget = None): + super().__init__(root, parent) + self.setRoot(root) + + def data(self, index: QModelIndex, role: Qt.ItemDataRole): + """Return data stored under ``role`` for the item at ``index``. + + A given class:`QModelIndex` can store multiple types of data, each with + its own "ItemDataRole". + """ + item = self.getItem(index) + if role == Qt.ItemDataRole.DisplayRole: + return item._node_name() + elif role == Qt.ItemDataRole.EditRole: + # used to populate line edit when editing + return item._node_name() + elif role == Qt.ItemDataRole.UserRole: + return self.getItem(index) + # Match size setting in QtLayerListModel data() + elif role == Qt.ItemDataRole.SizeHintRole: + return QSize(200, 34) + # Match thumbnail retrieval in QtLayerListModel data() + elif role == ThumbnailRole and not item.is_group(): + thumbnail = item.layer.thumbnail + return QImage( + thumbnail, + thumbnail.shape[1], + thumbnail.shape[0], + QImage.Format_RGBA8888, + ) + # Match alignment of text in QtLayerListModel data() + elif role == Qt.ItemDataRole.TextAlignmentRole: + return Qt.AlignCenter + # Match check state in QtLayerListModel data() + elif role == Qt.ItemDataRole.CheckStateRole: + if not item.is_group(): + return ( + Qt.CheckState.Checked + if item.layer.visible + else Qt.CheckState.Unchecked + ) + else: + return ( + Qt.CheckState.Checked + if item.visible + else Qt.CheckState.Unchecked + ) + + return super().data(index, role) + + def setData( + self, + index: QModelIndex, + value: Any, + role: int = Qt.ItemDataRole.EditRole, + ) -> bool: + item = self.getItem(index) + if role == Qt.ItemDataRole.EditRole: + item.name = value + role = Qt.ItemDataRole.DisplayRole + elif role == Qt.ItemDataRole.CheckStateRole: + if not item.is_group(): + item.layer.visible = ( + Qt.CheckState(value) == Qt.CheckState.Checked + ) + else: + item.visible = Qt.CheckState(value) == Qt.CheckState.Checked + + # Changing the visibility of a group will affect all its + # children - emit data changed for them too + for child_item in item.traverse(): + child_index = self.nestedIndex( + child_item.index_from_root() + ) + self.dataChanged.emit(child_index, child_index, [role]) + + else: + return super().setData(index, value, role=role) + + self.dataChanged.emit(index, index, [role]) + return True + + +class QtGroupLayerView(QtNodeTreeView): + """ + A QTreeView that works with the QtGroupLayerModel model. + See `napari._qt.containers.QtNodeTreeView` for more information. + + Parameters + ---------- + root : GroupLayer + The root object from which to form the model. + parent : QWidget, optional + Parent QObject for the instance. + """ + + _root: GroupLayer + model_class = QtGroupLayerModel + + def __init__(self, root: GroupLayer, parent: QWidget = None): + super().__init__(root, parent) + self.setRoot(root) + + grouplayer_delegate = GroupLayerDelegate() + self.setItemDelegate(grouplayer_delegate) + + # Keep existing style and add additional items from 'tree.qss' + # Tree.qss matches styles for QtListView and QtLayerList from + # 02_custom.qss in Napari + stylesheet = get_current_stylesheet( + extra=[str(Path(__file__).parent / "styles" / "tree.qss")] + ) + self.setStyleSheet(stylesheet) + + def setRoot(self, root: GroupLayer): + """Override setRoot to ensure .model is a QtGroupLayerModel""" + self._root = root + self.setModel(QtGroupLayerModel(root, self)) + + # from _BaseEventedItemView + root.selection.events.changed.connect(self._on_py_selection_change) + root.selection.events._current.connect(self._on_py_current_change) + self._sync_selection_models() + + # from QtNodeTreeView + self.model().rowsRemoved.connect(self._redecorate_root) + self.model().rowsInserted.connect(self._redecorate_root) + self._redecorate_root() + + def dropEvent(self, event: QDropEvent): + # On drag and drop, selectionChanged isn't fired as the same items + # remain selected in the view, and just their indexes/position is + # changed. Here we force the view selection to be synced to the model + # after drag and drop. + super().dropEvent(event) + self.sync_selection_from_view_to_model() + + def sync_selection_from_view_to_model(self): + """Force model / group layer to select the same items as the view""" + selected = [self.model().getItem(qi) for qi in self.selectedIndexes()] + self._root.propagate_selection(event=None, new_selection=selected) diff --git a/src/napari_experimental/napari.yaml b/src/napari_experimental/napari.yaml index 4ad016f..27d402f 100644 --- a/src/napari_experimental/napari.yaml +++ b/src/napari_experimental/napari.yaml @@ -3,28 +3,12 @@ display_name: Napari experimental # use 'hidden' to remove plugin from napari hub search results visibility: public # see https://napari.org/stable/plugins/manifest.html for valid categories -categories: ["Annotation", "Segmentation", "Acquisition"] +categories: [] contributions: commands: - - id: napari-experimental.make_container_widget - python_name: napari_experimental:ImageThreshold - title: Make threshold Container widget - - id: napari-experimental.make_magic_widget - python_name: napari_experimental:threshold_magic_widget - title: Make threshold magic widget - - id: napari-experimental.make_function_widget - python_name: napari_experimental:threshold_autogenerate_widget - title: Make threshold function widget - - id: napari-experimental.make_qwidget - python_name: napari_experimental:ExampleQWidget - title: Make ExampleQWidget + - id: napari-experimental.make_group_layer_widget + python_name: napari_experimental:GroupLayerWidget + title: Make group layers widget widgets: - - command: napari-experimental.make_container_widget - display_name: Container Threshold - - command: napari-experimental.make_magic_widget - display_name: Magic Threshold - - command: napari-experimental.make_function_widget - autogenerate: true - display_name: Autogenerate Threshold - - command: napari-experimental.make_qwidget - display_name: Example QWidget + - command: napari-experimental.make_group_layer_widget + display_name: Show Grouped Layers diff --git a/src/napari_experimental/resources/folder-open.svg b/src/napari_experimental/resources/folder-open.svg new file mode 100644 index 0000000..084b8d1 --- /dev/null +++ b/src/napari_experimental/resources/folder-open.svg @@ -0,0 +1 @@ + diff --git a/src/napari_experimental/resources/folder.svg b/src/napari_experimental/resources/folder.svg new file mode 100644 index 0000000..c58d5e9 --- /dev/null +++ b/src/napari_experimental/resources/folder.svg @@ -0,0 +1 @@ + diff --git a/src/napari_experimental/styles/tree.qss b/src/napari_experimental/styles/tree.qss new file mode 100644 index 0000000..b038b93 --- /dev/null +++ b/src/napari_experimental/styles/tree.qss @@ -0,0 +1,81 @@ +QtGroupLayerView { + min-width: 242px; +} + +QtGroupLayerView { + background: {{background}}; +} + +QtGroupLayerView QScrollBar:vertical { + max-width: 8px; +} + +QtGroupLayerView QScrollBar::add-line:vertical, +QtGroupLayerView QScrollBar::sub-line:vertical { + height: 10px; + width: 8px; + margin-top: 2px; + margin-bottom: 2px; +} + +QtGroupLayerView QScrollBar:up-arrow, +QtGroupLayerView QScrollBar:down-arrow { + min-height: 6px; + min-width: 6px; + max-height: 6px; + max-width: 6px; +} + +QtGroupLayerView::item { + padding: 4px; + margin: 2px 2px 2px 2px; + background-color: {{ foreground }}; + border: 1px solid {{ foreground }}; +} + +QtGroupLayerView::item:hover { + background-color: {{ lighten(foreground, 3) }}; +} + +/* in the QSS context "active" means the window is active */ +/* (as opposed to focused on another application) */ +QtGroupLayerView::item:selected:active{ + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 {{ current }}, stop: 1 {{ darken(current, 15) }}); +} + + +QtGroupLayerView::item:selected:!active { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 {{ darken(current, 10) }}, stop: 1 {{ darken(current, 25) }}); +} + + +QtGroupLayerView QLineEdit { + background-color: {{ darken(current, 20) }}; + selection-background-color: {{ lighten(current, 20) }}; + font-size: {{ font_size }}; +} + +QtGroupLayerView::item { + margin: 2px 2px 2px 28px; + border-top-right-radius: 2px; + border-bottom-right-radius: 2px; + border: 0; +} + +/* the first one is the "partially checked" state */ +QtGroupLayerView::indicator { + width: 16px; + height: 16px; + position: absolute; + left: 0px; + image: url("theme_{{ id }}:/visibility_off.svg"); +} + +QtGroupLayerView::indicator:unchecked { + image: url("theme_{{ id }}:/visibility_off_50.svg"); + +} + +QtGroupLayerView::indicator:checked { + image: url("theme_{{ id }}:/visibility.svg"); +} diff --git a/tests/blobs.py b/tests/blobs.py new file mode 100644 index 0000000..975f421 --- /dev/null +++ b/tests/blobs.py @@ -0,0 +1,27 @@ +import os + +import napari +import numpy as np +from skimage import data + +os.environ["ALLOW_LAYERGROUPS"] = "1" + + +points = np.random.rand(10, 3) * (1, 2, 3) * 100 +colors = np.random.rand(10, 3) + +blobs = data.binary_blobs(length=100, volume_fraction=0.05, n_dim=2) + +viewer = napari.Viewer(ndisplay=3) + +pl = napari.layers.Points(points, face_color=colors) +il = napari.layers.Image(blobs, scale=(1, 2), translate=(20, 15)) + +viewer.add_layer(pl) +viewer.add_layer(il) + +dock_widget, plugin_widget = viewer.window.add_plugin_dock_widget( + "napari-experimental", "Show Grouped Layers" +) + +napari.run() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0c68eb2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import pytest +from qtpy.QtCore import Qt + +# * imports into test files are considered bad, however +# importing fixtures defined in other files into conftest +# means we can avoid a large conftest.py file. +# It would be nice if ruff had an ignore block: https://github.com/astral-sh/ruff/issues/3711 +from .fixtures.conftest_group_layers import * # noqa: F403 +from .fixtures.conftest_layers import * # noqa: F403 + + +@pytest.fixture +def double_click_on_view(qtbot): + """Fixture to avoid code repetition to emulate double-click on a view.""" + + def inner_double_click_on_view(view, index): + viewport_index_position = view.visualRect(index).center() + + # weirdly, to correctly emulate a double-click + # you need to click first. Also, note that the view + # needs to be interacted with via its viewport + qtbot.mouseClick( + view.viewport(), + Qt.MouseButton.LeftButton, + pos=viewport_index_position, + ) + qtbot.mouseDClick( + view.viewport(), + Qt.MouseButton.LeftButton, + pos=viewport_index_position, + ) + + return inner_double_click_on_view + + +@pytest.fixture +def right_click_on_view(qtbot): + """Fixture to avoid code repetition to emulate right-click on a view.""" + + def inner_right_click_on_view(view, index): + viewport_index_position = view.visualRect(index).center() + + qtbot.mouseClick( + view.viewport(), + Qt.MouseButton.RightButton, + pos=viewport_index_position, + ) + + return inner_right_click_on_view diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/conftest_group_layers.py b/tests/fixtures/conftest_group_layers.py new file mode 100644 index 0000000..f4e3468 --- /dev/null +++ b/tests/fixtures/conftest_group_layers.py @@ -0,0 +1,52 @@ +from copy import deepcopy +from typing import Any + +import pytest +from napari_experimental.group_layer import GroupLayer + + +def return_copy_with_new_name(obj: Any, new_name: str = "Copy") -> Any: + """ + Returns a copy of the object that is passed with the name + attribute set to the new name provided. + If an object does not have a name attribute, this method + will add one to the returned copy. + """ + copy = deepcopy(obj) + copy.name = new_name + return copy + + +@pytest.fixture(scope="function") +def group_layer_data(points_layer, image_layer) -> GroupLayer: + return GroupLayer(points_layer, image_layer) + + +@pytest.fixture(scope="function") +def nested_layer_group(collection_of_layers) -> GroupLayer: + """ + Creates a GroupLayer container with the following structure: + + Root + - Points_0 + - Group_A + - Points_A0 + - Group_AA + - Points_AA0 + - Points_AA1 + - Points_A1 + - Points_1 + - Group_B + - Points_B0 + """ + group_aa = GroupLayer( + collection_of_layers["AA0"], collection_of_layers["AA1"] + ) + group_a = GroupLayer( + collection_of_layers["A0"], group_aa, collection_of_layers["A1"] + ) + group_b = GroupLayer(collection_of_layers["B0"]) + root = GroupLayer( + collection_of_layers["0"], group_a, collection_of_layers["1"], group_b + ) + return root diff --git a/tests/fixtures/conftest_layers.py b/tests/fixtures/conftest_layers.py new file mode 100644 index 0000000..d242e37 --- /dev/null +++ b/tests/fixtures/conftest_layers.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Dict + +import numpy as np +import pytest +from napari.layers import Image, Points +from skimage import data + +if TYPE_CHECKING: + import numpy.typing as npt + + +SEED = 0 +GENERATOR = np.random.RandomState(seed=SEED) + + +def return_copy_with_new_name(obj: Any, new_name: str = "Copy") -> Any: + """ + Returns a copy of the object that is passed with the name + attribute set to the new name provided. + If an object does not have a name attribute, this method + will add one to the returned copy. + """ + copy = deepcopy(obj) + copy.name = new_name + return copy + + +@pytest.fixture(scope="session") +def blobs() -> npt.NDArray: + return data.binary_blobs(length=100, volume_fraction=0.05, n_dim=2) + + +@pytest.fixture(scope="function") +def image_layer(blobs) -> Image: + return Image(blobs, scale=(1, 2), translate=(20, 15)) + + +@pytest.fixture(scope="session") +def points() -> npt.NDArray: + return GENERATOR.rand(10, 3) * (1, 2, 3) * 100 + + +@pytest.fixture(scope="session") +def colors() -> npt.NDArray: + return GENERATOR.rand(10, 3) + + +@pytest.fixture(scope="function") +def points_layer(points, colors) -> Points: + return Points(points, face_color=colors) + + +@pytest.fixture(scope="function") +def collection_of_layers(points_layer) -> Dict[str, Points]: + """ + Creates a Dictionary containing with key (str) : value (Points) + items that can be used to instantiate other objects if necessary. + + Objects persist in memory until end of test so that use of the IS + comparison is correctly carried out. + + For a given key k, the Points layer value has the name Points_{k}. + Otherwise, all Points layers are identical. + Valid keys (all strings): + - 0 + - A0 + - A1 + - AA0 + - AA1 + - 1 + - B0 + """ + return { + key: return_copy_with_new_name(points_layer, f"Points_{key}") + for key in ["0", "A0", "A1", "AA0", "AA1", "1", "B0"] + } diff --git a/tests/test_group_layer.py b/tests/test_group_layer.py new file mode 100644 index 0000000..f1a42bb --- /dev/null +++ b/tests/test_group_layer.py @@ -0,0 +1,328 @@ +from typing import Callable, Dict, Iterable, List, Tuple + +import pytest +from napari.layers import Points +from napari.utils.events.containers._nested_list import NestedIndex +from napari_experimental.group_layer import GroupLayer +from napari_experimental.group_layer_node import GroupLayerNode + +from .fixtures.conftest_layers import return_copy_with_new_name + + +def recursively_apply_function( + group_layer: GroupLayer, func: Callable[[GroupLayer], None] +) -> None: + """ + Recursively apply a function to all members of a GroupLayer, and then all + subtrees of that GroupLayer. Functions are intended to conduct the + necessary assertions for a particular test. + """ + func(group_layer) + + for branch in [ + item for item in group_layer if isinstance(item, GroupLayer) + ]: + recursively_apply_function(branch, func) + + +def test_group_layer_types(nested_layer_group: GroupLayer) -> None: + """ + Check that everything stored in a GroupLayer is a GroupLayerNode, + and that any child GroupLayers also adhere to this structure. + """ + + def assert_correct_types(group_layer: GroupLayer) -> None: + assert isinstance( + group_layer, GroupLayer + ), f"{group_layer} is not a GroupLayer instance." + + for item in group_layer: + assert isinstance( + item, GroupLayerNode + ), f"{item} in {group_layer} is not a GroupLayerNode instance." + + recursively_apply_function(nested_layer_group, assert_correct_types) + + +def test_is_group(nested_layer_group: GroupLayer) -> None: + """ + Check that GroupLayer instances always return True + from their is_group() method. + """ + + def assert_correct_is_group(group_layer: GroupLayer) -> None: + assert ( + group_layer.is_group() + ), f"{group_layer} is not flagged as a Group." + + for node in [ + item for item in group_layer if not isinstance(item, GroupLayer) + ]: + assert not node.is_group() + + recursively_apply_function(nested_layer_group, assert_correct_is_group) + + +@pytest.mark.parametrize( + ["layer_key", "recursive", "expected_result"], + [ + pytest.param("A0", True, True, id="Depth 1, recurse True"), + pytest.param("AA0", True, True, id="Depth 2, recurse True"), + pytest.param("A0", False, False, id="Depth 1, recurse False"), + pytest.param("1", False, True, id="Depth 0, recurse False"), + ], +) +def test_check_is_already_tracking( + nested_layer_group: GroupLayer, + collection_of_layers: Dict[str, Points], + layer_key: str, + recursive: bool, + expected_result: bool, +): + assert ( + nested_layer_group.check_already_tracking( + layer_ptr=collection_of_layers[layer_key], recursive=recursive + ) + == expected_result + ), ( + f"Incorrect result (expected {expected_result}) " + f"for check_already_tracking (with recursive = {recursive})" + ) + + +@pytest.mark.parametrize( + ["with_groups", "expected_order"], + [ + pytest.param( + False, + [ + (0,), # Points_0 + (1, 0), # Points_A0 + (1, 1, 0), # Points_AA0 + (1, 1, 1), # Points_AA1 + (1, 2), # Points_A1 + (2,), # Points_1 + (3, 0), # Points_B0 + ], + id="without Groups", + ), + pytest.param( + True, + [ + (0,), # Points_0 + (1,), # Group_A + (1, 0), # Points_A0 + (1, 1), # Group_AA + (1, 1, 0), # Points_AA0 + (1, 1, 1), # Points_AA1 + (1, 2), # Points_A1 + (2,), # Points_1 + (3,), # Group_B + (3, 0), # Points_B0 + ], + id="Including Groups", + ), + ], +) +def test_flat_index( + nested_layer_group: GroupLayer, + with_groups: bool, + expected_order: List[NestedIndex], +) -> None: + flat_order = nested_layer_group.flat_index_order( + include_groups=with_groups + ) + for i, returned_nested_index in enumerate(flat_order): + expected_nested_index = expected_order[i] + assert expected_nested_index == returned_nested_index, ( + f"Mismatch at position {i}: " + f"got {returned_nested_index} but expected {expected_nested_index}" + ) + + +@pytest.mark.parametrize( + ["location", "expected_location"], + [ + pytest.param(None, (-1), id="Default location"), + pytest.param((1, 1), (1, 1), id="Inside a sub-Group"), + ], +) +def test_add_layer( + nested_layer_group: GroupLayer, + points_layer: Points, + location: NestedIndex, + expected_location: NestedIndex, +) -> None: + nested_layer_group.add_new_layer(points_layer, location=location) + assert nested_layer_group[expected_location].layer == points_layer, ( + f"Layer was not inserted at {expected_location} " + f"(given location argument {location})." + ) + + +def test_add_layer_failure_cases( + nested_layer_group: GroupLayer, points_layer: Points +) -> None: + # Cannot add a Layer to a Node object - target must be a space in a Group + pts_added_to_a_node = return_copy_with_new_name( + points_layer, "Add to a Node" + ) + with pytest.raises( + ValueError, + match="Item at (.*) is not a Group", + ): + nested_layer_group.add_new_layer(pts_added_to_a_node, (1, 0, 1)) + + # Must provide a Layer pointer to create a new Node + with pytest.raises( + ValueError, + match="A Layer must be provided when (.*)", + ): + nested_layer_group.add_new_layer(None) + + # Cannot track the same Layer twice + nested_layer_group.add_new_layer(points_layer) # Add the layer + with pytest.raises( + RuntimeError, match="Group Node\[.*\] is already tracking" + ): + nested_layer_group.add_new_layer(points_layer) # Try to add it again + + +def test_add_group( + nested_layer_group: GroupLayer, + points_layer: Points, +) -> None: + add_at_location = (2,) + pts_1 = return_copy_with_new_name(points_layer, "pts_1") + pts_2 = return_copy_with_new_name(points_layer, "pts_2") + + nested_layer_group.add_new_group(pts_1, pts_2, location=add_at_location) + + assert nested_layer_group[ + add_at_location + ].is_group(), "Group added in the incorrect location." + + added_group: GroupLayer = nested_layer_group[add_at_location] + assert added_group.check_already_tracking( + pts_1 + ) and added_group.check_already_tracking( + pts_2 + ), "Points layers were not added to the new Group upon creation." + assert ( + len(added_group) == 2 + ), "Additional items added to the Group upon creation." + + +@pytest.mark.parametrize( + [ + "o_index", + "d_index", + "previous_moves", + "expected_index", + ], + [ + pytest.param((0,), (2,), {}, (0,), id="No previous interference"), + pytest.param( + (2,), + (0,), + {(): [1]}, + (2,), + id="1 previous move, but it was from a position above " + "the original to a position above the original", + ), + pytest.param( + (2,), + (0,), + {(1,): [0]}, + (3,), + id="1 previous move, from another group to a position above.", + ), + pytest.param( + (2,), + (3,), + {(1,): [0]}, + (2,), + id="1 previous move, to a position below the original.", + ), + pytest.param( + (2,), + (1, 2), + {(): [0, 4]}, + (1,), + id="2 previous moves, only 1 of which conflicts", + ), + pytest.param( + (1, 2, 1), + (1, 3, 0), + {(): [0], (1,): [0, 4], (1, 2): [0]}, + (0, 1, 0), + id="Group indices affected by moves", + ), + ], +) +def test_revise_indicies( + o_index: List[NestedIndex], + d_index: NestedIndex, + previous_moves: Dict[NestedIndex, List[int]], + expected_index: NestedIndex, +) -> None: + computed_index = GroupLayer._revise_indices_based_on_previous_moves( + original_index=o_index, + original_dest=d_index, + previous_moves=previous_moves, + ) + assert computed_index == expected_index, ( + "Did not provide correct expected index, " + f"got {computed_index} but expected {expected_index}" + ) + + +@pytest.mark.parametrize( + ["sources", "destination", "expected_plan"], + [ + pytest.param( + ((0,), (1,)), + (2,), + [((0,), (2,)), ((0,), (2,))], + id="Effectively doing nothing: (0,) + (1,) -> (2,)", + # (0,) moves to (2,) without problems. + # (1,) is now index (0,); + # -1 for the previous move taking an element from ABOVE this one, + # The destination is (2,); + # +1: being the 2nd move in the list, + # -1: previous move taking an element from ABOVE this one, + ), + pytest.param( + ((0,), (1, 0)), + (-1,), + [((0,), (4,)), ((0, 0), (4,))], + id="Move two items from different subgroups to the end.", + ), + ], +) +def test_move_plan( + nested_layer_group: GroupLayer, + sources: Iterable[NestedIndex], + destination: NestedIndex, + expected_plan: List[Tuple[NestedIndex, NestedIndex]], +) -> None: + generated_pairs = list( + nested_layer_group._move_plan(sources=sources, dest_index=destination) + ) + assert len(generated_pairs) == len( + expected_plan + ), "Plan and expected plan do not have the same number of elements" + + # Could do a direct comparison of lists, + # but provide more granular detail here + for i, g_pair in enumerate(generated_pairs): + e_pair = expected_plan[i] + for ii, type in enumerate(["source", "destination"]): + assert g_pair[ii] == e_pair[ii], ( + f"Move {i} {type}s do not agree: " + f"expected {type} at {e_pair[ii]} " + f"but was informed it was at {g_pair[ii]}" + ) + + # Run the move just to see if errors are then thrown up + nested_layer_group.move_multiple(sources, destination) diff --git a/tests/test_group_layer_controls.py b/tests/test_group_layer_controls.py new file mode 100644 index 0000000..c6ebe38 --- /dev/null +++ b/tests/test_group_layer_controls.py @@ -0,0 +1,89 @@ +from napari._qt.layer_controls.qt_image_controls import QtImageControls +from napari._qt.layer_controls.qt_points_controls import QtPointsControls +from napari.layers import Image, Points +from napari_experimental.group_layer import GroupLayer +from napari_experimental.group_layer_controls import ( + QtGroupLayerControls, + QtGroupLayerControlsContainer, +) + + +def test_controls_selection(make_napari_viewer, group_layer_data: GroupLayer): + """Test that group layer controls initialise with the correct widget, and + match changes to the selected item.""" + viewer = make_napari_viewer() + # Add an empty group also + group_layer_data.add_new_group() + + # Initialise with image item selected + image_item = group_layer_data[1] + assert isinstance(image_item.layer, Image) + group_layer_data.selection.active = image_item + + controls = QtGroupLayerControlsContainer(viewer, group_layer_data) + assert isinstance( + controls.currentWidget(), QtImageControls + ), "Initial widget doesn't match selected image layer" + + # Switch to points item selected + points_item = group_layer_data[0] + assert isinstance(points_item.layer, Points) + group_layer_data.selection.active = points_item + assert isinstance( + controls.currentWidget(), QtPointsControls + ), "Current widget doesn't match selected points layer" + + # Switch to group layer selected + group_item = group_layer_data[-1] + assert group_layer_data.is_group() + group_layer_data.selection.active = group_item + assert isinstance( + controls.currentWidget(), QtGroupLayerControls + ), "Current widget doesn't match selected group layer" + + +def test_controls_insertion( + make_napari_viewer, group_layer_data: GroupLayer, blobs: Points +): + """Test that insertions into group layers are reflected in the controls + widgets""" + viewer = make_napari_viewer() + controls = QtGroupLayerControlsContainer(viewer, group_layer_data) + + # Every item in group layer data should have a corresponding widget + n_items = len(group_layer_data) + assert len(controls.widgets) == n_items + for item in group_layer_data: + assert item in controls.widgets + + new_layer = Image( + blobs, scale=(1, 2), translate=(20, 15), name="new-blobs" + ) + group_layer_data.add_new_layer(layer_ptr=new_layer) + + # Check that adding an item to group layers, also appears in the widgets + assert len(group_layer_data) == n_items + 1 + assert len(controls.widgets) == n_items + 1 + assert group_layer_data[-1] in controls.widgets + + +def test_controls_deletion(make_napari_viewer, group_layer_data): + """Test that deletion from group layers is reflected in the controls + widgets""" + viewer = make_napari_viewer() + controls = QtGroupLayerControlsContainer(viewer, group_layer_data) + + # Every item in group layer data should have a corresponding widget + n_items = len(group_layer_data) + assert len(controls.widgets) == n_items + for item in group_layer_data: + assert item in controls.widgets + + item_to_remove = group_layer_data[0] + group_layer_data.remove_layer_item(layer_ptr=item_to_remove.layer) + + # Check that removing an item from group layers, is reflected in + # the widgets + assert len(group_layer_data) == n_items - 1 + assert len(controls.widgets) == n_items - 1 + assert item_to_remove not in controls.widgets diff --git a/tests/test_group_layer_qt_model.py b/tests/test_group_layer_qt_model.py new file mode 100644 index 0000000..41996e7 --- /dev/null +++ b/tests/test_group_layer_qt_model.py @@ -0,0 +1,12 @@ +from napari_experimental.group_layer import GroupLayer +from napari_experimental.group_layer_qt import QtGroupLayerModel + + +def test_qt_group_layer_model( + group_layer_data: GroupLayer, nested_layer_group: GroupLayer, qtmodeltester +) -> None: + simple = QtGroupLayerModel(root=group_layer_data) + qtmodeltester.check(simple) + + nested = QtGroupLayerModel(nested_layer_group) + qtmodeltester.check(nested) diff --git a/tests/test_group_layer_widget.py b/tests/test_group_layer_widget.py new file mode 100644 index 0000000..c462320 --- /dev/null +++ b/tests/test_group_layer_widget.py @@ -0,0 +1,285 @@ +import pytest +from napari_experimental._widget import GroupLayerWidget +from napari_experimental.group_layer import GroupLayer, GroupLayerNode +from napari_experimental.group_layer_actions import GroupLayerActions +from napari_experimental.group_layer_delegate import GroupLayerDelegate +from qtpy.QtCore import QPoint, Qt +from qtpy.QtWidgets import QWidget + + +@pytest.fixture() +def group_layer_widget(make_napari_viewer, blobs) -> GroupLayerWidget: + """Group layer widget with one image layer""" + viewer = make_napari_viewer() + viewer.add_image(blobs) + + _, plugin_widget = viewer.window.add_plugin_dock_widget( + "napari-experimental", "Show Grouped Layers" + ) + return plugin_widget + + +@pytest.fixture() +def group_layer_widget_with_nested_groups( + group_layer_widget, image_layer, points_layer +): + """Group layer widget containing a group layer with images/points inside. + The group layer (and all items inside of it) are selected. The group is + visible, but not all of the items inside are.""" + group_layers = group_layer_widget.group_layers + + # Add a group layer with an image layer and point layer inside. One is + # visible and the other is not + group_layers.add_new_group() + new_group = group_layers[1] + + image_layer.visible = True + points_layer.visible = False + group_layers.add_new_layer(layer_ptr=image_layer, location=(1, 0)) + group_layers.add_new_layer(layer_ptr=points_layer, location=(1, 1)) + new_image = new_group[0] + new_points = new_group[1] + + # Select them all + group_layers.propagate_selection( + new_selection=[new_group, new_image, new_points] + ) + + return group_layer_widget + + +def test_widget_creation(make_napari_viewer_proxy) -> None: + viewer = make_napari_viewer_proxy() + assert isinstance(GroupLayerWidget(viewer), QWidget) + + +def test_rename_group_layer(group_layer_widget: GroupLayerWidget): + group_layers_view = group_layer_widget.group_layers_view + group_layers_model = group_layers_view.model() + + # Add an empty group layer + group_layer_widget.group_layers.add_new_group() + + # Check current name + new_name = "renamed-group" + node_index = group_layers_model.index(group_layers_model.rowCount(), 0) + group_layer = group_layers_model.getItem(node_index) + assert isinstance(group_layer, GroupLayer) + assert group_layer.name != new_name + + # Rename + group_layers_model.setData(node_index, new_name) + assert group_layers_model.getItem(node_index).name == new_name + assert ( + group_layers_model.getItem(node_index)._node_name() == f"GL-{new_name}" + ) + + +def test_rename_layer(group_layer_widget): + group_layers_view = group_layer_widget.group_layers_view + group_layers_model = group_layers_view.model() + + # Check current name + node_index = group_layers_model.index(0, 0) + node = group_layers_model.getItem(node_index) + assert isinstance(node, GroupLayerNode) + assert node.name == "blobs" + + # Rename + new_name = "new-blobs" + group_layers_model.setData(node_index, new_name) + assert group_layers_model.getItem(node_index).name == new_name + assert group_layers_model.getItem(node_index)._node_name() == new_name + + +def test_double_click_edit(group_layer_widget, double_click_on_view): + """Check that the view enters editing state when an item is + double-clicked""" + group_layers_view = group_layer_widget.group_layers_view + group_layers_model = group_layers_view.model() + assert group_layers_view.state() == group_layers_view.NoState + + # Check enters editing state on double click + node_index = group_layers_model.index(0, 0) + double_click_on_view(group_layers_view, node_index) + assert group_layers_view.state() == group_layers_view.EditingState + + +def test_right_click_context_menu( + group_layer_widget, right_click_on_view, mocker +): + """Test that right clicking on an item in the view initiates the context + menu""" + + # Using the real show_context_menu causes the test to hang, so mock it here + mocker.patch.object(GroupLayerDelegate, "show_context_menu") + + group_layers_view = group_layer_widget.group_layers_view + delegate = group_layers_view.itemDelegate() + + # right click on item and check show_context_menu is called + node_index = group_layers_view.model().index(0, 0) + right_click_on_view(group_layers_view, node_index) + delegate.show_context_menu.assert_called_once() + + +def test_actions_context_menu( + group_layer_widget, right_click_on_view, monkeypatch +): + """Test that the context menu contains the correct actions""" + + group_layers_view = group_layer_widget.group_layers_view + delegate = group_layers_view.itemDelegate() + assert not hasattr(delegate, "_context_menu") + + # Uses same setup as inside napari's test_qt_layer_list (otherwise the + # context menu hangs in the test) + monkeypatch.setattr( + "napari_experimental.group_layer_actions.ContextMenu.exec_", + lambda self, x: x, + ) + + # Show the context menu directly + node_index = group_layers_view.model().index(0, 0) + delegate.show_context_menu( + node_index, + group_layers_view.model(), + QPoint(10, 10), + parent=group_layers_view, + ) + assert hasattr(delegate, "_context_menu") + + # Test number and name of actions in the context menu matches + # GroupLayerActions + assert len(delegate._context_menu.actions()) == len( + delegate._group_layer_actions.actions + ) + context_menu_action_names = [ + action.text() for action in delegate._context_menu.actions() + ] + group_layer_action_names = [ + action.title for action in delegate._group_layer_actions.actions + ] + assert context_menu_action_names == group_layer_action_names + + +def test_toggle_visibility_layer_via_context_menu(group_layer_widget): + group_layers = group_layer_widget.group_layers + group_layer_actions = GroupLayerActions(group_layers) + + assert group_layers[0].layer.visible is True + group_layer_actions._toggle_visibility() + assert group_layers[0].layer.visible is False + + +def test_toggle_visibility_layer_via_model(group_layer_widget): + group_layers = group_layer_widget.group_layers + group_layers_view = group_layer_widget.group_layers_view + group_layers_model = group_layers_view.model() + + assert group_layers[0].layer.visible is True + + node_index = group_layers_model.index(0, 0) + group_layers_model.setData( + node_index, + Qt.CheckState.Unchecked, + role=Qt.ItemDataRole.CheckStateRole, + ) + + assert group_layers[0].layer.visible is False + + +def test_toggle_visibility_group_layer_via_context_menu( + group_layer_widget_with_nested_groups, +): + group_layers = group_layer_widget_with_nested_groups.group_layers + group_layer_actions = GroupLayerActions(group_layers) + + # Check starting visibility of group layer and items inside + new_group = group_layers[1] + new_image = new_group[0] + new_points = new_group[1] + assert new_group.visible is True + assert new_image.layer.visible is True + assert new_points.layer.visible is False + + # Toggle visibility and check all become False. As both the image and point + # are inside the new_group, the group takes priority. + group_layer_actions._toggle_visibility() + assert new_group.visible is False + assert new_image.layer.visible is False + assert new_points.layer.visible is False + + +def test_toggle_visibility_group_layer_via_model( + group_layer_widget_with_nested_groups, +): + group_layers = group_layer_widget_with_nested_groups.group_layers + group_layers_model = ( + group_layer_widget_with_nested_groups.group_layers_view.model() + ) + + # Check starting visibility of group layer and items inside + new_group = group_layers[1] + new_image = new_group[0] + new_points = new_group[1] + assert new_group.visible is True + assert new_image.layer.visible is True + assert new_points.layer.visible is False + + # Toggle visibility and check all become False. As both the image and point + # are inside the new_group, the group takes priority. + node_index = group_layers_model.nestedIndex(new_group.index_from_root()) + group_layers_model.setData( + node_index, + Qt.CheckState.Unchecked, + role=Qt.ItemDataRole.CheckStateRole, + ) + assert new_group.visible is False + assert new_image.layer.visible is False + assert new_points.layer.visible is False + + +def test_layer_sync(make_napari_viewer, image_layer, points_layer): + """ + Test the synchronisation between the widget and the main LayerList. + This test will be redundant when the plugin functionality replaces the + main viewer functionality. + """ + viewer = make_napari_viewer() + viewer.add_layer(image_layer) + + widget = GroupLayerWidget(viewer) + assert len(widget.group_layers) == 1 + + # Check that adding a layer to the viewer means it is also added to + # the group layers view. + + viewer.add_layer(points_layer) + assert len(widget.group_layers) == 2 + + # Check that reordering the layer order in the group layers view + # forces an update in the main viewer. + # However, don't forget that the ordering is REVERSED in the GUI, so + # viewer.layers[-1] == widget.group_layers[0], etc. + for in_viewer, in_widget in zip( # noqa: B905 + reversed(viewer.layers), + [node.layer for node in widget.group_layers], + ): + assert in_viewer is in_widget + # Move layer at position 1 to position 0 + widget.group_layers.move((1,), (0,)) + # Viewer should have auto-synced these changes + for in_viewer, in_widget in zip( # noqa: B905 + reversed(viewer.layers), + [node.layer for node in widget.group_layers], + ): + assert in_viewer is in_widget + + # Deletion in main viewer results in deletion in group layers viewer + viewer.layers.remove(points_layer) + assert len(widget.group_layers) == 1 + assert image_layer is widget.group_layers[0].layer + # Deletion in group layers viewer results in deletion in main viewer + widget.group_layers.remove_layer_item(image_layer) + assert len(viewer.layers) == 0 diff --git a/tests/test_widget.py b/tests/test_widget.py deleted file mode 100644 index 349f80d..0000000 --- a/tests/test_widget.py +++ /dev/null @@ -1,65 +0,0 @@ -import numpy as np -from napari_experimental._widget import ( - ExampleQWidget, - ImageThreshold, - threshold_autogenerate_widget, - threshold_magic_widget, -) - - -def test_threshold_autogenerate_widget(): - # because our "widget" is a pure function, we can call it and - # test it independently of napari - im_data = np.random.random((100, 100)) - thresholded = threshold_autogenerate_widget(im_data, 0.5) - assert thresholded.shape == im_data.shape - # etc. - - -# make_napari_viewer is a pytest fixture that returns a napari viewer object -# you don't need to import it, as long as napari is installed -# in your testing environment -def test_threshold_magic_widget(make_napari_viewer): - viewer = make_napari_viewer() - layer = viewer.add_image(np.random.random((100, 100))) - - # our widget will be a MagicFactory or FunctionGui instance - my_widget = threshold_magic_widget() - - # if we "call" this object, it'll execute our function - thresholded = my_widget(viewer.layers[0], 0.5) - assert thresholded.shape == layer.data.shape - # etc. - - -def test_image_threshold_widget(make_napari_viewer): - viewer = make_napari_viewer() - layer = viewer.add_image(np.random.random((100, 100))) - my_widget = ImageThreshold(viewer) - - # because we saved our widgets as attributes of the container - # we can set their values without having to "interact" with the viewer - my_widget._image_layer_combo.value = layer - my_widget._threshold_slider.value = 0.5 - - # this allows us to run our functions directly and ensure - # correct results - my_widget._threshold_im() - assert len(viewer.layers) == 2 - - -# capsys is a pytest fixture that captures stdout and stderr output streams -def test_example_q_widget(make_napari_viewer, capsys): - # make viewer and add an image layer using our fixture - viewer = make_napari_viewer() - viewer.add_image(np.random.random((100, 100))) - - # create our widget, passing in the viewer - my_widget = ExampleQWidget(viewer) - - # call our widget method - my_widget._on_click() - - # read captured output and check that it's as we expected - captured = capsys.readouterr() - assert captured.out == "napari has 1 layers\n"