Skip to content

Reduce dependence on ViewerModel, using Layer(List) instead#125

Merged
TimMonko merged 39 commits into
napari:mainfrom
TimMonko:enh/reduce-complexity
Mar 20, 2026
Merged

Reduce dependence on ViewerModel, using Layer(List) instead#125
TimMonko merged 39 commits into
napari:mainfrom
TimMonko:enh/reduce-complexity

Conversation

@TimMonko
Copy link
Copy Markdown
Contributor

@TimMonko TimMonko commented Mar 15, 2026

References and relevant issues

Built on #124 diff: TimMonko/napari-metadata@enh/layer-events...TimMonko:napari-metadata:enh/reduce-complexity

Born out of discussion about 12 hours ago with @carlosmariorr

Description

Significantly cleans up the code by only placing the ViewerModel inheritance at the npe2 MetadataWidget (_main.py) boundary. Other widgets now only need to inherit Layer (Axis and File widgets) or LayerList (Inheritance Widget), this creates significantly easier reasoning about what should happen in each widget.

Because we know that the subwidgets are only shown in the container if a selection is active, we also can assume there will never be a None layer. So, now we also don't need resolve_layer and instead can just depend directly on public layer API. Effectively now layer_utils.py only contains special logic (a good example of this is the dtype one). Because of widget construction lifetimes, we still do need to check for Nones (unfortuantely), so there is some complexity here still, but at least we don't have if None checks everyone.

This is going to be slightly annoying to review, but one key is that tests are either removed (layer_utils functions being gone) or are simpler because they drop ViewerModel and are typed differently. As such, the test suite passing with no logic changes indicates that this doesn't break functionality.

I also tested in napari itself locally, and all events are still hooked up well.

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 15, 2026

Codecov Report

❌ Patch coverage is 99.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.06%. Comparing base (04a1701) to head (f82d72f).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/napari_metadata/widgets/_base.py 98.01% 2 Missing ⚠️
src/napari_metadata/widgets/_main.py 97.14% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #125      +/-   ##
==========================================
+ Coverage   94.47%   97.06%   +2.58%     
==========================================
  Files          11       11              
  Lines        1429     1429              
==========================================
+ Hits         1350     1387      +37     
+ Misses         79       42      -37     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TimMonko
Copy link
Copy Markdown
Contributor Author

after #107 there are lots of conflicts here, which I expected. I'm going to wait until we merge #124 to fix all the conflicts

TimMonko added a commit that referenced this pull request Mar 17, 2026
# References and relevant issues

Meeting 12 hours ago with @carlosmariorr we built this with an LLM
together. We noticed some bad patterns which is now where #125 was born
from.

# Description

This adds callbacks to layer events so that there is now bidirectional
communication between layers and widget values. I'm not sure how
important it is to review this, because #125 bludgeons much of the
logic.

@carlosmariorr what do you think? We discussed this and observed its
working performance this morning, and the event connections continue in
#125.
Comment thread src/napari_metadata/widgets/_axis.py Outdated
Comment on lines +233 to +234
if self._selected_layer is None:
return
Copy link
Copy Markdown
Collaborator

@carlosmariorr carlosmariorr Mar 18, 2026

Choose a reason for hiding this comment

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

I guess I need to leave this comment up.

Is this necessary?

Just context for the archive:
Layer cannot be None anymore in the AxisComponentBase but the inheriting classes (e.g. AxisLabels in _axis.py) have this check in their _on_value_changed() method which I believe is not necessary if the Layer should not be set to None.

I think that the way to handle this is to make it so that in the even of layer changed to None, the widgets are destroyed.

I'll take a look at #127 to check what this is doing and then come back to this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No more of these should exist now.

Comment on lines +199 to +204
scales = layer.scale
for value in scales:
sb = QDoubleSpinBox(parent=self._parent_widget)
sb.setDecimals(3)
sb.setSingleStep(0.1)
sb.setRange(0.001, 1_000_000)
sb.setRange(self._SCALE_MINIMUM, 1_000_000)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

we remove the old method which clamped the axis values, and now just use the widget behavior.
This still works fine with tests. We can increase to 4 decimals if needed

Comment on lines 85 to +99
@@ -106,11 +95,70 @@ def component_label(self) -> QLabel:
return self._component_qlabel

@abstractmethod
def load_entries(self, layer: Layer | None = None) -> None:
"""Load or refresh widget state for *layer* (defaults to active)."""
def load_entries(self, layer: Layer) -> None:
"""Load or refresh widget state for *layer*."""
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Two noticeable changes here that effect everything since this is our base ABC

  1. we don't take viewer anymore
  2. there is no optional layer for load_entries


def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._selected_layer: Layer | None = None
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this is where we init layers, if needed, with None, before they get loaded

Comment on lines +115 to +121
def _require_selected_layer(self) -> Layer:
layer = self._selected_layer
if layer is None:
raise RuntimeError(
f'{type(self).__name__} is not bound to a layer.'
)
return layer
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this is our technical Qt safety for widget references during teardown, so we can call layer = self._required_selected_layer() to ensure that a Layer is returned, and no longer None, this centralizes all the condition if self._selected_layer is None: checks

return layer


class BoundLayerCoordinator(BoundLayerOwner, ABC):
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This ABC is focused on safely referencing layers as they are selected, because I noticed shared logic in so many widgets

"""Return the display string for *layer*."""


class BoundFileComponentBase(BoundLayerOwner, FileComponentBase):
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this class was created for the LayerName widget, which requires safely naming layers and is more than just a callback to a layer property

if self._selected_layer is not None:
for component in self._components:
component.load_entries(self._selected_layer)
layer = self._require_selected_layer()
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

here is where our None checks live like now

Comment on lines +90 to +93
# Wire layer list events directly
self._layers.events.inserted.connect(self._update_layers_combobox)
self._layers.events.removed.connect(self._update_layers_combobox)
self._layers.events.changed.connect(self._update_layers_combobox)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

layerlist event wiring, rather than using the separate things in layer_utils

Comment on lines +265 to +266
self._general_metadata_instance.unbind_layer()
self._axis_metadata_instance.unbind_layer()
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

again, we see here how things like unbinding takes care of both disconnecting events and other parts of the widget deconstruction/reconstruction

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this file removes all layer objects that already exist, and only is used to query things, like datatype, that are not a guaranteed property. Now we just use layer callbacks everywhere

@TimMonko
Copy link
Copy Markdown
Contributor Author

MErging after long talk with @jni in community meeting. Will work on codesmells later, and hopefully left enough breadcrumbs here

@TimMonko TimMonko merged commit 8adbed8 into napari:main Mar 20, 2026
19 checks passed
@TimMonko TimMonko deleted the enh/reduce-complexity branch March 20, 2026 02:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants