diff --git a/MANIFEST.in b/MANIFEST.in
index 9ab4e2c..3cf1506 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -6,5 +6,9 @@ exclude .pre-commit-config.yaml
recursive-exclude * __pycache__
recursive-exclude * *.py[co]
+recursive-include src *.qss
+recursive-include src *.svg
+
+
prune .napari-hub
prune tests
diff --git a/pyproject.toml b/pyproject.toml
index d937650..73e0d7f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,7 +26,7 @@ dynamic = ["version"]
napari-experimental = "napari_experimental:napari.yaml"
[project.optional-dependencies]
-dev = ["tox", "pytest", "pytest-cov", "pytest-qt", "pre-commit"]
+dev = ["tox", "pytest", "pytest-cov", "pytest-qt", "pytest-mock", "pre-commit"]
napari-latest = ["napari @ git+https://github.com/napari/napari.git"]
[project.urls]
diff --git a/src/napari_experimental/group_layer.py b/src/napari_experimental/group_layer.py
index 7e6f452..f42a6e9 100644
--- a/src/napari_experimental/group_layer.py
+++ b/src/napari_experimental/group_layer.py
@@ -6,6 +6,7 @@
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,
@@ -84,6 +85,19 @@ def name(self) -> str:
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
+
def __init__(
self,
*items_to_include: Layer | GroupLayerNode | GroupLayer,
@@ -117,6 +131,12 @@ def __init__(
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
+
@staticmethod
def _revise_indices_based_on_previous_moves(
original_index: NestedIndex,
@@ -479,3 +499,39 @@ def remove_layer_item(self, layer_ptr: Layer, prune: bool = True) -> None:
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
index bab68cd..334173b 100644
--- a/src/napari_experimental/group_layer_controls.py
+++ b/src/napari_experimental/group_layer_controls.py
@@ -106,8 +106,6 @@ def _add_item(self, item: GroupLayer | GroupLayerNode) -> None:
"""
if item.is_group():
controls = QtGroupLayerControls()
- # Need to also react to changes of selection in nested group layers
- item.selection.events.active.connect(self._display)
else:
layer = item.layer
controls = create_qt_layer_controls(layer)
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_qt.py b/src/napari_experimental/group_layer_qt.py
index f8d252b..c76a100 100644
--- a/src/napari_experimental/group_layer_qt.py
+++ b/src/napari_experimental/group_layer_qt.py
@@ -1,11 +1,16 @@
from __future__ import annotations
+from pathlib import Path
from typing import TYPE_CHECKING, Any
from napari._qt.containers import QtNodeTreeModel, QtNodeTreeView
-from qtpy.QtCore import QModelIndex, Qt
+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
@@ -42,7 +47,37 @@ def data(self, index: QModelIndex, role: Qt.ItemDataRole):
return item._node_name()
elif role == Qt.ItemDataRole.UserRole:
return self.getItem(index)
- return None
+ # 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,
@@ -50,12 +85,31 @@ def setData(
value: Any,
role: int = Qt.ItemDataRole.EditRole,
) -> bool:
+ item = self.getItem(index)
if role == Qt.ItemDataRole.EditRole:
- self.getItem(index).name = value
- self.dataChanged.emit(index, index, [role])
- return True
+ 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 False
+ return super().setData(index, value, role=role)
+
+ self.dataChanged.emit(index, index, [role])
+ return True
class QtGroupLayerView(QtNodeTreeView):
@@ -78,6 +132,17 @@ 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
@@ -92,3 +157,16 @@ def setRoot(self, root: GroupLayer):
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/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/conftest.py b/tests/conftest.py
index 547c50f..0c68eb2 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -33,3 +33,19 @@ def inner_double_click_on_view(view, index):
)
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/test_group_layer_widget.py b/tests/test_group_layer_widget.py
index d15846e..c462320 100644
--- a/tests/test_group_layer_widget.py
+++ b/tests/test_group_layer_widget.py
@@ -1,14 +1,51 @@
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)
- return GroupLayerWidget(viewer)
+
+ _, 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:
@@ -68,6 +105,141 @@ def test_double_click_edit(group_layer_widget, double_click_on_view):
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.