Skip to content

Commit

Permalink
Add right click menu and styling (#29)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
K-Meech and willGraham01 authored Jul 8, 2024
1 parent 5bb3858 commit 42acf51
Show file tree
Hide file tree
Showing 12 changed files with 640 additions and 10 deletions.
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)

Check warning on line 499 in src/napari_experimental/group_layer.py

View check run for this annotation

Codecov / codecov/patch

src/napari_experimental/group_layer.py#L497-L499

Added lines #L497 - L499 were not covered by tests
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

0 comments on commit 42acf51

Please sign in to comment.