Skip to content

Commit

Permalink
Add transform mode button for layers (napari#6794)
Browse files Browse the repository at this point in the history
# References and relevant issues

Part of napari#3975

# Description

Add a button to activate the layers transform mode. Also, add a way to
reset the transform to the initial value set at layer creation (reset
trigger via Alt + Left mouse click over the transform mode button):

A preview:


![transform_mode_button](https://github.com/napari/napari/assets/16781833/6b19c949-4ce9-425b-bb89-a1b42b99bb8e)

# Notes/TODOs:

* [x] Icon selection/definition needs to be done (in progress)
* Possible icon design at
napari#3975 (comment)
* Selected icon at
napari#3975 (comment)
* [x] Implemented the Alt-click approach to trigger a reset with a
confirmation dialog but happy to check any other alternatives/ideas

---------

Co-authored-by: Isabela Presedo-Floyd <[email protected]>
Co-authored-by: Peter Sobolewski <[email protected]>
Co-authored-by: Draga Doncila Pop <[email protected]>
  • Loading branch information
4 people authored Jul 5, 2024
1 parent e1c8e9e commit 3ba3a3e
Show file tree
Hide file tree
Showing 15 changed files with 813 additions and 429 deletions.
234 changes: 234 additions & 0 deletions napari/_qt/layer_controls/_tests/test_qt_layer_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import random
import sys
from typing import NamedTuple, Optional
from unittest.mock import Mock

import numpy as np
import pytest
Expand All @@ -13,6 +14,7 @@
QAbstractSpinBox,
QCheckBox,
QComboBox,
QMessageBox,
QPushButton,
QRadioButton,
)
Expand Down Expand Up @@ -42,6 +44,7 @@
Vectors,
)
from napari.utils.colormaps import DirectLabelColormap
from napari.utils.events.event import Event


class LayerTypeWithData(NamedTuple):
Expand Down Expand Up @@ -455,6 +458,130 @@ def test_create_layer_controls_qcolorswatchedit(
assert not captured.err


@pytest.mark.parametrize(
(
'layer_type_with_data',
'action_manager_trigger',
),
[
(
_LABELS_WITH_DIRECT_COLORMAP,
'napari:activate_labels_transform_mode',
),
(
_LABELS,
'napari:activate_labels_transform_mode',
),
(
_IMAGE,
'napari:activate_image_transform_mode',
),
(
_POINTS,
'napari:activate_points_transform_mode',
),
(
_SHAPES,
'napari:activate_shapes_transform_mode',
),
(
_SURFACE,
'napari:activate_surface_transform_mode',
),
(
_TRACKS,
'napari:activate_tracks_transform_mode',
),
(
_VECTORS,
'napari:activate_vectors_transform_mode',
),
],
)
def test_create_layer_controls_transform_mode_button(
qtbot,
create_layer_controls,
layer_type_with_data,
action_manager_trigger,
monkeypatch,
):
action_manager_mock = Mock(trigger=Mock())

# Monkeypatch the action_manager instance to prevent `KeyError: 'layer'`
# over `napari.layers.utils.layer_utils.register_layer_attr_action._handle._wrapper`
monkeypatch.setattr(
'napari._qt.layer_controls.qt_layer_controls_base.action_manager',
action_manager_mock,
)

# create layer controls widget
ctrl = create_layer_controls(layer_type_with_data)

# check create widget corresponds to the expected class for each type of layer
assert isinstance(ctrl, layer_type_with_data.expected_isinstance)

# check transform mode button existence
assert ctrl.transform_button

# check layer mode change
assert ctrl.layer.mode == 'pan_zoom'
ctrl.transform_button.click()
assert ctrl.layer.mode == 'transform'

# check reset transform behavior
ctrl.layer.affine = None
assert ctrl.layer.affine != ctrl.layer._initial_affine

def reset_transform_warning_dialog(*args):
return QMessageBox.Yes

monkeypatch.setattr(
'qtpy.QtWidgets.QMessageBox.warning', reset_transform_warning_dialog
)
qtbot.mouseClick(
ctrl.transform_button,
Qt.LeftButton,
Qt.KeyboardModifier.AltModifier,
)
assert ctrl.layer.affine == ctrl.layer._initial_affine


@pytest.mark.parametrize(
'layer_type_with_data',
[
_LABELS_WITH_DIRECT_COLORMAP,
_LABELS,
_IMAGE,
_POINTS,
_SHAPES,
_SURFACE,
_TRACKS,
_VECTORS,
],
)
def test_layer_controls_invalid_mode(
qtbot,
create_layer_controls,
layer_type_with_data,
):
# create layer controls widget
ctrl = create_layer_controls(layer_type_with_data)

# check create widget corresponds to the expected class for each type of layer
assert isinstance(ctrl, layer_type_with_data.expected_isinstance)

# check layer mode and corresponding mode button
assert ctrl.layer.mode == 'pan_zoom'
assert ctrl.panzoom_button.isChecked()

# check setting invalid mode
with pytest.raises(ValueError, match='not recognized'):
ctrl._on_mode_change(Event('mode', mode='invalid_mode'))

# check panzoom_button is still checked
assert ctrl.panzoom_button.isChecked()


def test_unknown_raises(qtbot):
class Test:
"""Unmatched class"""
Expand Down Expand Up @@ -550,17 +677,124 @@ def test_set_3d_display_with_shapes(qtbot):
assert not layer.editable


def test_set_3d_display_with_labels(qtbot):
"""Some modes only work for labels layers rendered in 2D and not
in 3D. Verify that the related mode buttons are disabled upon switching to
3D rendering mode while the layer is still editable.
"""
viewer = ViewerModel()
container = QtLayerControlsContainer(viewer)
qtbot.addWidget(container)
layer = viewer.add_labels(np.zeros((3, 4), dtype=int))
assert viewer.dims.ndisplay == 2
assert container.currentWidget().polygon_button.isEnabled()
assert container.currentWidget().transform_button.isEnabled()
assert layer.editable

viewer.dims.ndisplay = 3

assert not container.currentWidget().polygon_button.isEnabled()
assert not container.currentWidget().transform_button.isEnabled()
assert layer.editable


@pytest.mark.parametrize(
'add_layer_with_data',
[
('add_labels', np.zeros((3, 4), dtype=int)),
('add_points', np.empty((0, 2))),
('add_shapes', np.empty((0, 2, 4))),
('add_image', np.random.rand(8, 8)),
(
'add_surface',
(
np.random.random((10, 2)),
np.random.randint(10, size=(6, 3)),
np.random.random(10),
),
),
('add_tracks', np.zeros((2, 4))),
('add_vectors', np.zeros((2, 2, 2))),
],
)
def test_set_3d_display_and_layer_visibility(qtbot, add_layer_with_data):
"""Some modes only work for layers rendered in 2D and not
in 3D. Verify that the related mode buttons are disabled upon switching to
3D rendering mode and the disable state is kept even when changing layer
visibility.
For the labels layer the specific polygon mode button should be disabled in
3D regardless of the layer being visible or not. For all the layers the same
applies for the transform mode button.
"""
viewer = ViewerModel()
container = QtLayerControlsContainer(viewer)
qtbot.addWidget(container)
add_layer_method, data = add_layer_with_data
layer = getattr(viewer, add_layer_method)(data)

# 2D mode
assert viewer.dims.ndisplay == 2
if add_layer_method == 'add_labels':
assert container.currentWidget().polygon_button.isEnabled()
assert container.currentWidget().transform_button.isEnabled()

# 2D mode + layer not visible
layer.visible = False
if add_layer_method == 'add_labels':
assert not container.currentWidget().polygon_button.isEnabled()
assert not container.currentWidget().transform_button.isEnabled()

# 2D mode + layer visible
layer.visible = True
if add_layer_method == 'add_labels':
assert container.currentWidget().polygon_button.isEnabled()
assert container.currentWidget().transform_button.isEnabled()

# 3D mode
viewer.dims.ndisplay = 3
if add_layer_method == 'add_labels':
assert not container.currentWidget().polygon_button.isEnabled()
assert not container.currentWidget().transform_button.isEnabled()

# 3D mode + layer not visible
layer.visible = False
if add_layer_method == 'add_labels':
assert not container.currentWidget().polygon_button.isEnabled()
assert not container.currentWidget().transform_button.isEnabled()

# 3D mode + layer visible
layer.visible = True
if add_layer_method == 'add_labels':
assert not container.currentWidget().polygon_button.isEnabled()
assert not container.currentWidget().transform_button.isEnabled()


# The following tests handle changes to the layer's visible and
# editable state for layer control types that have controls to edit
# the layer. For more context see:
# https://github.com/napari/napari/issues/1346
# Updated due to the addition of a transform mode button for all the layers,
# For more context see:
# https://github.com/napari/napari/pull/6794


@pytest.fixture(
params=(
(Labels, np.zeros((3, 4), dtype=int)),
(Points, np.empty((0, 2))),
(Shapes, np.empty((0, 2, 4))),
(Image, np.random.rand(8, 8)),
(
Surface,
(
np.random.random((10, 2)),
np.random.randint(10, size=(6, 3)),
np.random.random(10),
),
),
(Tracks, np.zeros((2, 4))),
(Vectors, np.zeros((2, 2, 2))),
)
)
def editable_layer(request):
Expand Down
22 changes: 20 additions & 2 deletions napari/_qt/layer_controls/qt_image_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,22 @@ class QtImageControls(QtBaseImageControls):
Attributes
----------
layer : napari.layers.Image
An instance of a napari Image layer.
MODE : Enum
Available modes in the associated layer.
PAN_ZOOM_ACTION_NAME : str
String id for the pan-zoom action to bind to the pan_zoom button.
TRANSFORM_ACTION_NAME : str
String id for the transform action to bind to the transform button.
button_group : qtpy.QtWidgets.QButtonGroup
Button group for image based layer modes (PAN_ZOOM TRANSFORM).
button_grid : qtpy.QtWidgets.QGridLayout
GridLayout for the layer mode buttons
panzoom_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton
Button to pan/zoom shapes layer.
transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton
Button to transform shapes layer.
attenuationSlider : qtpy.QtWidgets.QSlider
Slider controlling attenuation rate for `attenuated_mip` mode.
attenuationLabel : qtpy.QtWidgets.QLabel
Expand All @@ -50,15 +66,15 @@ class QtImageControls(QtBaseImageControls):
Slider controlling the isosurface threshold value for rendering.
isoThresholdLabel : qtpy.QtWidgets.QLabel
Label for the isosurface threshold slider widget.
layer : napari.layers.Image
An instance of a napari Image layer.
renderComboBox : qtpy.QtWidgets.QComboBox
Dropdown menu to select the rendering mode for image display.
renderLabel : qtpy.QtWidgets.QLabel
Label for the rendering mode dropdown menu.
"""

layer: 'napari.layers.Image'
PAN_ZOOM_ACTION_NAME = 'activate_image_pan_zoom_mode'
TRANSFORM_ACTION_NAME = 'activate_image_transform_mode'

def __init__(self, layer) -> None:
super().__init__(layer)
Expand Down Expand Up @@ -168,6 +184,7 @@ def __init__(self, layer) -> None:
colormap_layout.addWidget(self.colormapComboBox)
colormap_layout.addStretch(1)

self.layout().addRow(self.button_grid)
self.layout().addRow(self.opacityLabel, self.opacitySlider)
self.layout().addRow(
trans._('contrast limits:'), self.contrastLimitsSlider
Expand Down Expand Up @@ -366,6 +383,7 @@ def _on_ndisplay_changed(self):
self._update_rendering_parameter_visibility()
self.depictionComboBox.show()
self.depictionLabel.show()
super()._on_ndisplay_changed()


class PlaneNormalButtons(QWidget):
Expand Down
25 changes: 22 additions & 3 deletions napari/_qt/layer_controls/qt_image_controls_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
import numpy as np
from qtpy.QtCore import Qt
from qtpy.QtGui import QImage, QPixmap
from qtpy.QtWidgets import QHBoxLayout, QLabel, QPushButton, QWidget
from qtpy.QtWidgets import (
QHBoxLayout,
QLabel,
QPushButton,
QWidget,
)
from superqt import QDoubleRangeSlider

from napari._qt.layer_controls.qt_colormap_combobox import QtColormapComboBox
Expand Down Expand Up @@ -53,6 +58,22 @@ class QtBaseImageControls(QtLayerControls):
Attributes
----------
layer : napari.layers.Layer
An instance of a napari layer.
MODE : Enum
Available modes in the associated layer.
PAN_ZOOM_ACTION_NAME : str
String id for the pan-zoom action to bind to the pan_zoom button.
TRANSFORM_ACTION_NAME : str
String id for the transform action to bind to the transform button.
button_group : qtpy.QtWidgets.QButtonGroup
Button group for image based layer modes (PAN_ZOOM TRANSFORM).
button_grid : qtpy.QtWidgets.QGridLayout
GridLayout for the layer mode buttons
panzoom_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton
Button to pan/zoom shapes layer.
transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton
Button to transform shapes layer.
clim_popup : napari._qt.qt_range_slider_popup.QRangeSliderPopup
Popup widget launching the contrast range slider.
colorbarLabel : qtpy.QtWidgets.QLabel
Expand All @@ -63,8 +84,6 @@ class QtBaseImageControls(QtLayerControls):
Contrast range slider widget.
gammaSlider : qtpy.QtWidgets.QSlider
Gamma adjustment slider widget.
layer : napari.layers.Layer
An instance of a napari layer.
"""

Expand Down
Loading

0 comments on commit 3ba3a3e

Please sign in to comment.