diff --git a/docs/make_api.py b/docs/make_api.py index fa2e3394a..d42ec5c29 100644 --- a/docs/make_api.py +++ b/docs/make_api.py @@ -25,6 +25,7 @@ EXCLUDE_MODULES = { "movement.cli_entrypoint", "movement.napari.loader_widgets", + "movement.napari.regions_widget", "movement.napari.meta_widget", } diff --git a/docs/source/_static/napari_bboxes_layer.png b/docs/source/_static/napari_bboxes_layer.png deleted file mode 100644 index ab795f596..000000000 Binary files a/docs/source/_static/napari_bboxes_layer.png and /dev/null differ diff --git a/docs/source/_static/napari_bboxes_layers.png b/docs/source/_static/napari_bboxes_layers.png new file mode 100644 index 000000000..b218b6519 Binary files /dev/null and b/docs/source/_static/napari_bboxes_layers.png differ diff --git a/docs/source/_static/napari_drawing_regions.png b/docs/source/_static/napari_drawing_regions.png new file mode 100644 index 000000000..78167797f Binary files /dev/null and b/docs/source/_static/napari_drawing_regions.png differ diff --git a/docs/source/_static/napari_new_region_layer.png b/docs/source/_static/napari_new_region_layer.png new file mode 100644 index 000000000..13b55495b Binary files /dev/null and b/docs/source/_static/napari_new_region_layer.png differ diff --git a/docs/source/_static/napari_plugin_data_tracks.png b/docs/source/_static/napari_plugin_data_tracks.png deleted file mode 100644 index 5d234fdcb..000000000 Binary files a/docs/source/_static/napari_plugin_data_tracks.png and /dev/null differ diff --git a/docs/source/_static/napari_plugin_video_slider.png b/docs/source/_static/napari_plugin_video_slider.png deleted file mode 100644 index c3b527cb0..000000000 Binary files a/docs/source/_static/napari_plugin_video_slider.png and /dev/null differ diff --git a/docs/source/_static/napari_points_layer_tooltip.png b/docs/source/_static/napari_points_layer_tooltip.png deleted file mode 100644 index cb54016f3..000000000 Binary files a/docs/source/_static/napari_points_layer_tooltip.png and /dev/null differ diff --git a/docs/source/_static/napari_points_tooltip.png b/docs/source/_static/napari_points_tooltip.png new file mode 100644 index 000000000..20f95804c Binary files /dev/null and b/docs/source/_static/napari_points_tooltip.png differ diff --git a/docs/source/_static/napari_poses_layers.png b/docs/source/_static/napari_poses_layers.png new file mode 100644 index 000000000..1f69231cc Binary files /dev/null and b/docs/source/_static/napari_poses_layers.png differ diff --git a/docs/source/_static/napari_tracks_layer_head_length.png b/docs/source/_static/napari_tracks_layer_head_length.png deleted file mode 100644 index 5a03bc3bc..000000000 Binary files a/docs/source/_static/napari_tracks_layer_head_length.png and /dev/null differ diff --git a/docs/source/_static/napari_tracks_sliders.png b/docs/source/_static/napari_tracks_sliders.png new file mode 100644 index 000000000..1c26126c3 Binary files /dev/null and b/docs/source/_static/napari_tracks_sliders.png differ diff --git a/docs/source/_static/napari_plugin_video_reader.png b/docs/source/_static/napari_video_reader.png similarity index 100% rename from docs/source/_static/napari_plugin_video_reader.png rename to docs/source/_static/napari_video_reader.png diff --git a/docs/source/_static/napari_video_slider.png b/docs/source/_static/napari_video_slider.png new file mode 100644 index 000000000..732e6996f Binary files /dev/null and b/docs/source/_static/napari_video_slider.png differ diff --git a/docs/source/community/contributing.md b/docs/source/community/contributing.md index 433c274db..03aea8e7e 100644 --- a/docs/source/community/contributing.md +++ b/docs/source/community/contributing.md @@ -471,6 +471,76 @@ SourceSoftware: TypeAlias = Literal[ ] ``` +### Developing the napari plugin + +The `movement` plugin for `napari` is built following the +[napari plugin guide](napari:plugins/building_a_plugin/index.html). +All widgets subclass `qtpy.QtWidgets.QWidget` (see the +[napari guide on widgets](napari:plugins/building_a_plugin/guides.html)). + +The plugin lives in [`movement.napari`](movement-github:tree/main/movement/napari) +and is structured as follows: + +- `movement.napari.meta_widget`: the top-level + container widget registered as the `napari` plugin entry point, + which brings together all other subwidgets: + - `movement.napari.loader_widgets`: a Qt form widget for + loading tracked datasets from supported file formats as + points, tracks and boxes. + - `movement.napari.regions_widget`: a Qt table widget for managing named + regions of interest drawn as `napari` shapes layers. + See the next section for more details on this widget's architecture. +- {mod}`movement.napari.layer_styles`: dataclasses that encapsulate visual + properties for each layer type. +- {mod}`movement.napari.convert`: functions for converting `movement` + datasets into the NumPy arrays and properties DataFrames + that `napari` layer constructors expect. + +#### Qt Model/View architecture + +`movement.napari.regions_widget` follows +[Qt's Model/View pattern](qt6:model-view-programming.html) +to separate the data (what is stored) from the display (how it is shown). +Understanding this pattern is helpful before making changes to this module, +and for creating new widgets that follow the same design principles. + +The three components are: + +- `RegionsTableModel` (subclasses + [`QAbstractTableModel`](qt6:qabstracttablemodel.html)): + wraps a `napari` [shapes layer](napari:howtos/layers/shapes.html) and + exposes its data to Qt (i.e., region names from `layer.properties["name"]` + and shape types). It listens to `napari` layer + [events](napari:guides/events_reference.html) and emits Qt signals + when the data changes. +- `RegionsTableView` (subclasses [`QTableView`](qt6:qtableview.html)): + renders the model's data as a table and handles user interactions + (e.g. row selection, inline name editing). Keeps table row selection + in sync with `napari`'s current shape selection. +- `RegionsWidget`: connects the table model and table view. It manages + layer selection, creates and links models to views, and handles layer + lifecycle events. + +Data flows in both directions: + +``` +napari shapes layer <-> RegionsTableModel <-> RegionsTableView <-> User +``` + +:::{dropdown} Preventing circular updates in bidirectional syncs +:color: success +:icon: light-bulb + +Bidirectional syncing between `napari` and the Qt table can cause circular +updates. For example, selecting a `napari` shape selects the corresponding +table row, which would then re-trigger shape selection. + +Guard flags such as `_syncing_row_selection` and `_syncing_layer_selection` +break this cycle: while a sync is in progress, the corresponding flag is set +to `True` and any events that would re-trigger it are ignored. Preserve this +pattern when adding new two-way sync logic. +::: + ### Continuous integration All pushes and pull requests will be built by [GitHub actions](github-docs:actions). This will usually include linting, testing and deployment. diff --git a/docs/source/conf.py b/docs/source/conf.py index c1500f293..1c9d3fd4e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -251,6 +251,7 @@ "uv": "https://docs.astral.sh/uv/{{path}}#{{fragment}}", "attrs": "https://www.attrs.org/en/stable/{{path}}#{{fragment}}", "pytest-benchmark": "https://pytest-benchmark.readthedocs.io/en/latest/{{path}}#{{fragment}}", + "qt6": "https://doc.qt.io/qt-6/{{path}}#{{fragment}}", } intersphinx_mapping = { diff --git a/docs/source/user_guide/gui.md b/docs/source/user_guide/gui.md index 18bd69c06..d206b3828 100644 --- a/docs/source/user_guide/gui.md +++ b/docs/source/user_guide/gui.md @@ -5,7 +5,8 @@ The `movement` graphical user interface (GUI), powered by our custom plugin for [napari](napari:), makes it easy to view and explore `movement` motion tracks. Currently, you can use it to visualise 2D [movement datasets](target-poses-and-bboxes-dataset) -as points, tracks, and rectangular bounding boxes (if defined) overlaid on video frames. +as points, tracks, and rectangular bounding boxes (if defined) overlaid on +video frames, as well as to define regions of interest (RoIs). :::{warning} The GUI is still in early stages of development but we are working on ironing @@ -61,7 +62,7 @@ plugin—and click `OK`. You can optionally select to remember this reader for all files with the same extension. -![napari widget video reader](../_static/napari_plugin_video_reader.png) +![napari video reader](../_static/napari_video_reader.png) `napari-video` will load the video as an image stack with a slider @@ -70,7 +71,7 @@ You may also use the left and right arrow keys to navigate frame-by-frame. -![napari widget video slider](../_static/napari_plugin_video_slider.png) +![napari video slider](../_static/napari_video_slider.png) Clicking on the play button will start the video playback at a default rate of 10 frames per second. You can adjust the playback speed by right-clicking on the @@ -199,11 +200,11 @@ For a poses dataset, you will see a view similar to this: (target-widget-screenshot)= -![napari widget with poses dataset loaded](../_static/napari_plugin_data_tracks.png) +![napari with poses dataset loaded](../_static/napari_poses_layers.png) And for a bounding boxes dataset, you will see a view more like the one below: -![napari widget with shapes loaded](../_static/napari_bboxes_layer.png) +![napari with bboxes dataset loaded](../_static/napari_bboxes_layers.png) Note the additional bounding boxes layer that is loaded for bounding boxes datasets. For both poses and bounding boxes datasets, you can toggle the visibility of any of these layers by clicking on the eye icon. @@ -230,7 +231,7 @@ and the time in seconds (calculated based on the frame number and the `fps` value). -![napari points layer tooltip](../_static/napari_points_layer_tooltip.png) +![napari points tooltip](../_static/napari_points_tooltip.png) @@ -276,7 +277,7 @@ selected layer shows the trajectories of the keypoints from the current frame un end of the video. -![napari tracks layer head length](../_static/napari_tracks_layer_head_length.png) +![napari tracks sliders](../_static/napari_tracks_sliders.png) You can also use the [tracks layer](napari:howtos/layers/tracks.html) controls panel to @@ -324,3 +325,119 @@ You can find all the [keyboard shortcuts](napari:guides/preferences.html#shortcu `napari` window, under `Preferences > Shortcuts`. ::: + +## Define regions of interest + +The `Define regions of interest` menu allows you to draw shapes on the +viewer and use them as regions of interest (RoIs) for analysis. + +Each shape you draw represents a static region +that remains fixed across all frames. + +:::{admonition} Terminology +:class: note + +**Shape** +: Any geometric object you draw on the `napari` canvas + (e.g. a polygon or a line). Shapes are grouped together in + `napari` [shapes layers](napari:howtos/layers/shapes.html). + +**Region** +: A named `napari` shape that `movement` recognises as a + region of interest (RoI) for analysis. + +**Region layer** +: A `napari` [shapes layers](napari:howtos/layers/shapes.html) managed + by `movement`, whose shapes are treated as **regions**. + +::: + + +### Create a region layer + +To define a region for your data, start by creating a region layer, +which will serve as a container. To do this: + +1. Ensure a [background layer](target-load-video) is loaded so that you + can see where you are drawing. +2. Expand the `Define regions of interest` menu on the right-hand side + of the `napari` window. +3. Click the `Add new layer` button. + +This will create a new region layer in the layer list and select it. +The layer also appears in the dropdown next to the `Add new layer` button. +The layer's default name is `regions`, but you can change it by double-clicking +on it in the layer list (this is the case for any `napari` layer). + +![napari new region layer](../_static/napari_new_region_layer.png) + +### Draw and edit regions + +With the region layer selected, you can use the native `napari` shape tools +in the layer controls panel to draw shapes on the canvas. +Convenient keyboard shortcuts are available to select the shape drawing tools, +for example: + +- `R`: rectangle tool +- `E`: ellipse tool +- `P`: polygon tool +- `L`: line tool + +Tools are also provided for selecting, moving, transforming, +and deleting shapes, as well as for editing their vertices. +You can also copy-paste shapes within a layer. +See the [napari shapes layer guide](napari:howtos/layers/shapes.html) for +details on the drawing tools and how to use them. + +Each shape you draw is automatically added to the widget's table of regions +on the right-hand side and auto-assigned a unique name +(e.g. `region`, `region_1`, `region_2`, etc.). + +![napari drawing regions](../_static/napari_drawing_regions.png) + +You can interact with the table in the following ways: + +- **Select**: clicking a row in the table selects the corresponding shape + on the canvas, and vice versa. This makes it easy to identify which + shape corresponds to which table entry. +- **Rename**: double-click the name cell of a region to edit its name. + Press `Enter` to confirm the change or `Escape` to cancel. +- **Delete**: select a shape on the canvas and press `Delete` (or + `Backspace`) to remove it. The corresponding row will be removed from + the table. + +:::{admonition} Changing shape appearance +:class: tip + +You can change the edge colour, face colour, edge width, and opacity of +shapes by selecting a shape (or multiple shapes) +and using the `napari` layer controls panel. +Once a shape's appearance has been adjusted, all subsequent shapes drawn in +the same layer will inherit those properties by default. + +To display region names on the canvas, tick the `display text` checkbox +in the layer controls panel. The text colour follows the shape's edge +colour. +::: + +### Save and load regions + +Save and load functionality for region layers is coming soon. +You will be able to save a region layer to a GeoJSON file, +and load it back later or share it with others. This will also allow you +to import drawn regions into your Python code using the +{func}`~movement.roi.load_rois` function. + +**Stay tuned!** + +### Working with multiple region layers + +You can use region layers to group related regions together. +For example, you might use one layer for "nesting zones" and another for +"foraging zones". + +To add another region layer, click the `Add new layer` button again. +You can switch between region layers using the layers dropdown, or by +selecting the layer directly in the `napari` layer list—both are kept in sync. +The regions table updates to show only the shapes +belonging to the selected layer. diff --git a/movement/napari/layer_styles.py b/movement/napari/layer_styles.py index 969123b25..b7ac1876c 100644 --- a/movement/napari/layer_styles.py +++ b/movement/napari/layer_styles.py @@ -4,6 +4,8 @@ import numpy as np import pandas as pd +from napari.layers import Shapes +from napari.utils.color import ColorValue from napari.utils.colormaps import ensure_colormap DEFAULT_COLORMAP = "turbo" @@ -123,7 +125,7 @@ def set_color_by(self, property: str, cmap: str | None = None) -> None: @dataclass class BoxesStyle(LayerStyle): - """Style properties for a napari Shapes layer.""" + """Style properties for a napari Shapes layer containing bounding boxes.""" edge_width: int = 3 opacity: float = 1.0 @@ -190,6 +192,70 @@ def set_text_by(self, property: str) -> None: self.text["string"] = property +@dataclass +class RegionsStyle(LayerStyle): + """Style properties for a napari Shapes layer containing regions. + + The same ``color`` is applied to faces, edges, and text. + The face color alpha is hardcoded to 0.25, while edges and text are + fully opaque. Overall layer opacity is set to 1.0. + """ + + name: str = "Regions" + color: str | tuple = "red" + edge_width: float = 5.0 + opacity: float = 1.0 + text: dict = field( + default_factory=lambda: { + "visible": False, + "anchor": "center", + } + ) + + @property + def face_color(self) -> ColorValue: + """Return the face color with transparency applied.""" + color = ColorValue(self.color) + color[-1] = 0.25 # this is hardcoded for now + return color + + @property + def edge_and_text_color(self) -> ColorValue: + """Return the opaque color for edges and text.""" + color = ColorValue(self.color) + color[-1] = 1.0 + return color + + def set_style_for_new_shapes(self, layer: Shapes) -> None: + """Set the style that napari will apply to newly drawn shapes. + + napari uses current_* properties to style shapes as they are drawn. + """ + layer.current_face_color = self.face_color + layer.current_edge_color = self.edge_and_text_color + layer.current_edge_width = self.edge_width + + def set_color_all_shapes(self, layer: Shapes) -> None: + """Set colors on all existing shapes in a napari Shapes layer.""" + # Configure text appearance + text_dict = layer.text.dict() + text_dict.update(self.text) + layer.text = text_dict + layer.text.color = self.edge_and_text_color + + # Set layer opacity and per-shape face/edge colors + layer.opacity = self.opacity + n_shapes = len(layer.data) + if n_shapes > 0: + layer.face_color = [self.face_color] * n_shapes + layer.edge_color = [self.edge_and_text_color] * n_shapes + layer.edge_width = [self.edge_width] * n_shapes + layer.text.string = "{name}" + layer.text.refresh(layer.features) + + self.set_style_for_new_shapes(layer) + + def _sample_colormap(n: int, cmap_name: str) -> list[tuple]: """Sample n equally-spaced colors from a napari colormap. diff --git a/movement/napari/loader_widgets.py b/movement/napari/loader_widgets.py index 267302693..abe2bf37b 100644 --- a/movement/napari/loader_widgets.py +++ b/movement/napari/loader_widgets.py @@ -396,12 +396,19 @@ def _update_frame_slider_range(self): with all NaN values, the frame slider range will not reflect the full range of frames. """ + + def _layer_has_data(layer): + if isinstance(layer, Shapes): + return len(layer.data) > 0 + return layer.data.shape[0] > 0 + # Only update the frame slider range if there are layers - # that are Points, Tracks, Image or Shapes + # that are Points, Tracks, Image or Shapes with data list_layers = [ ly for ly in self.viewer.layers if isinstance(ly, Points | Tracks | Image | Shapes) + and _layer_has_data(ly) ] if len(list_layers) > 0: # Get the maximum frame index from all candidate layers diff --git a/movement/napari/meta_widget.py b/movement/napari/meta_widget.py index b51cc912a..245710876 100644 --- a/movement/napari/meta_widget.py +++ b/movement/napari/meta_widget.py @@ -4,6 +4,7 @@ from qt_niu.collapsible_widget import CollapsibleWidgetContainer from movement.napari.loader_widgets import DataLoader +from movement.napari.regions_widget import RegionsWidget class MovementMetaWidget(CollapsibleWidgetContainer): @@ -24,5 +25,12 @@ def __init__(self, napari_viewer: Viewer, parent=None): widget_title="Load tracked data", ) - self.loader = self.collapsible_widgets[0] - self.loader.expand() # expand the loader widget by default + # Add the Regions widget + self.add_widget( + RegionsWidget(napari_viewer, parent=self), + collapsible=True, + widget_title="Define regions of interest", + ) + + loader_collapsible = self.collapsible_widgets[0] + loader_collapsible.expand() # expand the loader widget by default diff --git a/movement/napari/regions_widget.py b/movement/napari/regions_widget.py new file mode 100644 index 000000000..323b1dde6 --- /dev/null +++ b/movement/napari/regions_widget.py @@ -0,0 +1,784 @@ +"""Widget for defining regions of interest. + +This module uses Qt's Model/View architecture to separate data from display. +See our `napari plugin development guide +`_ +for more background. +""" + +import re + +from napari.layers import Shapes +from napari.viewer import Viewer +from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt +from qtpy.QtWidgets import ( + QComboBox, + QGroupBox, + QHBoxLayout, + QPushButton, + QTableView, + QVBoxLayout, + QWidget, +) + +from movement.napari.layer_styles import RegionsStyle, _sample_colormap + +DEFAULT_REGION_NAME = "region" +DROPDOWN_PLACEHOLDER = "No region layers" + +# Metadata keys stored on napari Shapes layers managed by this widget. +# - REGIONS_LAYER_KEY: marks the layer as a movement regions layer on creation. +# - REGIONS_COLOR_IDX_KEY stores the palette index assigned to the regions +# layer so its color remains stable across re-linking. +REGIONS_LAYER_KEY: str = "movement_regions_layer" +REGIONS_COLOR_IDX_KEY: str = "movement_regions_color_idx" + +# Fixed palette of colours assigned sequentially +# to regions layers as they are first linked to the widget. +REGIONS_COLORS: list[tuple] = _sample_colormap(10, "tab10") + + +class RegionsWidget(QWidget): + """Main widget for defining regions of interest. + + This widget provides a user interface for managing regions of interest + drawn as shapes in napari. It coordinates the napari viewer, + the RegionsTableModel, and the RegionsTableView. + + """ + + def __init__(self, napari_viewer: Viewer, parent=None): + """Initialise the regions widget. + + Parameters + ---------- + napari_viewer + The napari viewer instance. + parent + The parent widget. + + """ + super().__init__(parent=parent) + self.viewer = napari_viewer + self._next_color_idx = 0 + self.region_table_model: RegionsTableModel | None = None + self.region_table_view = RegionsTableView(self) + + # Guard flag to prevent circular updates during layer selection syncing + # between napari's layer list the dropdown in this widget. + self._syncing_layer_selection = False + + self._setup_regions_ui() + self._connect_layer_signals() + self._update_layer_dropdown() + + def _setup_regions_ui(self): + """Set up the user interface with two groupboxes. + + The first groupbox contains the region layer controls: + a dropdown to select an existing Regions layer + and a button to add a new Regions layer. + The second groupbox contains the regions table view. + """ + main_layout = QVBoxLayout() + self.setLayout(main_layout) + + # Create layer controls group box + layer_controls_group = QGroupBox("Layer to draw regions on") + layer_controls_group.setLayout(self._setup_region_layer_controls()) + main_layout.addWidget(layer_controls_group) + + # Create table view group box + table_view_group = QGroupBox("Regions drawn in this layer") + table_view_group.setLayout(self._setup_regions_table()) + main_layout.addWidget(table_view_group) + + def _setup_region_layer_controls(self): + """Create the region layer controls layout. + + Returns a QHBoxLayout containing: + + - Dropdown (QComboBox) for selecting region layers + - "Add new layer" button (QPushButton) + """ + layer_controls_layout = QHBoxLayout() + + self.layer_dropdown = QComboBox() + self.layer_dropdown.setMinimumWidth(150) + self.layer_dropdown.currentTextChanged.connect(self._on_layer_selected) + + self.add_layer_button = QPushButton("Add new layer") + self.add_layer_button.setEnabled(True) + self.add_layer_button.clicked.connect(self._add_new_layer) + + layer_controls_layout.addWidget(self.layer_dropdown) + layer_controls_layout.addWidget(self.add_layer_button) + + return layer_controls_layout + + def _setup_regions_table(self): + """Create the table view layout. + + Returns a QVBoxLayout containing the RegionsTableView widget. + """ + table_view_layout = QVBoxLayout() + table_view_layout.addWidget(self.region_table_view) + return table_view_layout + + def _connect_layer_signals(self): + """Connect layer lifecycle signals to widget handlers. + + Handles layer insertion, removal, and selection changes. + """ + self.viewer.layers.events.inserted.connect(self._update_layer_dropdown) + self.viewer.layers.events.removed.connect(self._update_layer_dropdown) + self.viewer.layers.selection.events.changed.connect( + self._on_napari_layer_selection_changed + ) + + def _is_region_layer(self, layer) -> bool: + """Check if a layer is a movement regions layer.""" + return isinstance(layer, Shapes) and bool( + layer.metadata.get(REGIONS_LAYER_KEY, False) + ) + + def _get_region_layers(self) -> dict[str, Shapes]: + """Get all region layers. + + Returns a dictionary with layer names as keys and layers as values. + """ + return { + layer.name: layer + for layer in self.viewer.layers + if self._is_region_layer(layer) + } + + def _update_layer_dropdown(self, _event=None): + """Refresh the layer dropdown with current region layers. + + Called when layers are added, removed, or renamed. Preserves the + current selection when possible; falls back to the first layer. + Shows placeholder text when no region layers exist. + """ + current_text = self.layer_dropdown.currentText() + region_layer_names = list(self._get_region_layers().keys()) + + self.layer_dropdown.clear() + if region_layer_names: + self.layer_dropdown.setStyleSheet("") + self.layer_dropdown.addItems(region_layer_names) + if current_text in region_layer_names: + self.layer_dropdown.setCurrentText(current_text) + else: + self.layer_dropdown.setCurrentIndex(0) + else: + self.layer_dropdown.setStyleSheet("color: gray;") + self.layer_dropdown.addItem(DROPDOWN_PLACEHOLDER) + self.layer_dropdown.model().item(0).setEnabled(False) + + def _on_layer_selected(self, layer_name: str): + """Handle layer selection from dropdown. + + - When a valid layer is selected, selects the layer in napari + and links it to the table model for display. + - When no layer is selected (placeholder text), clears the table model + and the napari layer selection. + """ + if not layer_name or layer_name == DROPDOWN_PLACEHOLDER: + self._clear_region_table_model() + self.viewer.layers.selection.clear() + self._update_table_tooltip() + return + + region_layer = self._get_region_layers().get(layer_name) + if region_layer is not None: + # Select the layer in napari + self.viewer.layers.selection.clear() + self.viewer.layers.selection.add(region_layer) + # Connect the region layer to the table model + self._link_layer_to_model(region_layer) + + def _on_napari_layer_selection_changed(self, event=None): + """Sync napari layer list selection to the dropdown and table. + + When the user clicks a region layer in napari's layer list, + the dropdown and table update to reflect that layer. + """ + # Return early if we're already syncing to avoid circular updates + if self._syncing_layer_selection: + return + + active = self.viewer.layers.selection.active + # Return early if the active layer is not a region layer + if not self._is_region_layer(active): + return + + # Return early if the active layer is already selected in the dropdown + if self.layer_dropdown.currentText() == active.name: + return + + # Sync the dropdown to match the active layer (in a guarded block) + self._syncing_layer_selection = True + self.layer_dropdown.setCurrentText(active.name) + self._syncing_layer_selection = False + + def _on_layer_renamed(self, event=None): + """Handle layer renaming by updating the dropdown.""" + self._update_layer_dropdown() + self.layer_dropdown.setCurrentText(event.source.name) + + def _add_new_layer(self): + """Create a new Regions layer and select it.""" + new_layer = self.viewer.add_shapes( + name="regions", + metadata={REGIONS_LAYER_KEY: True}, + ) + self.layer_dropdown.setCurrentText(new_layer.name) + + def _link_layer_to_model(self, region_layer: Shapes): + """Link a regions layer to a new table model. + + This is the core method that connects the Model-View components: + + - Disconnects any previous model + - Auto-assigns names to unnamed shapes + - Applies consistent color styling + - Creates a new RegionsTableModel for the layer + - Connects model signals for data/selection sync + """ + # Disconnect previous model if it exists + self._disconnect_table_model_signals() + + # Auto-assign names if the layer has shapes without names. + self._auto_assign_region_names(region_layer) + + # On first link, assign the next palette color and apply it to all + # existing shapes (also primes current_* so the first drawn shape + # gets the palette color). On re-link, napari restores each layer's + # own current_* automatically — no intervention needed. + if REGIONS_COLOR_IDX_KEY not in region_layer.metadata: + region_layer.metadata[REGIONS_COLOR_IDX_KEY] = self._next_color_idx + idx = self._next_color_idx % len(REGIONS_COLORS) + self._next_color_idx += 1 + RegionsStyle(color=REGIONS_COLORS[idx]).set_color_all_shapes( + region_layer + ) + + # Create new model and link it to the table view + self.region_table_model = RegionsTableModel(region_layer) + self.region_table_view.setModel(self.region_table_model) + + # The model will listen to napari layer removal and renaming events + self.viewer.layers.events.removed.connect( + self.region_table_model._on_layer_deleted + ) + # Connect to layer name changes + region_layer.events.name.connect(self._on_layer_renamed) + # Connect model reset signal to tooltip updater + self.region_table_model.modelReset.connect(self._update_table_tooltip) + + # Update the tooltip based on the new model state + self._update_table_tooltip() + + def _auto_assign_region_names(self, region_layer: Shapes) -> None: + """Auto-assign names to regions if the layer has shapes without names. + + This handles cases where: + + - The "name" property doesn't exist + - The "name" property is empty or shorter than the number of shapes + - Some names are None or empty strings + """ + if len(region_layer.data) == 0: + return + + # Get existing names, ensure proper length + names = list(region_layer.properties.get("name", [])) + n_shapes = len(region_layer.data) + while len(names) < n_shapes: # pad with empty strings if needed + names.append("") + + # Check if any names are missing/invalid + needs_update = any( + not isinstance(name, str) or not name.strip() for name in names + ) + if needs_update: + names = _fill_empty_region_names(names) + + region_layer.properties = {"name": names} + + def _disconnect_table_model_signals(self): + """Disconnect all signals from the current table model. + + Safely disconnects: layer data change events, layer set_data events, + layer name change events, model reset signals, and viewer layer + removal events. + """ + if self.region_table_model is not None: + # Only disconnect layer events if the layer still exists + if self.region_table_model.layer is not None: + self.region_table_model.layer.events.data.disconnect( + self.region_table_model._on_layer_data_changed + ) + self.region_table_model.layer.events.set_data.disconnect( + self.region_table_model._on_layer_set_data + ) + self.region_table_model.layer.events.edge_color.disconnect( + self.region_table_model._on_edge_color_changed + ) + self.region_table_model.layer.events.name.disconnect( + self._on_layer_renamed + ) + # Always disconnect model/viewer events (don't depend on layer) + self.region_table_model.modelReset.disconnect( + self._update_table_tooltip + ) + self.viewer.layers.events.removed.disconnect( + self.region_table_model._on_layer_deleted + ) + + def _clear_region_table_model(self): + """Clear the current table model and disconnect from the view.""" + self._disconnect_table_model_signals() + self.region_table_model = None + self.region_table_view.setModel(None) + + def _update_table_tooltip(self): + """Update the table tooltip based on current state. + + Shows contextual hints: + + - How to create region layers when none exist + - How to draw shapes when layer is empty + - Usage tips when layer has shapes + """ + layer_name = self.layer_dropdown.currentText() + + if not layer_name or layer_name == DROPDOWN_PLACEHOLDER: + # No region layers exist + self.region_table_view.setToolTip( + "No region layers found.\nClick 'Add new layer' to create one." + ) + elif ( + self.region_table_model is None + or self.region_table_model.rowCount() == 0 + ): + # Layer selected but no shapes + self.region_table_view.setToolTip( + "No regions in this layer.\n" + "Use the napari layer controls to draw shapes." + ) + else: + # Layer has shapes - show usage tips + self.region_table_view.setToolTip( + "Click a row to select the shape.\n" + "Press Delete to remove it.\n" + "Double-click a name to rename." + ) + + def closeEvent(self, event): + """Clean up signal connections when widget is closed. + + Overrides QWidget.closeEvent to ensure proper cleanup of: + - Viewer-level layer insertion/removal/selection signals + - Table model connections + """ + # Disconnect viewer-level signals + self.viewer.layers.events.inserted.disconnect( + self._update_layer_dropdown + ) + self.viewer.layers.events.removed.disconnect( + self._update_layer_dropdown + ) + self.viewer.layers.selection.events.changed.disconnect( + self._on_napari_layer_selection_changed + ) + + # Clean up table model + self._clear_region_table_model() + + super().closeEvent(event) + + +class RegionsTableView(QTableView): + """Table view for displaying drawn regions of interest. + + Displays region data from a RegionsTableModel in a two-column table + (Name, Shape type). Handles user interactions: + + - Row selection syncs to shape selection in napari + - Double-click on Name column enables inline editing + """ + + def __init__(self, parent=None): + """Initialize the table view with selection and edit settings. + + Configures: + + - Row-based selection (clicking selects entire row) + - Single selection mode (one row at a time) + - Double-click or key press to edit Name column + """ + super().__init__(parent=parent) + self.setSelectionBehavior(QTableView.SelectRows) + self.setSelectionMode(QTableView.SingleSelection) + self.setEditTriggers( + QTableView.DoubleClicked | QTableView.EditKeyPressed + ) + self.current_model: RegionsTableModel | None = None + + # Guard flag to prevent circular updates between the two sync + # handlers: row selection → napari shape selection → row selection... + self._syncing_row_selection = False + + def setModel(self, model): + """Set the table model and connect selection signals. + + Overrides QTableView.setModel to additionally connect the + selection changed signal for syncing with napari layer selection. + """ + # Disconnect the (view-managed) highlight event from the previous layer + prev_layer = getattr(self.current_model, "layer", None) + if self.current_model is not None and prev_layer is not None: + prev_layer.events.highlight.disconnect( + self._on_shape_selection_changed + ) + + super().setModel(model) + self.current_model = model + + if model is not None: + self.selectionModel().selectionChanged.connect( + self._on_row_selection_changed + ) + if model.layer is not None: + model.layer.events.highlight.connect( + self._on_shape_selection_changed + ) + + def _on_row_selection_changed(self, selected, deselected): + """Sync row selection in the table to shape selection in napari.""" + if self._syncing_row_selection: + return + if self.current_model is None or self.current_model.layer is None: + return + + indexes = selected.indexes() + if not indexes: + return + + row = indexes[0].row() + if row < len(self.current_model.layer.data): + self._syncing_row_selection = True + self.current_model.layer.selected_data = {row} + self._syncing_row_selection = False + + def _on_shape_selection_changed(self, event=None): + """Sync shape selection in napari to row highlight in the table.""" + if self._syncing_row_selection: + return + if self.current_model is None or self.current_model.layer is None: + return + + selected = self.current_model.layer.selected_data + self._syncing_row_selection = True + if len(selected) == 1: + self.selectRow(next(iter(selected))) + else: + self.clearSelection() + self._syncing_row_selection = False + + +class RegionsTableModel(QAbstractTableModel): + """Table model exposing region data from a Shapes layer. + + Wraps a napari Shapes layer and provides data to RegionsTableView: + + - Column 0: Region name (from layer.properties["name"]) + - Column 1: Shape type (e.g., "rectangle", "polygon") + + Listens to layer data events and emits Qt signals when shapes are + added, removed, or modified. Also handles auto-naming of new shapes. + """ + + def __init__(self, shapes_layer: Shapes, parent=None): + """Initialize the model with a Shapes layer. + + Parameters + ---------- + shapes_layer + The napari Shapes layer containing the regions. + parent + The parent widget. + + """ + super().__init__(parent) + self.layer = shapes_layer + # Track shape count to detect new shapes + self._last_shape_count = len(shapes_layer.data) + # Guard flag: True between "adding" and "added" data events. + # Prevents the interleaved set_data event from processing drawn shapes + self._adding_shape = False + # Listen to layer data changes (drawing, editing, deleting shapes) + self.layer.events.data.connect(self._on_layer_data_changed) + # Listen to set_data events (copy-paste emits this, not data) + self.layer.events.set_data.connect(self._on_layer_set_data) + # Keep text colour tethered to edge colour + self.layer.events.edge_color.connect(self._on_edge_color_changed) + + def rowCount(self, parent=QModelIndex()): # noqa: B008 + """Return the number of regions (shapes) in the layer.""" + return len(self.layer.data) if self.layer else 0 + + def columnCount(self, parent=QModelIndex()): # noqa: B008 + """Return 2 columns: Name and Shape type.""" + return 2 if self.layer else 0 + + def data(self, index, role=Qt.DisplayRole): + """Return cell data for display or editing.""" + if not index.isValid(): + return None + + row, col = index.row(), index.column() + + if row >= len(self.layer.data): + return None + + if role == Qt.DisplayRole: + if col == 0: + return self._get_region_name_for_row(row) + elif col == 1: + return ( + self.layer.shape_type[row] + if row < len(self.layer.shape_type) + else "" + ) + elif role == Qt.EditRole and col == 0: + # Return editable data for the Name column + return self._get_region_name_for_row(row) + return None + + def flags(self, index): + """Return item flags (editable for Name column only).""" + if not index.isValid(): + return Qt.NoItemFlags + + if index.column() == 0: # Make only the Name column editable + return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable + else: + return Qt.ItemIsEnabled | Qt.ItemIsSelectable + + def setData(self, index, value, role=Qt.EditRole): + """Update region name when user edits the Name column. + + Updates the layer.properties["name"] list and emits dataChanged. + Only the Name column (column 0) is editable. + """ + if not index.isValid() or role != Qt.EditRole: + return False + + row, col = index.row(), index.column() + + if row >= len(self.layer.data): + return False + + # Only allow editing the Name column + if col == 0: + names = list(self.layer.properties.get("name", [])) + + while len(names) <= row: # pragma: no cover + names.append("") + + names[row] = str(value) + self.layer.properties = {"name": names} + self.dataChanged.emit(index, index) + return True + + return False + + def headerData(self, section, orientation, role=Qt.DisplayRole): + """Return header labels: 'Name' and 'Shape type' for columns.""" + if role != Qt.DisplayRole: + return None + if orientation == Qt.Horizontal: + return ["Name", "Shape type"][section] + else: # Vertical orientation + return str(section) # Return the row index as a string + + def _get_region_name_for_row(self, row): + """Get the region name for a given row index from layer properties.""" + names = self.layer.properties.get("name", []) + return names[row] if row < len(names) else "" + + def _on_layer_data_changed(self, event=None): + """Handle data events from drawing, editing, or deleting shapes. + + - For "added" events (drawing new shapes): assigns default name. + - For "removed" events: syncs model with remaining shapes. + - The "adding" event sets a guard flag so that the interleaved + set_data event (fired between "adding" and "added") defers + naming to this handler. + - Moves/resizes do not affect names and are ignored. + """ + if self.layer is None: + return + + n_shapes = len(self.layer.data) + + if event.action == "adding": + self._adding_shape = True + elif event.action == "added": + self._adding_shape = False + self._sync_names_on_shape_change(n_shapes, use_default_name=True) + elif event.action == "removed": + self._sync_names_on_shape_change(n_shapes) + + def _on_edge_color_changed(self, event=None): + """Tether text colour to edge colour when the user recolours shapes.""" + if self.layer is not None and len(self.layer.data) > 0: + self.layer.text.color = self.layer.edge_color + + def _on_layer_set_data(self, event=None): + """Handle set_data events from copy-paste operations. + + Copy-paste in napari emits set_data (not data) events. Newly pasted + shapes get unique names derived from the copied name (e.g. pasting + "burrow" when "burrow" already exists yields "burrow_1"). + + When drawing, napari also fires set_data between the "adding" and + "added" data events. In that case we skip here and let + _on_layer_data_changed handle naming with the correct default. + """ + if self.layer is None: + return + if self._adding_shape: + return + + n_shapes = len(self.layer.data) + if n_shapes != self._last_shape_count: + self._sync_names_on_shape_change(n_shapes) + + def _sync_names_on_shape_change( + self, + n_shapes: int, + use_default_name: bool = False, + ): + """Sync names list with current shape count and update model. + + Parameters + ---------- + n_shapes + Current number of shapes in the layer. + use_default_name + If True, newly added shapes get a unique default name + (e.g. "region", "region_1"). Use for drawn shapes. + If False, the existing name (e.g. from a copy-paste) is + kept as the base and made unique if needed. + Default is False. + + """ + current_names = list(self.layer.properties.get("name", [])) + + # Pad if list is too short (probably overly defensive) + while len(current_names) < n_shapes: # pragma: no cover + current_names.append(DEFAULT_REGION_NAME) + + # Truncate if list is too long (shapes were removed) + current_names = current_names[:n_shapes] + + # Assign unique names to newly added shapes. + # For drawn shapes (use_default_name=True), we override + # whatever napari propagated from the selected shape. + # For pasted shapes (use_default_name=False), we keep the + # copied name as the base and just make it unique. + if n_shapes > self._last_shape_count: + for i in range(self._last_shape_count, n_shapes): + base = current_names[i] + if use_default_name or not isinstance(base, str): + base = DEFAULT_REGION_NAME + current_names[i] = _unique_name(base, current_names[:i]) + + # text.string must be set before properties. + if n_shapes > 0: + self.layer.text.string = "{name}" + + self.layer.properties = {"name": current_names} + self._last_shape_count = n_shapes + + # text.refresh() (inside the properties setter) resets text colours + # to the default encoding, so re-tether them to edge colours. + if n_shapes > 0: + self.layer.text.color = self.layer.edge_color + + self.beginResetModel() + self.endResetModel() + + def _on_layer_deleted(self, event=None): + """Handle deletion of the associated Shapes layer from viewer. + + Disconnects from layer events, clears the layer reference, + and resets the model to empty state. + """ + # Only reset the model if the layer being removed + # is the one we are currently using. + if event.value == self.layer: + self.layer.events.data.disconnect(self._on_layer_data_changed) + self.layer.events.set_data.disconnect(self._on_layer_set_data) + self.layer.events.edge_color.disconnect( + self._on_edge_color_changed + ) + self.layer = None + self.beginResetModel() + self.endResetModel() + + +def _unique_name(base: str, existing_names: list) -> str: + """Return a unique name that is not in existing_names. + + The root is obtained by stripping any trailing ``_N`` suffix from + ``base``. If the root itself is available, it is returned as-is. + Otherwise, ``root_1``, ``root_2``, etc. are tried until a free + name is found. + + Parameters + ---------- + base + Desired name. + existing_names + Names already in use. + + Returns + ------- + str + A name that does not appear in existing_names. + + """ + # Strip existing "_N" suffix to get the root + root = re.sub(r"_\d+$", "", base) + if root not in existing_names: + return root + i = 1 + while f"{root}_{i}" in existing_names: + i += 1 + return f"{root}_{i}" + + +def _fill_empty_region_names(existing_names: list) -> list: + """Fill empty/None region names with DEFAULT_REGION_NAME. + + Parameters + ---------- + existing_names + Current list of region names. + + Returns + ------- + list + Updated list with default names where needed. + + """ + result = list(existing_names) + for i, name in enumerate(result): + if not isinstance(name, str) or not name.strip(): + result[i] = _unique_name(DEFAULT_REGION_NAME, result[:i]) + return result diff --git a/tests/test_unit/test_napari_plugin/test_data_loader_widget.py b/tests/test_unit/test_napari_plugin/test_data_loader_widget.py index b9614b95e..137928048 100644 --- a/tests/test_unit/test_napari_plugin/test_data_loader_widget.py +++ b/tests/test_unit/test_napari_plugin/test_data_loader_widget.py @@ -782,6 +782,30 @@ def test_deletion_all_layers(make_napari_viewer_proxy): viewer.layers.clear() +@pytest.mark.parametrize( + "add_layer", + [ + pytest.param(lambda v: v.add_shapes(), id="empty_shapes"), + pytest.param(lambda v: v.add_points(), id="empty_points"), + ], +) +def test_empty_layer_excluded_from_frame_slider_update( + make_napari_viewer_proxy, add_layer +): + """Test that empty layers don't cause an error in frame slider update. + + Empty Shapes and Points layers are excluded from the candidate layers + in _update_frame_slider_range, so max() is never called on an empty + sequence. + """ + viewer = make_napari_viewer_proxy() + loader = DataLoader(viewer) + add_layer(viewer) + + with does_not_raise(): + loader._update_frame_slider_range() + + # ------------------- tests for layers style ----------------------------# @pytest.mark.parametrize( ( diff --git a/tests/test_unit/test_napari_plugin/test_layer_styles.py b/tests/test_unit/test_napari_plugin/test_layer_styles.py index 6d91a2c98..2be8815f9 100644 --- a/tests/test_unit/test_napari_plugin/test_layer_styles.py +++ b/tests/test_unit/test_napari_plugin/test_layer_styles.py @@ -2,12 +2,14 @@ import pandas as pd import pytest +from numpy.testing import assert_array_equal from movement.napari.layer_styles import ( DEFAULT_COLORMAP, BoxesStyle, LayerStyle, PointsStyle, + RegionsStyle, TracksStyle, _sample_colormap, ) @@ -79,12 +81,22 @@ def default_style_attributes(): "translation": 5, }, }, + # Additional attributes for RoiStyle + RegionsStyle: { + "color": "red", + "edge_width": 5.0, + "opacity": 1.0, + "text": { + "visible": False, + "anchor": "center", + }, + }, } @pytest.mark.parametrize( "layer_class", - [LayerStyle, PointsStyle, TracksStyle, BoxesStyle], + [LayerStyle, PointsStyle, TracksStyle, BoxesStyle, RegionsStyle], ) def test_layer_style_initialization( sample_layer_style, layer_class, default_style_attributes @@ -260,7 +272,7 @@ def test_tracks_style_color_by( ("property_2", 2), ], ) -def test_shapes_style_set_color_by( +def test_boxes_style_set_color_by( color_property, n_unique_values, sample_layer_style, @@ -304,3 +316,106 @@ def test_shapes_style_set_color_by( isinstance(c, tuple) and len(c) == 4 for c in boxes_style.edge_color_cycle ) + + +@pytest.mark.parametrize( + ["color", "expected_rgb"], + [ + pytest.param("blue", (0.0, 0.0, 1.0), id="blue_as_str"), + pytest.param("red", (1.0, 0.0, 0.0), id="red_as_str"), + pytest.param((1.0, 0.0, 0.0, 1.0), (1.0, 0.0, 0.0), id="red_as_tuple"), + pytest.param( + (0.0, 0.0, 1.0, 0.5), (0.0, 0.0, 1.0), id="blue_as_tuple_alpha" + ), + ], +) +def test_regions_style_colors(color, expected_rgb): + """Test that setting the color attribute updates the face, edge, + and text colors. The face color must be transparent, while edges and + text must be opaque. + """ + # Create a Regions style object + regions_style = RegionsStyle(color=color) + + # Convert expected_rgb to RGBA for comparison + expected_rgba = expected_rgb + (1.0,) + expected_face_rgba = expected_rgb + (0.25,) + + assert_array_equal(regions_style.edge_and_text_color, expected_rgba) + assert_array_equal(regions_style.face_color, expected_face_rgba) + + +@pytest.mark.parametrize("n_shapes", [1, 3]) +def test_regions_style_set_color_all_shapes( + make_napari_viewer_proxy, n_shapes +): + """Test that set_color_all_shapes applies colors to all shapes.""" + viewer = make_napari_viewer_proxy() + + # Create shapes data (rectangles) + shapes_data = [ + [ + [10 * i, 10 * i], + [10 * i, 20 + 10 * i], + [20 + 10 * i, 20 + 10 * i], + [20 + 10 * i, 10 * i], + ] + for i in range(n_shapes) + ] + layer = viewer.add_shapes(shapes_data, shape_type="polygon") + layer.properties = {"name": [f"Region-{i}" for i in range(n_shapes)]} + + # Apply style + regions_style = RegionsStyle(color="blue") + regions_style.set_color_all_shapes(layer) + + # Check layer opacity and per-shape colors are applied + assert layer.opacity == pytest.approx(1.0) + assert len(layer.face_color) == n_shapes + assert len(layer.edge_color) == n_shapes + assert len(layer.edge_width) == n_shapes + + # Check that text string format is set + assert "{name}" in str(layer.text) + + +def test_regions_style_set_color_all_shapes_empty_layer( + make_napari_viewer_proxy, +): + """Test that set_color_all_shapes handles empty layers gracefully.""" + viewer = make_napari_viewer_proxy() + layer = viewer.add_shapes() + # Assert it's an empty layer + assert len(layer.data) == 0 + + # Should not raise an error + regions_style = RegionsStyle(color="red") + regions_style.set_color_all_shapes(layer) + + # '{name}' must not be set on an empty layer (would cause RuntimeWarning) + assert "{name}" not in str(layer.text.string) + + +@pytest.mark.parametrize( + "selected_data", + [ + pytest.param({0}, id="valid_selection"), + pytest.param(set(), id="no_selection"), + ], +) +def test_regions_style_set_style_for_new_shapes( + make_napari_viewer_proxy, selected_data +): + """Test that set_style_for_new_shapes runs without error, + regardless of whether the layer is selected or not. + """ + viewer = make_napari_viewer_proxy() + + # Create a shape + shapes_data = [[[0, 0], [0, 10], [10, 10], [10, 0]]] + layer = viewer.add_shapes(shapes_data, shape_type="polygon") + layer.selected_data = selected_data + + regions_style = RegionsStyle(color="green") + # Should not raise - exercises the method + regions_style.set_style_for_new_shapes(layer) diff --git a/tests/test_unit/test_napari_plugin/test_meta_widget.py b/tests/test_unit/test_napari_plugin/test_meta_widget.py index d4ce86dd9..017af563c 100644 --- a/tests/test_unit/test_napari_plugin/test_meta_widget.py +++ b/tests/test_unit/test_napari_plugin/test_meta_widget.py @@ -8,8 +8,12 @@ def test_meta_widget_instantiation(make_napari_viewer_proxy): viewer = make_napari_viewer_proxy() meta_widget = MovementMetaWidget(viewer) - assert len(meta_widget.collapsible_widgets) == 1 + assert len(meta_widget.collapsible_widgets) == 2 first_widget = meta_widget.collapsible_widgets[0] assert first_widget._text == "Load tracked data" assert first_widget.isExpanded() + + second_widget = meta_widget.collapsible_widgets[1] + assert second_widget._text == "Define regions of interest" + assert not second_widget.isExpanded() diff --git a/tests/test_unit/test_napari_plugin/test_regions_widget.py b/tests/test_unit/test_napari_plugin/test_regions_widget.py new file mode 100644 index 000000000..c40480a62 --- /dev/null +++ b/tests/test_unit/test_napari_plugin/test_regions_widget.py @@ -0,0 +1,900 @@ +"""Unit tests for the Regions widget in the napari plugin.""" + +from contextlib import nullcontext as does_not_raise + +import pytest +from napari.layers import Shapes +from qtpy.QtCore import QItemSelection, QModelIndex, Qt +from qtpy.QtWidgets import ( + QComboBox, + QGroupBox, + QTableView, +) + +from movement.napari.regions_widget import ( + DEFAULT_REGION_NAME, + DROPDOWN_PLACEHOLDER, + REGIONS_COLOR_IDX_KEY, + REGIONS_LAYER_KEY, + RegionsTableView, + RegionsWidget, + _unique_name, +) + +pytestmark = pytest.mark.filterwarnings( + "ignore:.*Previous color_by key.*:UserWarning" +) + + +# ------------------- Helpers ------------------------------------------------# +def add_regions_layer(viewer, data=None, name="regions", **kwargs): + """Add a shapes layer marked as a movement region layer to a viewer.""" + return viewer.add_shapes( + data, + name=name, + metadata={REGIONS_LAYER_KEY: True}, + **kwargs, + ) + + +# ------------------- Fixtures -----------------------------------------------# +@pytest.fixture +def two_polygons(): + """Return data for 2 sample polygon shapes.""" + return [ + [[0, 0], [0, 10], [10, 10], [10, 0]], + [[20, 20], [20, 30], [30, 30], [30, 20]], + ] + + +@pytest.fixture +def regions_widget(make_napari_viewer_proxy): + """Return a viewer with a Regions widget.""" + viewer = make_napari_viewer_proxy() + return RegionsWidget(viewer) + + +@pytest.fixture +def regions_widget_with_layer(regions_widget, two_polygons): + """Return a RegionsWidget and a shapes layer with 2 regions.""" + viewer = regions_widget.viewer + layer = add_regions_layer(viewer, two_polygons, shape_type="polygon") + layer.properties = { + "name": [DEFAULT_REGION_NAME, f"{DEFAULT_REGION_NAME}_1"] + } + return regions_widget, layer + + +# ------------------- Tests for widget instantiation -------------------------# +def test_widget_has_expected_attributes(make_napari_viewer_proxy): + """Test that the Regions widget is properly instantiated.""" + viewer = make_napari_viewer_proxy() + widget = RegionsWidget(viewer) + assert widget.viewer == viewer + assert widget.region_table_model is None + assert isinstance(widget.region_table_view, RegionsTableView) + + +def test_widget_has_expected_ui_elements(regions_widget): + """Test that the Regions widget has all expected UI elements.""" + group_boxes = regions_widget.findChildren(QGroupBox) + assert len(group_boxes) == 2 + assert regions_widget.findChild(QComboBox) is not None + assert regions_widget.add_layer_button is not None + assert regions_widget.findChild(QTableView) is not None + + +def test_color_assignment_is_sequential_and_stable(make_napari_viewer_proxy): + """Test that palette colors are assigned sequentially and stay stable. + + Each layer receives the next palette color on first link (stored in + metadata). Re-linking an already-colored layer must not change its index + or consume a new palette slot. + """ + viewer = make_napari_viewer_proxy() + layer_a = add_regions_layer(viewer, name="Regions-A") + layer_b = add_regions_layer(viewer, name="Regions-B") + widget = RegionsWidget(viewer) + + # layer_a is auto-linked on init (first item in dropdown) + assert layer_a.metadata[REGIONS_COLOR_IDX_KEY] == 0 + # Link layer_b: it gets the next palette index + widget.layer_dropdown.setCurrentText("Regions-B") + assert layer_b.metadata[REGIONS_COLOR_IDX_KEY] == 1 + assert widget._next_color_idx == 2 + + # Re-link layer_a: index and counter must be unchanged + widget.layer_dropdown.setCurrentText("Regions-A") + assert layer_a.metadata[REGIONS_COLOR_IDX_KEY] == 0 + assert widget._next_color_idx == 2 + + +def test_dropdown_shows_placeholder_when_no_layers(regions_widget): + """Test dropdown shows placeholder when no region layers exist.""" + assert regions_widget.layer_dropdown.currentText() == DROPDOWN_PLACEHOLDER + assert not regions_widget.layer_dropdown.model().item(0).isEnabled() + + +def test_dropdown_populated_with_existing_region_layer( + make_napari_viewer_proxy, +): + """Test dropdown is populated when region layer exists at init.""" + viewer = make_napari_viewer_proxy() + add_regions_layer(viewer) + widget = RegionsWidget(viewer) + assert widget.layer_dropdown.count() == 1 + assert widget.layer_dropdown.currentText() == "regions" + + +def test_auto_assign_names_pads_missing_name_property( + make_napari_viewer_proxy, two_polygons +): + """Test that missing region name property gets created and filled.""" + viewer = make_napari_viewer_proxy() + # Create layer with shapes but no "name" assigned to each shape + # (Note that "regions" is the name of the layer) + layer = add_regions_layer(viewer, two_polygons) + # Creating widget triggers _auto_assign_region_names which pads + # the list of region names to match the number of shapes in the layer + RegionsWidget(viewer) + # Region names should be created, filled, and made unique + names = layer.properties["name"] + assert len(names) == 2 + assert all(name.startswith(DEFAULT_REGION_NAME) for name in names) + assert len(set(names)) == 2 # all unique + + +# ------------------- Tests for signal/event connections ---------------------# +def test_add_layer_button_connected_to_handler( + make_napari_viewer_proxy, mocker +): + """Test that clicking Add new layer button calls the handler.""" + mock_method = mocker.patch( + "movement.napari.regions_widget.RegionsWidget._add_new_layer" + ) + widget = RegionsWidget(make_napari_viewer_proxy()) + widget.add_layer_button.click() + mock_method.assert_called_once() + + +def test_dropdown_connected_to_layer_selection_handler( + make_napari_viewer_proxy, mocker +): + """Test that changing dropdown selection calls the handler.""" + mock_method = mocker.patch( + "movement.napari.regions_widget.RegionsWidget._on_layer_selected" + ) + viewer = make_napari_viewer_proxy() + add_regions_layer(viewer) + + # Create second layer to switch to + add_regions_layer(viewer, name="Regions [1]") + widget = RegionsWidget(viewer) + + # Reset calls to mock since the widget initialisation + # triggers `_on_layer_selected` internally + # (when the dropdown is populated and a layer is auto-selected) + mock_method.reset_mock() + widget.layer_dropdown.setCurrentText("Regions [1]") + mock_method.assert_called_once() + + +def test_layer_added_triggers_dropdown_update( + make_napari_viewer_proxy, mocker +): + """Test that adding a layer triggers dropdown update.""" + mock_method = mocker.patch( + "movement.napari.regions_widget.RegionsWidget._update_layer_dropdown" + ) + viewer = make_napari_viewer_proxy() + RegionsWidget(viewer) + + mock_method.reset_mock() + viewer.add_shapes(name="regions") + mock_method.assert_called_once() + + +def test_layer_removed_triggers_dropdown_update( + make_napari_viewer_proxy, mocker +): + """Test that removing a layer triggers dropdown update.""" + mock_method = mocker.patch( + "movement.napari.regions_widget.RegionsWidget._update_layer_dropdown" + ) + viewer = make_napari_viewer_proxy() + layer = viewer.add_shapes(name="regions") + + # _ to avoid it being gc (must stay alive to receive signal) + _ = RegionsWidget(viewer) + + mock_method.reset_mock() + viewer.layers.remove(layer) + mock_method.assert_called_once() + + +def test_shape_data_change_triggers_model_update( + regions_widget_with_layer, mocker +): + """Test that adding a shape to an existing layer triggers + table model's data change handler. + """ + widget, layer = regions_widget_with_layer + mock_method = mocker.patch.object( + widget.region_table_model, "_on_layer_data_changed" + ) + layer.add([[60, 60], [60, 70], [70, 70], [70, 60]]) + mock_method.assert_called() + + +def test_set_data_event_triggers_handler(regions_widget_with_layer, mocker): + """Test that assigning to layer.data fires the set_data event + and calls the handler. + """ + widget, layer = regions_widget_with_layer + mock_method = mocker.patch.object( + widget.region_table_model, "_on_layer_set_data" + ) + layer.data = [shape * 2 for shape in layer.data] # scale existing shapes + mock_method.assert_called() # napari fires set_data multiple times + + +# ------------------- Tests for widget methods -------------------------------# +def test_add_new_layer(regions_widget): + """Test that _add_new_layer creates a properly configured region layer.""" + regions_widget._add_new_layer() + + assert len(regions_widget.viewer.layers) == 1 + layer = regions_widget.viewer.layers[0] + assert isinstance(layer, Shapes) + assert layer.name.startswith("regions") + assert layer.metadata.get(REGIONS_LAYER_KEY) is True + assert regions_widget.layer_dropdown.currentText() == layer.name + + +def test_add_multiple_layers_increments_name(regions_widget): + """Test that multiple new layers get unique names.""" + regions_widget._add_new_layer() + regions_widget._add_new_layer() + layer_names = [layer.name for layer in regions_widget.viewer.layers] + assert len(set(layer_names)) == 2 + + +def test_update_layer_dropdown_on_layer_added(regions_widget): + """Test dropdown is updated when a new region layer is added.""" + add_regions_layer(regions_widget.viewer) + assert regions_widget.layer_dropdown.count() == 1 + assert regions_widget.layer_dropdown.currentText() == "regions" + + +def test_update_layer_dropdown_on_layer_removed(regions_widget_with_layer): + """Test dropdown is updated when a region layer is removed.""" + widget, layer = regions_widget_with_layer + assert widget.layer_dropdown.count() == 1 + + widget.viewer.layers.remove(layer) + assert widget.layer_dropdown.currentText() == DROPDOWN_PLACEHOLDER + + +def test_dropdown_ignores_non_region_layers(make_napari_viewer_proxy): + """Test dropdown ignores non-region shapes layers.""" + viewer = make_napari_viewer_proxy() + viewer.add_shapes(name="Other shapes") + widget = RegionsWidget(viewer) + assert widget.layer_dropdown.currentText() == DROPDOWN_PLACEHOLDER + # the dropdown count should hold placeholder text only + assert widget.layer_dropdown.count() == 1 + + +def test_dropdown_includes_layer_with_region_metadata( + make_napari_viewer_proxy, +): + """Test dropdown includes layers marked with region metadata.""" + viewer = make_napari_viewer_proxy() + layer = viewer.add_shapes(name="Custom name") + layer.metadata[REGIONS_LAYER_KEY] = True + widget = RegionsWidget(viewer) + assert widget.layer_dropdown.count() == 1 + assert widget.layer_dropdown.currentText() == "Custom name" + + +def test_dropdown_follows_napari_when_new_region_layer_added( + make_napari_viewer_proxy, +): + """Test dropdown follows napari's active layer when a new region is added. + + When napari adds a new region layer, it becomes the active layer, so + the dropdown syncs to it (bidirectional layer selection). + """ + viewer = make_napari_viewer_proxy() + add_regions_layer(viewer) + widget = RegionsWidget(viewer) + widget.layer_dropdown.setCurrentText("regions") + + add_regions_layer(viewer, name="regions [1]") + # napari makes the newly added layer active, so the dropdown follows + assert widget.layer_dropdown.currentText() == "regions [1]" + + +def test_layer_selection_links_to_model(regions_widget_with_layer): + """Test that selecting a layer creates a linked table model.""" + widget, layer = regions_widget_with_layer + assert widget.region_table_model is not None + assert widget.region_table_model.layer == layer + + +def test_renaming_layer_updates_dropdown(regions_widget_with_layer): + """Test that renaming a layer updates the dropdown.""" + widget, layer = regions_widget_with_layer + layer.name = "Regions renamed" + assert widget.layer_dropdown.findText("Regions renamed") != -1 + # findText returns the index of the matching item or -1 if not found + + +def test_close_cleans_up(regions_widget_with_layer): + """Test that closing widget disconnects signals and clears model.""" + widget, _ = regions_widget_with_layer + with does_not_raise(): + widget.close() + assert widget.region_table_model is None + + +# ------------------- Tests for _unique_name ---------------------------------# +@pytest.mark.parametrize( + "base, existing, expected", + [ + pytest.param("region", [], "region", id="no_conflict"), + pytest.param("region", ["region"], "region_1", id="one_existing"), + pytest.param( + "region", + ["region", "region_1"], + "region_2", + id="two_existing", + ), + pytest.param( + "region_1", + ["region", "region_1"], + "region_2", + id="suffix_stripped_before_search", + ), + pytest.param( + "region_1", + ["region", "region_1", "region_2"], + "region_3", + id="suffix_stripped_counts_up", + ), + ], +) +def test_unique_name(base, existing, expected): + """Test that _unique_name returns a unique name.""" + assert _unique_name(base, existing) == expected + + +# ------------------- Tests for region auto-naming ---------------------------# +@pytest.mark.parametrize("empty_value", ["", None]) +def test_fills_empty_or_none_names( + make_napari_viewer_proxy, two_polygons, empty_value +): + """Test that empty/None names are filled with default name.""" + viewer = make_napari_viewer_proxy() + layer = add_regions_layer(viewer, two_polygons[:1], shape_type="polygon") + layer.properties = {"name": [empty_value]} + + RegionsWidget(viewer) + assert layer.properties["name"][0] == DEFAULT_REGION_NAME + + +def test_preserves_user_names(make_napari_viewer_proxy, two_polygons): + """Test that user-assigned names are preserved.""" + viewer = make_napari_viewer_proxy() + layer = add_regions_layer(viewer, two_polygons, shape_type="polygon") + layer.properties = {"name": ["Arena", ""]} + + RegionsWidget(viewer) + assert all(layer.properties["name"] == ["Arena", DEFAULT_REGION_NAME]) + + +def test_new_drawn_shape_gets_default_name( + regions_widget_with_layer, two_polygons +): + """Test that newly drawn shapes get a unique default name.""" + _, layer = regions_widget_with_layer + layer.add(two_polygons[:1]) + + names = layer.properties["name"] + assert len(names) == 3 + # The last drawn shape gets a unique name derived from DEFAULT_REGION_NAME + assert names[-1].startswith(DEFAULT_REGION_NAME) + assert names[-1] not in names[:-1] # new name is unique among prior names + + +def test_new_drawn_shape_uses_default_name_after_rename( + regions_widget_with_layer, two_polygons, mocker +): + """Test that drawing after renaming still uses DEFAULT_REGION_NAME. + + Renaming an existing region (e.g. "region" → "burrow") should not + influence the name assigned to subsequently drawn shapes. New shapes + should always derive their name from DEFAULT_REGION_NAME. + + When drawing interactively, napari fires events in this order: + data("adding") → set_data → data("added"). The set_data handler + must defer to the data("added") handler, which applies the default + name. We simulate this sequence by disconnecting events, manually + adding the shape, and then replaying the three events in order. + """ + widget, layer = regions_widget_with_layer + model = widget.region_table_model + + # Rename the first region from "region" to "burrow" + index = model.index(0, 0) + model.setData(index, "burrow", Qt.EditRole) + assert layer.properties["name"][0] == "burrow" + + # Disconnect events so layer.add() doesn't trigger handlers + layer.events.data.disconnect(model._on_layer_data_changed) + layer.events.set_data.disconnect(model._on_layer_set_data) + layer.add(two_polygons[:1]) + + # Replay the napari event sequence: adding → set_data → added + model._on_layer_data_changed(mocker.Mock(action="adding")) + model._on_layer_set_data() + model._on_layer_data_changed(mocker.Mock(action="added")) + + # Reconnect events for cleanup + layer.events.data.connect(model._on_layer_data_changed) + layer.events.set_data.connect(model._on_layer_set_data) + + names = list(layer.properties["name"]) + assert len(names) == 3 + # The new shape must use DEFAULT_REGION_NAME, not "burrow" + assert names[2].startswith(DEFAULT_REGION_NAME) + + +# ------------------- Tests for RegionsTableModel ----------------------------# +def test_table_model_row_and_column_count(regions_widget_with_layer): + """Test that table model dimensions match the data.""" + widget, _ = regions_widget_with_layer + assert widget.region_table_model.rowCount() == 2 + assert widget.region_table_model.columnCount() == 2 + + +def test_table_model_header_labels(regions_widget_with_layer): + """Test that table model header labels are correct.""" + widget, _ = regions_widget_with_layer + assert widget.region_table_model.headerData(0, Qt.Horizontal) == "Name" + assert ( + widget.region_table_model.headerData(1, Qt.Horizontal) == "Shape type" + ) + + +@pytest.mark.parametrize( + "column_index", + [ + pytest.param(0, id="name_column"), + pytest.param(1, id="shape_type_column"), + ], +) +@pytest.mark.parametrize( + "role", + [ + pytest.param(Qt.DisplayRole, id="display_role"), + pytest.param(Qt.EditRole, id="edit_role"), + ], +) +def test_table_model_data_returns_correct_values( + regions_widget_with_layer, column_index, role +): + """Test that table model returns correct data for each column and role. + + The role captures the reason why Qt calls the .data() method of the + model. Qt.DisplayRole means Qt wants the data for display. + Qt.EditRole means Qt wants the data to pre-fill an editor. + """ + expected = { + 0: { + Qt.DisplayRole: DEFAULT_REGION_NAME, + Qt.EditRole: DEFAULT_REGION_NAME, + }, + 1: {Qt.DisplayRole: "polygon", Qt.EditRole: None}, + } + widget, _ = regions_widget_with_layer + index = widget.region_table_model.index(0, column_index) + result = widget.region_table_model.data(index, role) + assert result == expected[column_index][role] + + +@pytest.mark.parametrize( + "method, args, expected", + [ + pytest.param("data", (Qt.DisplayRole,), None, id="data_returns_none"), + pytest.param( + "setData", ("Name", Qt.EditRole), False, id="setData_returns_false" + ), + ], +) +def test_table_model_with_stale_index( + regions_widget_with_layer, method, args, expected +): + """Test table model methods return appropriate values for a stale index. + + A stale index is one that was valid when created but whose row + exceeds the layer data after shapes are removed. + """ + widget, layer = regions_widget_with_layer + assert len(layer.data) == 2 # row 1 is valid before clearing + index = widget.region_table_model.index(1, 0) + layer.data = [] # makes index stale + result = getattr(widget.region_table_model, method)(index, *args) + assert result == expected + + +def test_table_model_setData_updates_region_name(regions_widget_with_layer): + """Test that setData updates the region name column. + + The `name` column is column index = 0. + """ + widget, layer = regions_widget_with_layer + index = widget.region_table_model.index(0, 0) + + # Assert that name is initially default + assert layer.properties["name"][0] == DEFAULT_REGION_NAME + + # Edit the name via the model and assert it updates in layer properties + result = widget.region_table_model.setData(index, "New Name", Qt.EditRole) + assert result is True + assert layer.properties["name"][0] == "New Name" + + +def test_table_model_setData_rejects_shape_type_edit( + regions_widget_with_layer, +): + """Test that setData returns False for shape_type column. + + The `shape_type` column is column index = 1 . + """ + widget, _ = regions_widget_with_layer + index = widget.region_table_model.index(0, 1) + result = widget.region_table_model.setData(index, "rectangle", Qt.EditRole) + assert result is False + + +@pytest.mark.parametrize( + "column, expected_editable", + [(0, True), (1, False)], + ids=["name_editable", "shape_type_not_editable"], +) +def test_table_model_column_editability( + regions_widget_with_layer, column, expected_editable +): + """Test that only the name column is editable.""" + widget, _ = regions_widget_with_layer + index = widget.region_table_model.index(0, column) + flags = widget.region_table_model.flags(index) + assert bool(flags & Qt.ItemIsEditable) == expected_editable + + +def test_table_model_updates_on_shape_added( + regions_widget_with_layer, two_polygons +): + """Test that adding a shape updates the table model.""" + widget, layer = regions_widget_with_layer + initial_count = widget.region_table_model.rowCount() + layer.add(two_polygons[:1]) + + assert widget.region_table_model.rowCount() == initial_count + 1 + + +def test_sync_names_assigns_default_to_new_shapes( + regions_widget_with_layer, two_polygons +): + """Test that _sync_names_on_shape_change assigns a unique default name.""" + widget, layer = regions_widget_with_layer + model = widget.region_table_model + # Add a shape so layer has 3 shapes + layer.add(two_polygons[:1]) + # Reset _last_shape_count to simulate state before shape was added + model._last_shape_count = 2 + # Call sync with use_default_name=True (as the data handler does) + model._sync_names_on_shape_change(n_shapes=3, use_default_name=True) + # New shape gets a unique name derived from DEFAULT_REGION_NAME + names = layer.properties["name"] + assert names[2].startswith(DEFAULT_REGION_NAME) + assert names[2] not in names[:2] # new name is unique among prior names + + +def test_table_model_updates_on_shape_removed(regions_widget_with_layer): + """Test that removing a shape updates the table model.""" + widget, layer = regions_widget_with_layer + initial_count = widget.region_table_model.rowCount() + layer.selected_data = {0} + layer.remove_selected() + + assert widget.region_table_model.rowCount() == initial_count - 1 + + +def test_table_model_set_data_handler_uniquifies_pasted_names( + regions_widget_with_layer, two_polygons +): + """Test that _on_layer_set_data uniquifies duplicate names from copy-paste. + + This handler is triggered by copy-paste operations. It should detect + shape count changes and give pasted shapes unique names. + """ + widget, layer = regions_widget_with_layer + model = widget.region_table_model + + # Add a third shape with a name that duplicates an existing one + layer.add(two_polygons[:1]) + layer.properties = {"name": ["Region-A", "Region-B", "Region-A"]} + + # Simulate the state before a "paste" by resetting the shape count tracker + model._last_shape_count = 2 + + # Call the handler (as would happen on set_data event) + model._on_layer_set_data() + + # Verify model updated and duplicate name was made unique + assert model.rowCount() == 3 + assert model._last_shape_count == 3 + expected_names = ["Region-A", "Region-B", "Region-A_1"] + assert list(layer.properties["name"]) == expected_names + + +def test_table_model_cleared_on_layer_deletion(regions_widget_with_layer): + """Test that deleting the layer clears the table model.""" + widget, layer = regions_widget_with_layer + widget.viewer.layers.remove(layer) + assert widget.region_table_model is None + + +def test_table_model_ignores_other_layer_deletion( + regions_widget_with_layer, two_polygons +): + """Test that table model ignores deletion of unrelated layers.""" + widget, layer = regions_widget_with_layer + other_layer = widget.viewer.add_shapes(two_polygons, name="Other layer") + widget.viewer.layers.remove(other_layer) + + assert widget.region_table_model is not None + assert widget.region_table_model.layer == layer + + +@pytest.mark.parametrize( + "method_name", + ["_on_layer_data_changed", "_on_layer_set_data"], +) +def test_layer_event_handlers_return_early_when_no_layer( + regions_widget_with_layer, method_name +): + """Test that layer event handlers return early when layer is None.""" + widget, _ = regions_widget_with_layer + model = widget.region_table_model + model.layer = None + method = getattr(model, method_name) + with does_not_raise(): + method(event=None) + + +def test_on_layer_deleted_cleans_up_table_model( + regions_widget_with_layer, mocker +): + """Test that _on_layer_deleted disconnects and clears the table model.""" + widget, layer = regions_widget_with_layer + model = widget.region_table_model + # Create mock event with the layer as value + mock_event = mocker.Mock() + mock_event.value = layer + # Call _on_layer_deleted directly + model._on_layer_deleted(mock_event) + # Model should have cleared its layer reference + assert model.layer is None + + +# ------------------- Tests for RegionsTableView -----------------------------# +def test_table_row_selection_syncs_to_shape(regions_widget_with_layer): + """Test that selecting a row in table selects shape in layer.""" + widget, layer = regions_widget_with_layer + widget.region_table_view.selectRow(0) + assert layer.selected_data == {0} + + +def test_shape_selection_syncs_to_table_row(regions_widget_with_layer): + """Test that selecting a shape in napari highlights the table row. + This is the inverse of the previous test, ensuring a bidirectional sync. + """ + widget, layer = regions_widget_with_layer + layer.selected_data = {1} + assert widget.region_table_view.currentIndex().row() == 1 + + +def test_shape_deselection_clears_table_selection(regions_widget_with_layer): + """Test that deselecting shapes in napari clears the table selection.""" + widget, layer = regions_widget_with_layer + layer.selected_data = {0} + layer.selected_data = set() + assert not widget.region_table_view.selectionModel().hasSelection() + + +def test_napari_layer_selection_syncs_to_dropdown(regions_widget): + """Test that selecting a region layer in napari updates the dropdown.""" + viewer = regions_widget.viewer + layer_a = add_regions_layer(viewer, name="Regions-A") + layer_b = add_regions_layer(viewer, name="Regions-B") + + viewer.layers.selection.active = layer_a + assert regions_widget.layer_dropdown.currentText() == "Regions-A" + + viewer.layers.selection.active = layer_b + assert regions_widget.layer_dropdown.currentText() == "Regions-B" + + +def test_non_region_layer_selection_does_not_change_dropdown(regions_widget): + """Test that selecting a non-region layer leaves the dropdown unchanged.""" + viewer = regions_widget.viewer + add_regions_layer(viewer, name="Regions-A") + other_layer = viewer.add_shapes(name="Other shapes") + + regions_widget.layer_dropdown.setCurrentText("Regions-A") + viewer.layers.selection.active = other_layer + + assert regions_widget.layer_dropdown.currentText() == "Regions-A" + + +def test_table_allows_name_editing(regions_widget_with_layer): + """Test that name column is editable via double-click.""" + widget, _ = regions_widget_with_layer + triggers = widget.region_table_view.editTriggers() + assert triggers & QTableView.DoubleClicked + + +# ------------------- Tests for tooltips -------------------------------------# +@pytest.mark.parametrize( + "add_shapes_kwargs, expected_text", + [ + pytest.param(None, "No region layers", id="no_layers"), + pytest.param( + {"name": "regions"}, + "No regions in this layer", + id="empty_layer", + ), + pytest.param( + { + "name": "regions", + "data": [[[0, 0], [0, 10], [10, 10], [10, 0]]], + }, + "Click a row", + id="with_shapes", + ), + ], +) +def test_table_tooltip_reflects_state( + make_napari_viewer_proxy, add_shapes_kwargs, expected_text +): + """Test table tooltip text reflects current widget state.""" + viewer = make_napari_viewer_proxy() + if add_shapes_kwargs is not None: + add_regions_layer(viewer, **add_shapes_kwargs) + widget = RegionsWidget(viewer) + assert expected_text in widget.region_table_view.toolTip() + + +# ------------------- Tests for edge cases -----------------------------------# +def test_draw_delete_and_redraw(regions_widget, two_polygons): + """Test the full draw → delete all → redraw lifecycle. + + It should run without errors, and after the redraw, + the new shape should get a valid default name. + """ + viewer = regions_widget.viewer + layer = add_regions_layer(viewer) + + # Draw, then delete — must not raise + layer.add(two_polygons[:1]) + layer.selected_data = {0} + with does_not_raise(): + layer.remove_selected() + assert regions_widget.region_table_model.rowCount() == 0 + + # Redraw after empty — must not raise and must produce a valid name + layer.add(two_polygons[:1]) + assert regions_widget.region_table_model.rowCount() == 1 + assert layer.properties["name"][0] == DEFAULT_REGION_NAME + + +def test_edge_color_change_updates_text_color(regions_widget_with_layer): + """Test that text colour is tethered to edge colour. + + When the edge_color event fires, _on_edge_color_changed should update + text colours to match without raising an error. + """ + widget, layer = regions_widget_with_layer + with does_not_raise(): + layer.events.edge_color() + assert hasattr(widget.region_table_model, "_on_edge_color_changed") + + +def test_text_label_deferred_until_first_shape_drawn( + regions_widget, two_polygons +): + """Test that text.string is only set to '{name}' once a shape exists.""" + viewer = regions_widget.viewer + layer = add_regions_layer(viewer) # triggers link with no shapes + + assert "{name}" not in str(layer.text.string) + layer.add(two_polygons[:1]) # draw first shape + assert "{name}" in str(layer.text.string) + + +def test_empty_shapes_layer(make_napari_viewer_proxy): + """Test table handles empty shapes layer.""" + viewer = make_napari_viewer_proxy() + add_regions_layer(viewer) + with does_not_raise(): + widget = RegionsWidget(viewer) + + assert widget.region_table_model.rowCount() == 0 + + +def test_table_model_flags_invalid_index(regions_widget_with_layer): + """Test flags returns NoItemFlags for invalid index.""" + widget, _ = regions_widget_with_layer + invalid_index = QModelIndex() + flags = widget.region_table_model.flags(invalid_index) + assert flags == Qt.NoItemFlags + + +def test_table_model_guards_invalid_index(regions_widget_with_layer): + """Test data() and setData() return sentinel values for invalid index.""" + widget, _ = regions_widget_with_layer + model = widget.region_table_model + invalid = QModelIndex() + assert model.data(invalid) is None + assert model.setData(invalid, "x") is False + + +@pytest.mark.parametrize( + "call_handler", + [ + pytest.param( + lambda v: v._on_row_selection_changed(None, None), + id="row_selection", + ), + pytest.param( + lambda v: v._on_shape_selection_changed(), + id="shape_selection", + ), + ], +) +def test_table_view_selection_handlers_return_early_with_no_model( + regions_widget, call_handler +): + """Test selection handlers do not raise when no table model is linked.""" + with does_not_raise(): + call_handler(regions_widget.region_table_view) + + +def test_table_view_selection_with_empty_indexes(regions_widget_with_layer): + """Test table view handles empty selection indexes.""" + widget, _ = regions_widget_with_layer + empty_selection = QItemSelection() + with does_not_raise(): + widget.region_table_view._on_row_selection_changed( + empty_selection, None + ) + + +@pytest.mark.parametrize( + "orientation, role, expected", + [ + (Qt.Vertical, Qt.DisplayRole, "0"), + (Qt.Horizontal, Qt.DecorationRole, None), + ], + ids=["vertical_header", "non_display_role"], +) +def test_table_model_header_data_edge_cases( + regions_widget_with_layer, orientation, role, expected +): + """Test headerData for vertical orientation and non-display roles.""" + widget, _ = regions_widget_with_layer + header = widget.region_table_model.headerData(0, orientation, role) + assert header == expected