diff --git a/napari/_qt/layer_controls/_tests/test_qt_layer_controls.py b/napari/_qt/layer_controls/_tests/test_qt_layer_controls.py index 897ef9856f9..29d67dc2bcd 100644 --- a/napari/_qt/layer_controls/_tests/test_qt_layer_controls.py +++ b/napari/_qt/layer_controls/_tests/test_qt_layer_controls.py @@ -2,6 +2,7 @@ import random import sys from typing import NamedTuple, Optional +from unittest.mock import Mock import numpy as np import pytest @@ -13,6 +14,7 @@ QAbstractSpinBox, QCheckBox, QComboBox, + QMessageBox, QPushButton, QRadioButton, ) @@ -42,6 +44,7 @@ Vectors, ) from napari.utils.colormaps import DirectLabelColormap +from napari.utils.events.event import Event class LayerTypeWithData(NamedTuple): @@ -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""" @@ -550,10 +677,106 @@ 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( @@ -561,6 +784,17 @@ def test_set_3d_display_with_shapes(qtbot): (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): diff --git a/napari/_qt/layer_controls/qt_image_controls.py b/napari/_qt/layer_controls/qt_image_controls.py index 53612703a24..997bf4ed82e 100644 --- a/napari/_qt/layer_controls/qt_image_controls.py +++ b/napari/_qt/layer_controls/qt_image_controls.py @@ -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 @@ -50,8 +66,6 @@ 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 @@ -59,6 +73,8 @@ class QtImageControls(QtBaseImageControls): """ 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) @@ -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 @@ -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): diff --git a/napari/_qt/layer_controls/qt_image_controls_base.py b/napari/_qt/layer_controls/qt_image_controls_base.py index c6f7e6502bb..6b6b853b96a 100644 --- a/napari/_qt/layer_controls/qt_image_controls_base.py +++ b/napari/_qt/layer_controls/qt_image_controls_base.py @@ -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 @@ -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 @@ -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. """ diff --git a/napari/_qt/layer_controls/qt_labels_controls.py b/napari/_qt/layer_controls/qt_labels_controls.py index 8a7711d5844..59911507a5c 100644 --- a/napari/_qt/layer_controls/qt_labels_controls.py +++ b/napari/_qt/layer_controls/qt_labels_controls.py @@ -4,7 +4,6 @@ from qtpy.QtCore import Qt from qtpy.QtGui import QColor, QPainter from qtpy.QtWidgets import ( - QButtonGroup, QCheckBox, QComboBox, QHBoxLayout, @@ -17,10 +16,7 @@ from napari._qt.layer_controls.qt_layer_controls_base import QtLayerControls from napari._qt.utils import set_widgets_enabled_with_opacity from napari._qt.widgets._slider_compat import QSlider -from napari._qt.widgets.qt_mode_buttons import ( - QtModePushButton, - QtModeRadioButton, -) +from napari._qt.widgets.qt_mode_buttons import QtModePushButton from napari.layers.labels._labels_constants import ( LABEL_COLOR_MODE_TRANSLATIONS, LabelColorMode, @@ -30,7 +26,6 @@ from napari.layers.labels._labels_utils import get_dtype from napari.utils import CyclicLabelColormap from napari.utils._dtype import get_dtype_limits -from napari.utils.action_manager import action_manager from napari.utils.events import disconnect_events from napari.utils.translations import trans @@ -51,6 +46,14 @@ class QtLabelsControls(QtLayerControls): Attributes ---------- + layer : napari.layers.Labels + An instance of a napari Labels 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 of labels layer modes: PAN_ZOOM, PICKER, PAINT, ERASE, or FILL. @@ -58,21 +61,21 @@ class QtLabelsControls(QtLayerControls): Button to update colormap of label layer. contigCheckBox : qtpy.QtWidgets.QCheckBox Checkbox to control if label layer is contiguous. - fill_button : qtpy.QtWidgets.QtModeRadioButton + fill_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select FILL mode on Labels layer. - layer : napari.layers.Labels - An instance of a napari Labels layer. ndimSpinBox : qtpy.QtWidgets.QSpinBox Spinbox to control the number of editable dimensions of label layer. - paint_button : qtpy.QtWidgets.QtModeRadioButton + paint_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select PAINT mode on Labels layer. - panzoom_button : qtpy.QtWidgets.QtModeRadioButton + panzoom_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select PAN_ZOOM mode on Labels layer. - pick_button : qtpy.QtWidgets.QtModeRadioButton + transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton + Button to select TRANSFORM mode on Labels layer. + pick_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select PICKER mode on Labels layer. preserveLabelsCheckBox : qtpy.QtWidgets.QCheckBox Checkbox to control if existing labels are preserved - erase_button : qtpy.QtWidgets.QtModeRadioButton + erase_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select ERASE mode on Labels layer. selectionSpinBox : superqt.QLargeIntSpinBox Widget to select a specific label by its index. @@ -88,11 +91,13 @@ class QtLabelsControls(QtLayerControls): """ layer: 'napari.layers.Labels' + MODE = Mode + PAN_ZOOM_ACTION_NAME = 'activate_labels_pan_zoom_mode' + TRANSFORM_ACTION_NAME = 'activate_labels_transform_mode' def __init__(self, layer) -> None: super().__init__(layer) - self.layer.events.mode.connect(self._on_mode_change) self.layer.events.rendering.connect(self._on_rendering_change) self.layer.events.colormap.connect(self._on_colormap_change) self.layer.events.selected_label.connect( @@ -104,8 +109,6 @@ def __init__(self, layer) -> None: self._on_n_edit_dimensions_change ) self.layer.events.contour.connect(self._on_contour_change) - self.layer.events.editable.connect(self._on_editable_or_visible_change) - self.layer.events.visible.connect(self._on_editable_or_visible_change) self.layer.events.preserve_labels.connect( self._on_preserve_labels_change ) @@ -189,84 +192,50 @@ def __init__(self, layer) -> None: tooltip=trans._('shuffle colors'), ) - self.panzoom_button = QtModeRadioButton( + self.pick_button = self._radio_button( layer, - 'pan', - Mode.PAN_ZOOM, - checked=True, - ) - action_manager.bind_button( - 'napari:activate_labels_pan_zoom_mode', self.panzoom_button - ) - - self.pick_button = QtModeRadioButton(layer, 'picker', Mode.PICK) - action_manager.bind_button( - 'napari:activate_labels_picker_mode', self.pick_button - ) - - self.paint_button = QtModeRadioButton(layer, 'paint', Mode.PAINT) - action_manager.bind_button( - 'napari:activate_labels_paint_mode', self.paint_button + 'picker', + Mode.PICK, + True, + 'activate_labels_picker_mode', ) - - self.polygon_button = QtModeRadioButton( - layer, 'labels_polygon', Mode.POLYGON + self.paint_button = self._radio_button( + layer, + 'paint', + Mode.PAINT, + True, + 'activate_labels_paint_mode', ) - action_manager.bind_button( - 'napari:activate_labels_polygon_mode', - self.polygon_button, + self.polygon_button = self._radio_button( + layer, + 'labels_polygon', + Mode.POLYGON, + True, + 'activate_labels_polygon_mode', ) - - self.fill_button = QtModeRadioButton( + self.fill_button = self._radio_button( layer, 'fill', Mode.FILL, + True, + 'activate_labels_fill_mode', ) - action_manager.bind_button( - 'napari:activate_labels_fill_mode', - self.fill_button, - ) - - self.erase_button = QtModeRadioButton( + self.erase_button = self._radio_button( layer, 'erase', Mode.ERASE, + True, + 'activate_labels_erase_mode', ) - action_manager.bind_button( - 'napari:activate_labels_erase_mode', - self.erase_button, - ) - # don't bind with action manager as this would remove "Toggle with {shortcut}" - - self._EDIT_BUTTONS = ( - self.paint_button, - self.polygon_button, - self.pick_button, - self.fill_button, - self.erase_button, - ) - - self.button_group = QButtonGroup(self) - self.button_group.addButton(self.panzoom_button) - self.button_group.addButton(self.paint_button) - self.button_group.addButton(self.polygon_button) - self.button_group.addButton(self.pick_button) - self.button_group.addButton(self.fill_button) - self.button_group.addButton(self.erase_button) self._on_editable_or_visible_change() - button_row = QHBoxLayout() - button_row.addStretch(1) - button_row.addWidget(self.colormapUpdate) - button_row.addWidget(self.erase_button) - button_row.addWidget(self.paint_button) - button_row.addWidget(self.polygon_button) - button_row.addWidget(self.fill_button) - button_row.addWidget(self.pick_button) - button_row.addWidget(self.panzoom_button) - button_row.setSpacing(4) - button_row.setContentsMargins(0, 0, 0, 5) + self.button_grid.addWidget(self.colormapUpdate, 0, 0) + self.button_grid.addWidget(self.erase_button, 0, 1) + self.button_grid.addWidget(self.paint_button, 0, 2) + self.button_grid.addWidget(self.polygon_button, 0, 3) + self.button_grid.addWidget(self.fill_button, 0, 4) + self.button_grid.addWidget(self.pick_button, 0, 5) renderComboBox = QComboBox(self) rendering_options = [i.value for i in LabelsRendering] @@ -286,7 +255,7 @@ def __init__(self, layer) -> None: color_layout.addWidget(self.colorBox) color_layout.addWidget(self.selectionSpinBox) - self.layout().addRow(button_row) + self.layout().addRow(self.button_grid) self.layout().addRow(trans._('label:'), color_layout) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('brush size:'), self.brushSizeSlider) @@ -310,6 +279,22 @@ def change_color_mode(self): else: self.layer.colormap = self.layer._direct_colormap + def _on_mode_change(self, event): + """Receive layer model mode change event and update checkbox ticks. + + Parameters + ---------- + event : napari.utils.event.Event + The napari event that triggered this method. + + Raises + ------ + ValueError + Raise error if event.mode is not PAN_ZOOM, PICK, PAINT, ERASE, FILL + or TRANSFORM + """ + super()._on_mode_change(event) + def _on_colormap_change(self): enable_combobox = not self.layer._is_default_colors( self.layer._direct_colormap.color_dict @@ -334,36 +319,6 @@ def _on_data_change(self): dtype_lims = get_dtype_limits(get_dtype(self.layer)) self.selectionSpinBox.setRange(*dtype_lims) - def _on_mode_change(self, event): - """Receive layer model mode change event and update checkbox ticks. - - Parameters - ---------- - event : napari.utils.event.Event - The napari event that triggered this method. - - Raises - ------ - ValueError - Raise error if event.mode is not PAN_ZOOM, PICK, PAINT, ERASE, or - FILL - """ - mode = event.mode - if mode == Mode.PAN_ZOOM: - self.panzoom_button.setChecked(True) - elif mode == Mode.PICK: - self.pick_button.setChecked(True) - elif mode == Mode.PAINT: - self.paint_button.setChecked(True) - elif mode == Mode.POLYGON: - self.polygon_button.setChecked(True) - elif mode == Mode.FILL: - self.fill_button.setChecked(True) - elif mode == Mode.ERASE: - self.erase_button.setChecked(True) - elif mode != Mode.TRANSFORM: - raise ValueError(trans._('Mode not recognized')) - def changeRendering(self, text): """Change rendering mode for image display. @@ -492,8 +447,7 @@ def _on_n_edit_dimensions_change(self): with self.layer.events.n_edit_dimensions.blocker(): value = self.layer.n_edit_dimensions self.ndimSpinBox.setValue(int(value)) - if hasattr(self, 'polygon_button'): - self.polygon_button.setEnabled(self._is_polygon_tool_enabled()) + self._set_polygon_tool_state() def _on_contiguous_change(self): """Receive layer model contiguous change event and update the checkbox.""" @@ -512,14 +466,6 @@ def _on_show_selected_label_change(self): self.layer.show_selected_label ) - def _on_editable_or_visible_change(self): - """Receive layer model editable/visible change event & enable/disable buttons.""" - set_widgets_enabled_with_opacity( - self, - self._EDIT_BUTTONS, - self.layer.editable and self.layer.visible, - ) - def _on_rendering_change(self): """Receive layer model rendering change event and update dropdown menu.""" with self.layer.events.rendering.blocker(): @@ -528,12 +474,23 @@ def _on_rendering_change(self): ) self.renderComboBox.setCurrentIndex(index) + def _on_editable_or_visible_change(self): + super()._on_editable_or_visible_change() + self._set_polygon_tool_state() + def _on_ndisplay_changed(self): render_visible = self.ndisplay == 3 self.renderComboBox.setVisible(render_visible) self.renderLabel.setVisible(render_visible) self._on_editable_or_visible_change() - self.polygon_button.setEnabled(self._is_polygon_tool_enabled()) + self._set_polygon_tool_state() + super()._on_ndisplay_changed() + + def _set_polygon_tool_state(self): + if hasattr(self, 'polygon_button'): + set_widgets_enabled_with_opacity( + self, [self.polygon_button], self._is_polygon_tool_enabled() + ) def _is_polygon_tool_enabled(self): return ( diff --git a/napari/_qt/layer_controls/qt_layer_controls_base.py b/napari/_qt/layer_controls/qt_layer_controls_base.py index 0eae6a47956..d5f72c40e0c 100644 --- a/napari/_qt/layer_controls/qt_layer_controls_base.py +++ b/napari/_qt/layer_controls/qt_layer_controls_base.py @@ -1,9 +1,25 @@ from qtpy.QtCore import Qt -from qtpy.QtWidgets import QComboBox, QFormLayout, QFrame, QLabel - +from qtpy.QtGui import QMouseEvent +from qtpy.QtWidgets import ( + QButtonGroup, + QComboBox, + QFormLayout, + QFrame, + QGridLayout, + QLabel, + QMessageBox, +) + +from napari._qt.utils import set_widgets_enabled_with_opacity from napari._qt.widgets._slider_compat import QDoubleSlider -from napari.layers.base._base_constants import BLENDING_TRANSLATIONS, Blending +from napari._qt.widgets.qt_mode_buttons import QtModeRadioButton +from napari.layers.base._base_constants import ( + BLENDING_TRANSLATIONS, + Blending, + Mode, +) from napari.layers.base.base import Layer +from napari.utils.action_manager import action_manager from napari.utils.events import disconnect_events from napari.utils.translations import trans @@ -33,6 +49,22 @@ class QtLayerControls(QFrame): 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. blendComboBox : qtpy.QtWidgets.QComboBox Dropdown widget to select blending mode of layer. layer : napari.layers.Layer @@ -43,12 +75,21 @@ class QtLayerControls(QFrame): Label for the opacity slider widget. """ + MODE = Mode + PAN_ZOOM_ACTION_NAME = '' + TRANSFORM_ACTION_NAME = '' + def __init__(self, layer: Layer) -> None: super().__init__() self._ndisplay: int = 2 + self._EDIT_BUTTONS: tuple = () + self._MODE_BUTTONS: dict = {} self.layer = layer + self.layer.events.mode.connect(self._on_mode_change) + self.layer.events.editable.connect(self._on_editable_or_visible_change) + self.layer.events.visible.connect(self._on_editable_or_visible_change) self.layer.events.blending.connect(self._on_blending_change) self.layer.events.opacity.connect(self._on_opacity_change) @@ -57,6 +98,38 @@ def __init__(self, layer: Layer) -> None: self.setLayout(LayerFormLayout(self)) + # Buttons + self.button_group = QButtonGroup(self) + self.panzoom_button = self._radio_button( + layer, + 'pan', + self.MODE.PAN_ZOOM, + False, + self.PAN_ZOOM_ACTION_NAME, + extra_tooltip_text=trans._('(or hold Space)'), + checked=True, + ) + self.transform_button = self._radio_button( + layer, + 'transform', + self.MODE.TRANSFORM, + True, + self.TRANSFORM_ACTION_NAME, + extra_tooltip_text=trans._( + '\nAlt + Left mouse click over this button to reset' + ), + ) + self.transform_button.installEventFilter(self) + self._on_editable_or_visible_change() + + self.button_grid = QGridLayout() + self.button_grid.addWidget(self.panzoom_button, 0, 6) + self.button_grid.addWidget(self.transform_button, 0, 7) + self.button_grid.setContentsMargins(5, 0, 0, 5) + self.button_grid.setColumnStretch(0, 1) + self.button_grid.setSpacing(4) + + # Control widgets sld = QDoubleSlider(Qt.Orientation.Horizontal, parent=self) sld.setFocusPolicy(Qt.FocusPolicy.NoFocus) sld.setMinimum(0) @@ -122,6 +195,95 @@ def changeBlending(self, text): self.blendComboBox.setToolTip(blending_tooltip) self.layer.help = blending_tooltip + def _radio_button( + self, + layer, + btn_name, + mode, + edit_button, + action_name, + extra_tooltip_text='', + **kwargs, + ): + """ + Convenience local function to create a RadioButton and bind it to + an action at the same time. + + Parameters + ---------- + layer : napari.layers.Layer + The layer instance that this button controls.n + btn_name : str + name fo the button + mode : Enum + Value Associated to current button + edit_button: bool + True if the button corresponds to edition operations. False otherwise. + action_name : str + Action triggered when button pressed + extra_tooltip_text : str + Text you want added after the automatic tooltip set by the + action manager + **kwargs: + Passed to napari._qt.widgets.qt_mode_button.QtModeRadioButton + + Returns + ------- + button: napari._qt.widgets.qt_mode_button.QtModeRadioButton + button bound (or that will be bound to) to action `action_name` + + Notes + ----- + When shortcuts are modifed/added/removed via the action manager, the + tooltip will be updated to reflect the new shortcut. + """ + action_name = f'napari:{action_name}' + btn = QtModeRadioButton(layer, btn_name, mode, **kwargs) + action_manager.bind_button( + action_name, + btn, + extra_tooltip_text=extra_tooltip_text, + ) + self._MODE_BUTTONS[mode] = btn + self.button_group.addButton(btn) + if edit_button: + self._EDIT_BUTTONS += (btn,) + return btn + + def _on_mode_change(self, event): + """ + Update ticks in checkbox widgets when image based layer mode changed. + + Available modes for base layer are: + * PAN_ZOOM + * TRANSFORM + + Parameters + ---------- + event : napari.utils.event.Event + The napari event that triggered this method. + + Raises + ------ + ValueError + Raise error if event.mode is not PAN_ZOOM or TRANSFORM. + """ + if event.mode in self._MODE_BUTTONS: + self._MODE_BUTTONS[event.mode].setChecked(True) + else: + raise ValueError( + trans._("Mode '{mode}' not recognized", mode=event.mode) + ) + + def _on_editable_or_visible_change(self): + """Receive layer model editable/visible change event & enable/disable buttons.""" + set_widgets_enabled_with_opacity( + self, + self._EDIT_BUTTONS, + self.layer.editable and self.layer.visible, + ) + self._set_transform_tool_state() + def _on_opacity_change(self): """Receive layer model opacity change event and update opacity slider.""" with self.layer.events.opacity.blocker(): @@ -148,8 +310,47 @@ def _on_ndisplay_changed(self) -> None: """Respond to a change to the number of dimensions displayed in the viewer. This is needed because some layer controls may have options that are specific - to 2D or 3D visualization only. + to 2D or 3D visualization only like the transform mode button. """ + self._set_transform_tool_state() + + def _set_transform_tool_state(self): + """ + Enable/disable transform button taking into account: + * Layer visibility. + * Layer editability. + * Number of dimensions being displayed. + """ + set_widgets_enabled_with_opacity( + self, + [self.transform_button], + self.layer.editable and self.layer.visible and self.ndisplay == 2, + ) + + def eventFilter(self, qobject, event): + """ + Event filter implementation to handle the Alt + Left mouse click interaction to + reset the layer transform. + + For more info about Qt Event Filters you can check: + https://doc.qt.io/qt-6/eventsandfilters.html#event-filters + """ + if ( + qobject == self.transform_button + and event.type() == QMouseEvent.MouseButtonRelease + and event.button() == Qt.MouseButton.LeftButton + and event.modifiers() == Qt.AltModifier + ): + result = QMessageBox.warning( + self, + trans._('Reset transform'), + trans._('Are you sure you want to reset transforms?'), + QMessageBox.Yes | QMessageBox.No, + ) + if result == QMessageBox.Yes: + self.layer._reset_affine() + return True + return super().eventFilter(qobject, event) def deleteLater(self): disconnect_events(self.layer.events, self) diff --git a/napari/_qt/layer_controls/qt_points_controls.py b/napari/_qt/layer_controls/qt_points_controls.py index 293ede1a77e..e6fb4503dae 100644 --- a/napari/_qt/layer_controls/qt_points_controls.py +++ b/napari/_qt/layer_controls/qt_points_controls.py @@ -3,19 +3,16 @@ import numpy as np from qtpy.QtCore import Qt, Slot -from qtpy.QtWidgets import QButtonGroup, QCheckBox, QComboBox, QHBoxLayout +from qtpy.QtWidgets import ( + QCheckBox, + QComboBox, +) from napari._qt.layer_controls.qt_layer_controls_base import QtLayerControls -from napari._qt.utils import ( - qt_signals_blocked, - set_widgets_enabled_with_opacity, -) +from napari._qt.utils import qt_signals_blocked from napari._qt.widgets._slider_compat import QSlider from napari._qt.widgets.qt_color_swatch import QColorSwatchEdit -from napari._qt.widgets.qt_mode_buttons import ( - QtModePushButton, - QtModeRadioButton, -) +from napari._qt.widgets.qt_mode_buttons import QtModePushButton from napari.layers.points._points_constants import ( SYMBOL_TRANSLATION, SYMBOL_TRANSLATION_INVERTED, @@ -39,7 +36,15 @@ class QtPointsControls(QtLayerControls): Attributes ---------- - addition_button : qtpy.QtWidgets.QtModeRadioButton + layer : napari.layers.Points + An instance of a napari Points 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. + addition_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to add points to layer. button_group : qtpy.QtWidgets.QButtonGroup Button group of points layer modes (ADD, PAN_ZOOM, SELECT). @@ -49,13 +54,13 @@ class QtPointsControls(QtLayerControls): Widget to select display color for points borders. faceColorEdit : QColorSwatchEdit Widget to select display color for points faces. - layer : napari.layers.Points - An instance of a napari Points layer. outOfSliceCheckBox : qtpy.QtWidgets.QCheckBox Checkbox to indicate whether to render out of slice. - panzoom_button : qtpy.QtWidgets.QtModeRadioButton + panzoom_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button for pan/zoom mode. - select_button : qtpy.QtWidgets.QtModeRadioButton + transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton + Button to select transform mode. + select_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select points from layer. sizeSlider : qtpy.QtWidgets.QSlider Slider controlling size of points. @@ -70,11 +75,13 @@ class QtPointsControls(QtLayerControls): """ layer: 'napari.layers.Points' + MODE = Mode + PAN_ZOOM_ACTION_NAME = 'activate_points_pan_zoom_mode' + TRANSFORM_ACTION_NAME = 'activate_points_transform_mode' def __init__(self, layer) -> None: super().__init__(layer) - self.layer.events.mode.connect(self._on_mode_change) self.layer.events.out_of_slice_display.connect( self._on_out_of_slice_display_change ) @@ -95,8 +102,6 @@ def __init__(self, layer) -> None: self.layer.events.current_symbol.connect( self._on_current_symbol_change ) - self.layer.events.editable.connect(self._on_editable_or_visible_change) - self.layer.events.visible.connect(self._on_editable_or_visible_change) self.layer.text.events.visible.connect(self._on_text_visibility_change) sld = QSlider(Qt.Orientation.Horizontal) @@ -156,27 +161,26 @@ def __init__(self, layer) -> None: self.outOfSliceCheckBox.setChecked(self.layer.out_of_slice_display) self.outOfSliceCheckBox.stateChanged.connect(self.change_out_of_slice) - self.select_button = QtModeRadioButton( + self.textDispCheckBox = QCheckBox() + self.textDispCheckBox.setToolTip(trans._('toggle text visibility')) + self.textDispCheckBox.setChecked(self.layer.text.visible) + self.textDispCheckBox.stateChanged.connect(self.change_text_visibility) + + self.select_button = self._radio_button( layer, 'select_points', Mode.SELECT, + True, + 'activate_points_select_mode', ) - action_manager.bind_button( - 'napari:activate_points_select_mode', self.select_button - ) - self.addition_button = QtModeRadioButton(layer, 'add_points', Mode.ADD) - action_manager.bind_button( - 'napari:activate_points_add_mode', self.addition_button - ) - self.panzoom_button = QtModeRadioButton( + self.addition_button = self._radio_button( layer, - 'pan', - Mode.PAN_ZOOM, - checked=True, - ) - action_manager.bind_button( - 'napari:activate_points_pan_zoom_mode', self.panzoom_button + 'add_points', + Mode.ADD, + True, + 'activate_points_add_mode', ) + self.delete_button = QtModePushButton( layer, 'delete_shape', @@ -184,34 +188,14 @@ def __init__(self, layer) -> None: action_manager.bind_button( 'napari:delete_selected_points', self.delete_button ) - - self.textDispCheckBox = QCheckBox() - self.textDispCheckBox.setToolTip(trans._('toggle text visibility')) - self.textDispCheckBox.setChecked(self.layer.text.visible) - self.textDispCheckBox.stateChanged.connect(self.change_text_visibility) - - self._EDIT_BUTTONS = ( - self.select_button, - self.addition_button, - self.delete_button, - ) - - self.button_group = QButtonGroup(self) - self.button_group.addButton(self.select_button) - self.button_group.addButton(self.addition_button) - self.button_group.addButton(self.panzoom_button) + self._EDIT_BUTTONS += (self.delete_button,) self._on_editable_or_visible_change() - button_row = QHBoxLayout() - button_row.addStretch(1) - button_row.addWidget(self.delete_button) - button_row.addWidget(self.addition_button) - button_row.addWidget(self.select_button) - button_row.addWidget(self.panzoom_button) - button_row.setContentsMargins(0, 0, 0, 5) - button_row.setSpacing(4) + self.button_grid.addWidget(self.delete_button, 0, 3) + self.button_grid.addWidget(self.addition_button, 0, 4) + self.button_grid.addWidget(self.select_button, 0, 5) - self.layout().addRow(button_row) + self.layout().addRow(self.button_grid) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('point size:'), self.sizeSlider) self.layout().addRow(trans._('blending:'), self.blendComboBox) @@ -221,34 +205,6 @@ def __init__(self, layer) -> None: self.layout().addRow(trans._('display text:'), self.textDispCheckBox) self.layout().addRow(trans._('out of slice:'), self.outOfSliceCheckBox) - def _on_mode_change(self, event): - """Update ticks in checkbox widgets when points layer mode is changed. - - Available modes for points layer are: - * ADD - * SELECT - * PAN_ZOOM - - Parameters - ---------- - event : napari.utils.event.Event - The napari event that triggered this method. - - Raises - ------ - ValueError - Raise error if event.mode is not ADD, PAN_ZOOM, or SELECT. - """ - mode = event.mode - if mode == Mode.ADD: - self.addition_button.setChecked(True) - elif mode == Mode.SELECT: - self.select_button.setChecked(True) - elif mode == Mode.PAN_ZOOM: - self.panzoom_button.setChecked(True) - elif mode != Mode.TRANSFORM: - raise ValueError(trans._('Mode not recognized {mode}', mode=mode)) - def changeCurrentSymbol(self, text): """Change marker symbol of the points on the layer model. @@ -301,6 +257,27 @@ def change_text_visibility(self, state): # needs cast to bool for Qt6 self.layer.text.visible = bool(state) + def _on_mode_change(self, event): + """Update ticks in checkbox widgets when points layer mode is changed. + + Available modes for points layer are: + * ADD + * SELECT + * PAN_ZOOM + * TRANSFORM + + Parameters + ---------- + event : napari.utils.event.Event + The napari event that triggered this method. + + Raises + ------ + ValueError + Raise error if event.mode is not ADD, PAN_ZOOM, TRANSFORM or SELECT. + """ + super()._on_mode_change(event) + def _on_text_visibility_change(self): """Receive layer model text visibiltiy change event and update checkbox.""" with qt_signals_blocked(self.textDispCheckBox): @@ -359,14 +336,7 @@ def _on_current_border_color_change(self): def _on_ndisplay_changed(self): self.layer.editable = not (self.layer.ndim == 2 and self.ndisplay == 3) - - def _on_editable_or_visible_change(self): - """Receive layer model editable/visible change event & enable/disable buttons.""" - set_widgets_enabled_with_opacity( - self, - self._EDIT_BUTTONS, - self.layer.editable and self.layer.visible, - ) + super()._on_ndisplay_changed() def close(self): """Disconnect events when widget is closing.""" diff --git a/napari/_qt/layer_controls/qt_shapes_controls.py b/napari/_qt/layer_controls/qt_shapes_controls.py index 3ac5ddbdf09..e0eecae1370 100644 --- a/napari/_qt/layer_controls/qt_shapes_controls.py +++ b/napari/_qt/layer_controls/qt_shapes_controls.py @@ -3,19 +3,13 @@ import numpy as np from qtpy.QtCore import Qt -from qtpy.QtWidgets import QButtonGroup, QCheckBox, QGridLayout +from qtpy.QtWidgets import QCheckBox from napari._qt.layer_controls.qt_layer_controls_base import QtLayerControls -from napari._qt.utils import ( - qt_signals_blocked, - set_widgets_enabled_with_opacity, -) +from napari._qt.utils import qt_signals_blocked from napari._qt.widgets._slider_compat import QSlider from napari._qt.widgets.qt_color_swatch import QColorSwatchEdit -from napari._qt.widgets.qt_mode_buttons import ( - QtModePushButton, - QtModeRadioButton, -) +from napari._qt.widgets.qt_mode_buttons import QtModePushButton from napari.layers.shapes._shapes_constants import Mode from napari.utils.action_manager import action_manager from napari.utils.events import disconnect_events @@ -36,45 +30,53 @@ class QtShapesControls(QtLayerControls): Attributes ---------- + layer : napari.layers.Shapes + An instance of a napari Shapes 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 shapes layer modes (SELECT, DIRECT, PAN_ZOOM, ADD_RECTANGLE, ADD_ELLIPSE, ADD_LINE, - ADD_PATH, ADD_POLYGON, VERTEX_INSERT, VERTEX_REMOVE). + ADD_PATH, ADD_POLYGON, VERTEX_INSERT, VERTEX_REMOVE, TRANSFORM). delete_button : qtpy.QtWidgets.QtModePushButton Button to delete selected shapes - direct_button : qtpy.QtWidgets.QtModeRadioButton + direct_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select individual vertices in shapes. edgeColorEdit : QColorSwatchEdit Widget allowing user to set edge color of points. - ellipse_button : qtpy.QtWidgets.QtModeRadioButton + ellipse_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to add ellipses to shapes layer. faceColorEdit : QColorSwatchEdit Widget allowing user to set face color of points. - layer : napari.layers.Shapes - An instance of a napari Shapes layer. - line_button : qtpy.QtWidgets.QtModeRadioButton + line_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to add lines to shapes layer. move_back_button : qtpy.QtWidgets.QtModePushButton Button to move selected shape(s) to the back. move_front_button : qtpy.QtWidgets.QtModePushButton Button to move shape(s) to the front. - panzoom_button : qtpy.QtWidgets.QtModeRadioButton + panzoom_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to pan/zoom shapes layer. - path_button : qtpy.QtWidgets.QtModeRadioButton + transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton + Button to transform shapes layer. + path_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to add paths to shapes layer. - polygon_button : qtpy.QtWidgets.QtModeRadioButton + polygon_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to add polygons to shapes layer. - polygon_lasso_button : qtpy.QtWidgets.QtModeRadioButton + polygon_lasso_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to add polygons to shapes layer with a lasso tool. - rectangle_button : qtpy.QtWidgets.QtModeRadioButton + rectangle_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to add rectangles to shapes layer. - select_button : qtpy.QtWidgets.QtModeRadioButton + select_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to select shapes. textDispCheckBox : qtpy.QtWidgets.QCheckBox Checkbox to control if text should be displayed - vertex_insert_button : qtpy.QtWidgets.QtModeRadioButton + vertex_insert_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to insert vertex into shape. - vertex_remove_button : qtpy.QtWidgets.QtModeRadioButton + vertex_remove_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton Button to remove vertex from shapes. widthSlider : qtpy.QtWidgets.QSlider Slider controlling line edge width of shapes. @@ -86,11 +88,13 @@ class QtShapesControls(QtLayerControls): """ layer: 'napari.layers.Shapes' + MODE = Mode + PAN_ZOOM_ACTION_NAME = 'activate_shapes_pan_zoom_mode' + TRANSFORM_ACTION_NAME = 'activate_shapes_transform_mode' def __init__(self, layer) -> None: super().__init__(layer) - self.layer.events.mode.connect(self._on_mode_change) self.layer.events.edge_width.connect(self._on_edge_width_change) self.layer.events.current_edge_color.connect( self._on_current_edge_color_change @@ -98,8 +102,6 @@ def __init__(self, layer) -> None: self.layer.events.current_face_color.connect( self._on_current_face_color_change ) - self.layer.events.editable.connect(self._on_editable_or_visible_change) - self.layer.events.visible.connect(self._on_editable_or_visible_change) self.layer.text.events.visible.connect(self._on_text_visibility_change) sld = QSlider(Qt.Orientation.Horizontal) @@ -116,111 +118,58 @@ def __init__(self, layer) -> None: sld.valueChanged.connect(self.changeWidth) self.widthSlider = sld - def _radio_button( - parent, - btn_name, - mode, - action_name, - extra_tooltip_text='', - **kwargs, - ): - """ - Convenience local function to create a RadioButton and bind it to - an action at the same time. - - Parameters - ---------- - parent : Any - Parent of the generated QtModeRadioButton - btn_name : str - name fo the button - mode : Enum - Value Associated to current button - action_name : str - Action triggered when button pressed - extra_tooltip_text : str - Text you want added after the automatic tooltip set by the - action manager - **kwargs: - Passed to QtModeRadioButton - - Returns - ------- - button: QtModeRadioButton - button bound (or that will be bound to) to action `action_name` - - Notes - ----- - When shortcuts are modifed/added/removed via the action manager, the - tooltip will be updated to reflect the new shortcut. - """ - action_name = f'napari:{action_name}' - btn = QtModeRadioButton(parent, btn_name, mode, **kwargs) - action_manager.bind_button( - action_name, - btn, - extra_tooltip_text='', - ) - return btn - - self.select_button = _radio_button( - layer, 'select', Mode.SELECT, 'activate_select_mode' + self.select_button = self._radio_button( + layer, 'select', Mode.SELECT, True, 'activate_select_mode' ) - - self.direct_button = _radio_button( - layer, 'direct', Mode.DIRECT, 'activate_direct_mode' + self.direct_button = self._radio_button( + layer, 'direct', Mode.DIRECT, True, 'activate_direct_mode' ) - - self.panzoom_button = _radio_button( - layer, - 'pan', - Mode.PAN_ZOOM, - 'activate_shapes_pan_zoom_mode', - extra_tooltip_text=trans._('(or hold Space)'), - checked=True, - ) - - self.rectangle_button = _radio_button( + self.rectangle_button = self._radio_button( layer, 'rectangle', Mode.ADD_RECTANGLE, + True, 'activate_add_rectangle_mode', ) - self.ellipse_button = _radio_button( + self.ellipse_button = self._radio_button( layer, 'ellipse', Mode.ADD_ELLIPSE, + True, 'activate_add_ellipse_mode', ) - - self.line_button = _radio_button( - layer, 'line', Mode.ADD_LINE, 'activate_add_line_mode' + self.line_button = self._radio_button( + layer, 'line', Mode.ADD_LINE, True, 'activate_add_line_mode' ) - self.path_button = _radio_button( - layer, 'path', Mode.ADD_PATH, 'activate_add_path_mode' + self.path_button = self._radio_button( + layer, 'path', Mode.ADD_PATH, True, 'activate_add_path_mode' ) - self.polygon_button = _radio_button( + self.polygon_button = self._radio_button( layer, 'polygon', Mode.ADD_POLYGON, + True, 'activate_add_polygon_mode', ) - self.polygon_lasso_button = _radio_button( + self.polygon_lasso_button = self._radio_button( layer, 'polygon_lasso', Mode.ADD_POLYGON_LASSO, + True, 'activate_add_polygon_lasso_mode', ) - self.vertex_insert_button = _radio_button( + self.vertex_insert_button = self._radio_button( layer, 'vertex_insert', Mode.VERTEX_INSERT, + True, 'activate_vertex_insert_mode', ) - self.vertex_remove_button = _radio_button( + self.vertex_remove_button = self._radio_button( layer, 'vertex_remove', Mode.VERTEX_REMOVE, + True, 'activate_vertex_remove_mode', ) @@ -230,11 +179,9 @@ def _radio_button( slot=self.layer.move_to_front, tooltip=trans._('Move to front'), ) - action_manager.bind_button( 'napari:move_shapes_selection_to_front', self.move_front_button ) - self.move_back_button = QtModePushButton( layer, 'move_back', @@ -244,7 +191,6 @@ def _radio_button( action_manager.bind_button( 'napari:move_shapes_selection_to_back', self.move_back_button ) - self.delete_button = QtModePushButton( layer, 'delete_shape', @@ -254,55 +200,29 @@ def _radio_button( shortcut=Shortcut('Backspace').platform, ), ) - - self._EDIT_BUTTONS = ( - self.select_button, - self.direct_button, - self.rectangle_button, - self.ellipse_button, - self.line_button, - self.path_button, - self.polygon_button, - self.polygon_lasso_button, - self.vertex_remove_button, - self.vertex_insert_button, + self._EDIT_BUTTONS += ( self.delete_button, self.move_back_button, self.move_front_button, ) - - self.button_group = QButtonGroup(self) - self.button_group.addButton(self.select_button) - self.button_group.addButton(self.direct_button) - self.button_group.addButton(self.panzoom_button) - self.button_group.addButton(self.rectangle_button) - self.button_group.addButton(self.ellipse_button) - self.button_group.addButton(self.line_button) - self.button_group.addButton(self.path_button) - self.button_group.addButton(self.polygon_button) - self.button_group.addButton(self.polygon_lasso_button) - self.button_group.addButton(self.vertex_insert_button) - self.button_group.addButton(self.vertex_remove_button) self._on_editable_or_visible_change() - button_grid = QGridLayout() - button_grid.addWidget(self.vertex_remove_button, 0, 2) - button_grid.addWidget(self.vertex_insert_button, 0, 3) - button_grid.addWidget(self.delete_button, 0, 4) - button_grid.addWidget(self.direct_button, 0, 5) - button_grid.addWidget(self.select_button, 0, 6) - button_grid.addWidget(self.panzoom_button, 0, 7) - button_grid.addWidget(self.move_back_button, 1, 0) - button_grid.addWidget(self.move_front_button, 1, 1) - button_grid.addWidget(self.ellipse_button, 1, 2) - button_grid.addWidget(self.rectangle_button, 1, 3) - button_grid.addWidget(self.polygon_button, 1, 4) - button_grid.addWidget(self.polygon_lasso_button, 1, 5) - button_grid.addWidget(self.line_button, 1, 6) - button_grid.addWidget(self.path_button, 1, 7) - button_grid.setContentsMargins(5, 0, 0, 5) - button_grid.setColumnStretch(0, 1) - button_grid.setSpacing(4) + self.button_grid.addWidget(self.vertex_remove_button, 0, 1) + self.button_grid.addWidget(self.vertex_insert_button, 0, 2) + self.button_grid.addWidget(self.delete_button, 0, 3) + self.button_grid.addWidget(self.direct_button, 0, 4) + self.button_grid.addWidget(self.select_button, 0, 5) + self.button_grid.addWidget(self.move_back_button, 1, 0) + self.button_grid.addWidget(self.move_front_button, 1, 1) + self.button_grid.addWidget(self.ellipse_button, 1, 2) + self.button_grid.addWidget(self.rectangle_button, 1, 3) + self.button_grid.addWidget(self.polygon_button, 1, 4) + self.button_grid.addWidget(self.polygon_lasso_button, 1, 5) + self.button_grid.addWidget(self.line_button, 1, 6) + self.button_grid.addWidget(self.path_button, 1, 7) + self.button_grid.setContentsMargins(5, 0, 0, 5) + self.button_grid.setColumnStretch(0, 1) + self.button_grid.setSpacing(4) self.faceColorEdit = QColorSwatchEdit( initial_color=self.layer.current_face_color, @@ -323,7 +243,7 @@ def _radio_button( text_disp_cb.stateChanged.connect(self.change_text_visibility) self.textDispCheckBox = text_disp_cb - self.layout().addRow(button_grid) + self.layout().addRow(self.button_grid) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('edge width:'), self.widthSlider) self.layout().addRow(trans._('blending:'), self.blendComboBox) @@ -331,52 +251,6 @@ def _radio_button( self.layout().addRow(trans._('edge color:'), self.edgeColorEdit) self.layout().addRow(trans._('display text:'), self.textDispCheckBox) - def _on_mode_change(self, event): - """Update ticks in checkbox widgets when shapes layer mode changed. - - Available modes for shapes layer are: - * SELECT - * DIRECT - * PAN_ZOOM - * ADD_RECTANGLE - * ADD_ELLIPSE - * ADD_LINE - * ADD_PATH - * ADD_POLYGON - * VERTEX_INSERT - * VERTEX_REMOVE - - Parameters - ---------- - event : napari.utils.event.Event - The napari event that triggered this method. - - Raises - ------ - ValueError - Raise error if event.mode is not ADD, PAN_ZOOM, or SELECT. - """ - mode_buttons = { - Mode.SELECT: self.select_button, - Mode.DIRECT: self.direct_button, - Mode.PAN_ZOOM: self.panzoom_button, - Mode.ADD_RECTANGLE: self.rectangle_button, - Mode.ADD_ELLIPSE: self.ellipse_button, - Mode.ADD_LINE: self.line_button, - Mode.ADD_PATH: self.path_button, - Mode.ADD_POLYGON: self.polygon_button, - Mode.ADD_POLYGON_LASSO: self.polygon_lasso_button, - Mode.VERTEX_INSERT: self.vertex_insert_button, - Mode.VERTEX_REMOVE: self.vertex_remove_button, - } - - if event.mode in mode_buttons: - mode_buttons[event.mode].setChecked(True) - elif event.mode != Mode.TRANSFORM: - raise ValueError( - trans._("Mode '{mode}'not recognized", mode=event.mode) - ) - def changeFaceColor(self, color: np.ndarray): """Change face color of shapes. @@ -421,6 +295,35 @@ def change_text_visibility(self, state): """ self.layer.text.visible = Qt.CheckState(state) == Qt.CheckState.Checked + def _on_mode_change(self, event): + """Update ticks in checkbox widgets when shapes layer mode changed. + + Available modes for shapes layer are: + * SELECT + * DIRECT + * PAN_ZOOM + * ADD_RECTANGLE + * ADD_ELLIPSE + * ADD_LINE + * ADD_PATH + * ADD_POLYGON + * ADD_POLYGON_LASSO + * VERTEX_INSERT + * VERTEX_REMOVE + * TRANSFORM + + Parameters + ---------- + event : napari.utils.event.Event + The napari event that triggered this method. + + Raises + ------ + ValueError + Raise error if event.mode is not one of the available modes. + """ + super()._on_mode_change(event) + def _on_text_visibility_change(self): """Receive layer model text visibiltiy change change event and update checkbox.""" with self.layer.text.events.visible.blocker(): @@ -445,14 +348,7 @@ def _on_current_face_color_change(self): def _on_ndisplay_changed(self): self.layer.editable = self.ndisplay == 2 - - def _on_editable_or_visible_change(self): - """Receive layer model editable/visible change event & enable/disable buttons.""" - set_widgets_enabled_with_opacity( - self, - self._EDIT_BUTTONS, - self.layer.editable and self.layer.visible, - ) + super()._on_ndisplay_changed() def close(self): """Disconnect events when widget is closing.""" diff --git a/napari/_qt/layer_controls/qt_surface_controls.py b/napari/_qt/layer_controls/qt_surface_controls.py index 1549405bbe7..9a0edfd30bb 100644 --- a/napari/_qt/layer_controls/qt_surface_controls.py +++ b/napari/_qt/layer_controls/qt_surface_controls.py @@ -24,10 +24,26 @@ class QtSurfaceControls(QtBaseImageControls): ---------- layer : napari.layers.Surface An instance of a napari Surface 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. """ layer: 'napari.layers.Surface' + PAN_ZOOM_ACTION_NAME = 'activate_surface_pan_zoom_mode' + TRANSFORM_ACTION_NAME = 'activate_surface_transform_mode' def __init__(self, layer) -> None: super().__init__(layer) @@ -47,6 +63,7 @@ def __init__(self, layer) -> None: shading_comboBox.currentTextChanged.connect(self.changeShading) self.shadingComboBox = shading_comboBox + self.layout().addRow(self.button_grid) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow( trans._('contrast limits:'), self.contrastLimitsSlider diff --git a/napari/_qt/layer_controls/qt_tracks_controls.py b/napari/_qt/layer_controls/qt_tracks_controls.py index ed9b3dc5562..7147c804ecb 100644 --- a/napari/_qt/layer_controls/qt_tracks_controls.py +++ b/napari/_qt/layer_controls/qt_tracks_controls.py @@ -1,10 +1,15 @@ from typing import TYPE_CHECKING from qtpy.QtCore import Qt -from qtpy.QtWidgets import QCheckBox, QComboBox, QSlider +from qtpy.QtWidgets import ( + QCheckBox, + QComboBox, + QSlider, +) from napari._qt.layer_controls.qt_layer_controls_base import QtLayerControls from napari._qt.utils import qt_signals_blocked +from napari.layers.base._base_constants import Mode from napari.utils.colormaps import AVAILABLE_COLORMAPS from napari.utils.translations import trans @@ -24,21 +29,30 @@ class QtTracksControls(QtLayerControls): ---------- layer : layers.Tracks An instance of a Tracks layer. + button_group : qtpy.QtWidgets.QButtonGroup + Button group of points layer modes (ADD, PAN_ZOOM, SELECT). + panzoom_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton + Button for pan/zoom mode. + transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton + Button to select transform mode. """ layer: 'napari.layers.Tracks' + MODE = Mode + PAN_ZOOM_ACTION_NAME = 'activate_tracks_pan_zoom_mode' + TRANSFORM_ACTION_NAME = 'activate_tracks_transform_mode' def __init__(self, layer) -> None: super().__init__(layer) # NOTE(arl): there are no events fired for changing checkboxes + self.layer.events.color_by.connect(self._on_color_by_change) self.layer.events.tail_width.connect(self._on_tail_width_change) self.layer.events.tail_length.connect(self._on_tail_length_change) self.layer.events.head_length.connect(self._on_head_length_change) self.layer.events.properties.connect(self._on_properties_change) self.layer.events.colormap.connect(self._on_colormap_change) - self.layer.events.color_by.connect(self._on_color_by_change) # combo box for track coloring, we can get these from the properties # keys @@ -87,6 +101,7 @@ def __init__(self, layer) -> None: self.color_by_combobox.currentTextChanged.connect(self.change_color_by) self.colormap_combobox.currentTextChanged.connect(self.change_colormap) + self.layout().addRow(self.button_grid) self.layout().addRow(trans._('color by:'), self.color_by_combobox) self.layout().addRow(trans._('colormap:'), self.colormap_combobox) self.layout().addRow(trans._('blending:'), self.blendComboBox) diff --git a/napari/_qt/layer_controls/qt_vectors_controls.py b/napari/_qt/layer_controls/qt_vectors_controls.py index d49811252e1..3c22fbce50e 100644 --- a/napari/_qt/layer_controls/qt_vectors_controls.py +++ b/napari/_qt/layer_controls/qt_vectors_controls.py @@ -2,11 +2,17 @@ import numpy as np from qtpy.QtCore import Qt -from qtpy.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QLabel +from qtpy.QtWidgets import ( + QCheckBox, + QComboBox, + QDoubleSpinBox, + QLabel, +) from napari._qt.layer_controls.qt_layer_controls_base import QtLayerControls from napari._qt.utils import qt_signals_blocked from napari._qt.widgets.qt_color_swatch import QColorSwatchEdit +from napari.layers.base._base_constants import Mode from napari.layers.utils._color_manager_constants import ColorMode from napari.layers.vectors._vectors_constants import VECTORSTYLE_TRANSLATIONS from napari.utils.translations import trans @@ -25,6 +31,20 @@ class QtVectorsControls(QtLayerControls): Attributes ---------- + layer : napari.layers.Vectors + An instance of a napari Vectors 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 of points layer modes (ADD, PAN_ZOOM, SELECT). + panzoom_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton + Button for pan/zoom mode. + transform_button : napari._qt.widgets.qt_mode_button.QtModeRadioButton + Button to select transform mode. edge_color_label : qtpy.QtWidgets.QLabel Label for edgeColorSwatch edgeColorEdit : QColorSwatchEdit @@ -51,6 +71,9 @@ class QtVectorsControls(QtLayerControls): """ layer: 'napari.layers.Vectors' + MODE = Mode + PAN_ZOOM_ACTION_NAME = 'activate_tracks_pan_zoom_mode' + TRANSFORM_ACTION_NAME = 'activate_tracks_transform_mode' def __init__(self, layer) -> None: super().__init__(layer) @@ -122,6 +145,7 @@ def __init__(self, layer) -> None: out_of_slice_cb.stateChanged.connect(self.change_out_of_slice) self.outOfSliceCheckBox = out_of_slice_cb + self.layout().addRow(self.button_grid) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('width:'), self.widthSpinBox) self.layout().addRow(trans._('length:'), self.lengthSpinBox) diff --git a/napari/_qt/qt_resources/styles/01_buttons.qss b/napari/_qt/qt_resources/styles/01_buttons.qss index e605dea21e3..69331e86111 100644 --- a/napari/_qt/qt_resources/styles/01_buttons.qss +++ b/napari/_qt/qt_resources/styles/01_buttons.qss @@ -96,6 +96,10 @@ QtModeRadioButton[mode="pan"]::indicator { image: url("theme_{{ id }}:/pan_arrows.svg"); } +QtModeRadioButton[mode="transform"]::indicator { + image: url("theme_{{ id }}:/transform.svg"); +} + QtModeRadioButton[mode="select"]::indicator { image: url("theme_{{ id }}:/select.svg"); } diff --git a/napari/components/viewer_model.py b/napari/components/viewer_model.py index 7da9feab70d..1a92008fcf7 100644 --- a/napari/components/viewer_model.py +++ b/napari/components/viewer_model.py @@ -463,9 +463,15 @@ def _on_active_layer(self, event): """Update viewer state for a new active layer.""" active_layer = event.value if active_layer is None: + for layer in self.layers: + layer.update_transform_box_visibility(False) self.help = '' self.cursor.style = CursorStyle.STANDARD else: + active_layer.update_transform_box_visibility(True) + for layer in self.layers: + if layer != active_layer: + layer.update_transform_box_visibility(False) self.help = active_layer.help self.cursor.style = active_layer.cursor self.cursor.size = active_layer.cursor_size diff --git a/napari/layers/base/base.py b/napari/layers/base/base.py index 406d3383358..532541f5313 100644 --- a/napari/layers/base/base.py +++ b/napari/layers/base/base.py @@ -411,6 +411,9 @@ def __init__( scale = [1] * ndim if translate is None: translate = [0] * ndim + self._initial_affine = coerce_affine( + affine, ndim=ndim, name='physical2world' + ) self._transforms: TransformChain[Affine] = TransformChain( [ Affine(np.ones(ndim), np.zeros(ndim), name='tile2data'), @@ -424,7 +427,7 @@ def __init__( name='data2physical', units=units, ), - coerce_affine(affine, ndim=ndim, name='physical2world'), + self._initial_affine, Affine(np.ones(ndim), np.zeros(ndim), name='world2grid'), ] ) @@ -579,6 +582,13 @@ def _mode_setter_helper(self, mode_in: Union[Mode, str]) -> StringEnum: return mode + def update_transform_box_visibility(self, visible): + if 'transform_box' in self._overlays: + TRANSFORM = self._modeclass.TRANSFORM # type: ignore[attr-defined] + self._overlays['transform_box'].visible = ( + self.mode == TRANSFORM and visible + ) + @property def mode(self) -> str: """str: Interactive mode @@ -869,6 +879,9 @@ def affine(self, affine: Union[npt.ArrayLike, Affine]) -> None: self._clear_extents_and_refresh() self.events.affine() + def _reset_affine(self) -> None: + self.affine = self._initial_affine + @property def _translate_grid(self) -> npt.NDArray: """array: Factors to shift the layer by.""" diff --git a/napari/layers/tracks/_tracks_key_bindings.py b/napari/layers/tracks/_tracks_key_bindings.py index d53a26efb60..94e7b839e4d 100644 --- a/napari/layers/tracks/_tracks_key_bindings.py +++ b/napari/layers/tracks/_tracks_key_bindings.py @@ -16,8 +16,8 @@ def register_tracks_mode_action(description): @register_tracks_mode_action(trans._('Transform')) -def activate_tracks_transform_mode(layer): - layer.mode = Mode.TRANSFORM +def activate_tracks_transform_mode(layer: Tracks) -> None: + layer.mode = str(Mode.TRANSFORM) @register_tracks_mode_action(trans._('Pan/zoom')) diff --git a/napari/resources/icons/transform.svg b/napari/resources/icons/transform.svg new file mode 100644 index 00000000000..b38779d6638 --- /dev/null +++ b/napari/resources/icons/transform.svg @@ -0,0 +1,10 @@ +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 25 25" style="enable-background:new 0 0 25 25;" xml:space="preserve"> +<path d="M11.6628 2.70711C12.0533 2.31658 12.6865 2.31658 13.077 2.70711L15.0099 4.64004C15.4004 5.03056 15.4004 5.66373 15.0099 6.05425C14.6194 6.44478 13.9862 6.44478 13.5957 6.05425L12.3699 4.82843L11.144 6.05425C10.7535 6.44478 10.1204 6.44478 9.72983 6.05425C9.33931 5.66373 9.33931 5.03056 9.72983 4.64004L11.6628 2.70711Z"/> +<path d="M16.7427 6.93503C17.295 6.93503 17.7427 7.38274 17.7427 7.93503V7.93513C17.7427 8.48741 17.295 8.93513 16.7427 8.93513C16.1904 8.93513 15.7427 8.48741 15.7427 7.93513V7.93503C15.7427 7.38274 16.1904 6.93503 16.7427 6.93503Z" /> +<path d="M7.74268 6.93503C7.19039 6.93503 6.74268 7.38274 6.74268 7.93503V7.93513C6.74268 8.48741 7.19039 8.93513 7.74268 8.93513C8.29496 8.93513 8.74268 8.48741 8.74268 7.93513V7.93503C8.74268 7.38274 8.29496 6.93503 7.74268 6.93503Z" /> +<path d="M22.208 11.838C22.5985 12.2285 22.5985 12.8617 22.208 13.2522L21.4896 13.9706L19.4872 13.1445L20.0867 12.5451L18.8609 11.3193C18.4703 10.9288 18.4703 10.2956 18.8609 9.90508C19.2514 9.51455 19.8845 9.51455 20.2751 9.90508L22.208 11.838ZM12.7797 19.5014L13.6058 21.5038L13.0771 22.0325C12.6865 22.4231 12.0534 22.4231 11.6628 22.0325L9.7299 20.0996C9.33938 19.7091 9.33938 19.0759 9.72991 18.6854C10.1204 18.2949 10.7536 18.2949 11.1441 18.6854L12.3699 19.9112L12.7797 19.5014Z" /> +<path d="M7.74268 17.935C7.19039 17.935 6.74268 17.4873 6.74268 16.935V16.9349C6.74268 16.3826 7.19039 15.9349 7.74268 15.9349C8.29496 15.9349 8.74268 16.3826 8.74268 16.9349V16.935C8.74268 17.4873 8.29496 17.935 7.74268 17.935Z" /> +<path d="M2.70711 11.838C2.31658 12.2285 2.31658 12.8617 2.70711 13.2522L4.64004 15.1851C5.03056 15.5757 5.66373 15.5757 6.05425 15.1851C6.44478 14.7946 6.44478 14.1615 6.05425 13.7709L4.82843 12.5451L6.05425 11.3193C6.44478 10.9288 6.44478 10.2956 6.05425 9.90507C5.66373 9.51454 5.03056 9.51454 4.64004 9.90507L2.70711 11.838Z"/> +<path d="M15.8886 21.793C15.9726 21.975 16.1546 22.1011 16.3506 22.1011H16.3926C16.6026 22.0871 16.7846 21.9331 16.8406 21.7231L17.7646 18.0271L21.4606 17.1031C21.6706 17.0471 21.8246 16.8651 21.8386 16.6551C21.8526 16.4311 21.7266 16.2351 21.5306 16.1511L13.0466 12.6511C12.8506 12.5811 12.6406 12.6231 12.5006 12.7631C12.3606 12.9031 12.3046 13.1271 12.3886 13.3091L15.8886 21.793Z" /> +</svg>