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
40 changes: 40 additions & 0 deletions src/napari_experimental/group_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Optional

from napari.layers import Layer
from napari.utils.events import Event
from napari.utils.tree import Group

from napari_experimental.group_layer_node import GroupLayerNode
Expand Down Expand Up @@ -64,6 +65,9 @@ def __init__(
basetype=GroupLayerNode,
)

# If selection changes on this node, propagate changes to any children
self.selection.events.changed.connect(self.propagate_selection)

def _check_already_tracking(
self, layer_ptr: Layer, recursive: bool = True
) -> bool:
Expand Down Expand Up @@ -170,3 +174,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)
85 changes: 85 additions & 0 deletions src/napari_experimental/group_layer_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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 layers inside the
group layers"""
for item in self.group_layers.selection:
if not item.is_group():
visibility = item.layer.visible
item.layer.visible = not visibility
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for item in self.group_layers.selection:
if not item.is_group():
visibility = item.layer.visible
item.layer.visible = not visibility
for node in [item for item in self.group_layers.selection if not item.is_group()]:
visibility = node.layer.visible
node.layer.visible = not visibility

Personally I've been going with this when I only want to iterate though either nodes or groups - "item" is generic and can refer to either nodes or groups but here we really are just using the nodes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! Although now I've merged the km/group-vis-toggle branch, it needs to iterate through both groups and nodes to set visibility on both



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)
122 changes: 122 additions & 0 deletions src/napari_experimental/group_layer_delegate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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.
"""
item = index.data(ItemRole)

# For now, don't show the right click context menu for groups.
if not item.is_group():
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