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.