Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add right click menu and styling #29

Merged
merged 14 commits into from
Jul 8, 2024
4 changes: 4 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
56 changes: 56 additions & 0 deletions src/napari_experimental/group_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
105 changes: 105 additions & 0 deletions src/napari_experimental/group_layer_actions.py
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 12 in src/napari_experimental/group_layer_actions.py

View check run for this annotation

Codecov / codecov/patch

src/napari_experimental/group_layer_actions.py#L12

Added line #L12 was not covered by tests


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)

Check warning on line 89 in src/napari_experimental/group_layer_actions.py

View check run for this annotation

Codecov / codecov/patch

src/napari_experimental/group_layer_actions.py#L89

Added line #L89 was not covered by tests
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)

Check warning on line 105 in src/napari_experimental/group_layer_actions.py

View check run for this annotation

Codecov / codecov/patch

src/napari_experimental/group_layer_actions.py#L103-L105

Added lines #L103 - L105 were not covered by tests
2 changes: 0 additions & 2 deletions src/napari_experimental/group_layer_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
118 changes: 118 additions & 0 deletions src/napari_experimental/group_layer_delegate.py
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 20 in src/napari_experimental/group_layer_delegate.py

View check run for this annotation

Codecov / codecov/patch

src/napari_experimental/group_layer_delegate.py#L19-L20

Added lines #L19 - L20 were not covered by tests

from napari_experimental.group_layer_qt import (

Check warning on line 22 in src/napari_experimental/group_layer_delegate.py

View check run for this annotation

Codecov / codecov/patch

src/napari_experimental/group_layer_delegate.py#L22

Added line #L22 was not covered by tests
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

Check warning on line 39 in src/napari_experimental/group_layer_delegate.py

View check run for this annotation

Codecov / codecov/patch

src/napari_experimental/group_layer_delegate.py#L39

Added line #L39 was not covered by tests
if item.is_group():
expanded = option.widget.isExpanded(index)
icon_name = "folder-open" if expanded else "folder"
icon_path = (

Check warning on line 43 in src/napari_experimental/group_layer_delegate.py

View check run for this annotation

Codecov / codecov/patch

src/napari_experimental/group_layer_delegate.py#L41-L43

Added lines #L41 - L43 were not covered by tests
Path(__file__).parent / "resources" / f"{icon_name}.svg"
)
icon = QColoredSVGIcon(str(icon_path))

Check warning on line 46 in src/napari_experimental/group_layer_delegate.py

View check run for this annotation

Codecov / codecov/patch

src/napari_experimental/group_layer_delegate.py#L46

Added line #L46 was not covered by tests
else:
icon_name = f"new_{item.layer._type_string}"
try:
icon = QColoredSVGIcon.from_resources(icon_name)
except ValueError:
return

Check warning on line 52 in src/napari_experimental/group_layer_delegate.py

View check run for this annotation

Codecov / codecov/patch

src/napari_experimental/group_layer_delegate.py#L51-L52

Added lines #L51 - L52 were not covered by tests
# 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))

Check warning on line 76 in src/napari_experimental/group_layer_delegate.py

View check run for this annotation

Codecov / codecov/patch

src/napari_experimental/group_layer_delegate.py#L70-L76

Added lines #L70 - L76 were not covered by tests

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

Check warning on line 97 in src/napari_experimental/group_layer_delegate.py

View check run for this annotation

Codecov / codecov/patch

src/napari_experimental/group_layer_delegate.py#L97

Added line #L97 was not covered by tests

# 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)
Loading