From e6f4dc4bc2b56278ec1ac9d8b9d5bc6c6c83469c Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Wed, 11 Mar 2026 22:07:52 -0500 Subject: [PATCH 01/12] override AxisLabels layout entries to use 2 columns --- src/napari_metadata/widgets/_axis.py | 9 +++++++++ src/napari_metadata/widgets/_base.py | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/napari_metadata/widgets/_axis.py b/src/napari_metadata/widgets/_axis.py index 5f7f799..5bd8677 100644 --- a/src/napari_metadata/widgets/_axis.py +++ b/src/napari_metadata/widgets/_axis.py @@ -102,6 +102,15 @@ def _refresh_values(self, layer: Layer) -> None: with QSignalBlocker(self._line_edits[i]): self._line_edits[i].setText(label) + def get_layout_entries(self, axis_index: int) -> list[LayoutEntry]: + """Skip the empty axis-name column; span the line edit across it.""" + line_edit = self._line_edits[axis_index] + line_edit.setToolTip(self._tooltip_text) + return [ + LayoutEntry(widgets=[line_edit], col_span=2), + LayoutEntry(widgets=[self._inherit_checkboxes[axis_index]]), + ] + def _get_value_entries(self, axis_index: int) -> list[LayoutEntry]: return [LayoutEntry(widgets=[self._line_edits[axis_index]])] diff --git a/src/napari_metadata/widgets/_base.py b/src/napari_metadata/widgets/_base.py index a01bf88..84983f2 100644 --- a/src/napari_metadata/widgets/_base.py +++ b/src/napari_metadata/widgets/_base.py @@ -86,7 +86,9 @@ def __init__( self._component_qlabel = QLabel(self._label_text, parent=parent_widget) self._component_qlabel.setStyleSheet('font-weight: bold') - self._component_qlabel.setAlignment(Qt.AlignmentFlag.AlignLeft) + self._component_qlabel.setAlignment( + Qt.AlignmentFlag.AlignLeft + ) # this seems to do nothing self._component_qlabel.setToolTip(self._tooltip_text) @property From edadb272d90f0d1af718a4d48b8a2f024f0956e3 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Wed, 11 Mar 2026 22:18:50 -0500 Subject: [PATCH 02/12] span axes widgets across 4 columns --- src/napari_metadata/widgets/_axis.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/napari_metadata/widgets/_axis.py b/src/napari_metadata/widgets/_axis.py index 5bd8677..7b6e36d 100644 --- a/src/napari_metadata/widgets/_axis.py +++ b/src/napari_metadata/widgets/_axis.py @@ -103,11 +103,11 @@ def _refresh_values(self, layer: Layer) -> None: self._line_edits[i].setText(label) def get_layout_entries(self, axis_index: int) -> list[LayoutEntry]: - """Skip the empty axis-name column; span the line edit across it.""" + """Skip the empty axis-name column; span the line edit across all value cols.""" line_edit = self._line_edits[axis_index] line_edit.setToolTip(self._tooltip_text) return [ - LayoutEntry(widgets=[line_edit], col_span=2), + LayoutEntry(widgets=[line_edit], col_span=3), LayoutEntry(widgets=[self._inherit_checkboxes[axis_index]]), ] @@ -175,7 +175,7 @@ def _refresh_values(self, layer: Layer) -> None: self._spinboxes[i].setValue(value) def _get_value_entries(self, axis_index: int) -> list[LayoutEntry]: - return [LayoutEntry(widgets=[self._spinboxes[axis_index]])] + return [LayoutEntry(widgets=[self._spinboxes[axis_index]], col_span=2)] def _get_layer_values(self, layer: Layer) -> tuple: return get_axes_translations(self._napari_viewer, layer) @@ -236,7 +236,7 @@ def _refresh_values(self, layer: Layer) -> None: self._spinboxes[i].setValue(value) def _get_value_entries(self, axis_index: int) -> list[LayoutEntry]: - return [LayoutEntry(widgets=[self._spinboxes[axis_index]])] + return [LayoutEntry(widgets=[self._spinboxes[axis_index]], col_span=2)] def _get_layer_values(self, layer: Layer) -> tuple: return get_axes_scales(self._napari_viewer, layer) From 3d91361b349c4b994d95a5655f7863bd8dd59bce Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 12 Mar 2026 12:28:51 -0500 Subject: [PATCH 03/12] clean size policy for inheritance widget --- src/napari_metadata/widgets/_inheritance.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/napari_metadata/widgets/_inheritance.py b/src/napari_metadata/widgets/_inheritance.py index 732dfb6..5da2f94 100644 --- a/src/napari_metadata/widgets/_inheritance.py +++ b/src/napari_metadata/widgets/_inheritance.py @@ -52,12 +52,13 @@ def __init__( self._layout.setSpacing(3) self._layout.setContentsMargins(10, 10, 10, 10) - self.setMinimumWidth(300) - self._layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.setSizePolicy( - QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + QSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Preferred, + ) ) self._template_layer_label = QLabel('Template layer') From c9425b1643646073374fd846fb0a88a06df117de Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 12 Mar 2026 12:29:28 -0500 Subject: [PATCH 04/12] fix size syncing and scrolling for container widgets --- src/napari_metadata/widgets/_containers.py | 191 ++++++++++++++------- 1 file changed, 127 insertions(+), 64 deletions(-) diff --git a/src/napari_metadata/widgets/_containers.py b/src/napari_metadata/widgets/_containers.py index 5f141af..6fd20f4 100644 --- a/src/napari_metadata/widgets/_containers.py +++ b/src/napari_metadata/widgets/_containers.py @@ -21,7 +21,7 @@ from typing import TYPE_CHECKING, Literal -from qtpy.QtCore import QEvent, QObject, Qt +from qtpy.QtCore import QEvent, QObject, QSize, Qt from qtpy.QtGui import QWheelEvent from qtpy.QtWidgets import ( QHBoxLayout, @@ -44,6 +44,43 @@ Orientation = Literal['vertical', 'horizontal'] +class _ContentScrollArea(QScrollArea): + """Scroll area whose size hint tracks its content widget. + + This lets the parent layout size the expanded section from the content's + natural size without manually pinning fixed dimensions. + """ + + def __init__( + self, orientation: Orientation, parent: QWidget | None = None + ): + super().__init__(parent) + self._orientation = orientation + + def sizeHint(self) -> QSize: + widget = self.widget() + if widget is None: + return super().sizeHint() + + hint = widget.sizeHint() + frame = 2 * self.frameWidth() + if self._orientation == 'vertical': + min_hint = widget.minimumSizeHint() + return QSize(min_hint.width() + frame, hint.height() + frame) + return QSize(hint.width() + frame, 0) + + def minimumSizeHint(self) -> QSize: + widget = self.widget() + if widget is None: + return super().minimumSizeHint() + + hint = widget.minimumSizeHint() + frame = 2 * self.frameWidth() + if self._orientation == 'vertical': + return QSize(0, hint.height() + frame) + return QSize(0, 0) + + class CollapsibleSectionContainer(QWidget): """A titled, collapsible section that can be oriented vertically or horizontally. @@ -76,6 +113,12 @@ def __init__( self._on_toggle_callback = on_toggle self._orientation = orientation self._title = title + self.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Maximum + if orientation == 'vertical' + else QSizePolicy.Policy.Expanding, + ) # Outer layout — vertical stacks button above content; # horizontal places button beside content. @@ -91,6 +134,10 @@ def __init__( QPushButton if orientation == 'vertical' else RotatedButton ) self._button: QPushButton = button_class('') + if orientation == 'vertical': + self._button.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) font = self._button.font() font.setBold(True) self._button.setFont(font) @@ -99,12 +146,12 @@ def __init__( self._layout.addWidget(self._button, 0) # Expanding content area - self._expanding_area = QScrollArea(self) - self._expanding_area.setWidgetResizable(True) + self._expanding_area = _ContentScrollArea(orientation, self) if orientation == 'vertical': + self._expanding_area.setWidgetResizable(True) self._expanding_area.setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff + Qt.ScrollBarPolicy.ScrollBarAsNeeded ) self._expanding_area.setHorizontalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAsNeeded @@ -116,19 +163,19 @@ def __init__( if h_scrollbar is not None: h_scrollbar.installEventFilter(self._wheel_filter) self._expanding_area.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum ) else: # horizontal + self._expanding_area.setWidgetResizable(True) self._expanding_area.setVerticalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAsNeeded ) self._expanding_area.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff + Qt.ScrollBarPolicy.ScrollBarAsNeeded ) self._expanding_area.setSizePolicy( - QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding ) - self._expanding_area.setFixedWidth(0) self._expanding_area.setVisible(False) # Stretch factor 1 for horizontal so the content area fills available space @@ -156,19 +203,20 @@ def set_content_widget(self, widget: QWidget) -> None: if self._orientation == 'vertical': widget.setSizePolicy( - QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred ) self._expanding_area.setWidget(widget) else: # horizontal — wrap to keep content top-aligned wrapper = QWidget(self._expanding_area) + wrapper.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) wrapper_layout = QVBoxLayout(wrapper) wrapper_layout.setContentsMargins(0, 0, 0, 0) wrapper_layout.addWidget(widget) wrapper_layout.addStretch(1) self._expanding_area.setWidget(wrapper) - self._sync_size() - def isExpanded(self) -> bool: """Return ``True`` if the section is currently expanded.""" return self._button.isChecked() @@ -181,74 +229,89 @@ def setExpanded(self, checked: bool) -> None: """ self._button.setChecked(checked) + def sizeHint(self) -> QSize: + return self._section_size_hint(minimum=False) + + def minimumSizeHint(self) -> QSize: + return self._section_size_hint(minimum=True) + + def set_horizontal_section_width(self, width: int) -> None: + """Apply a computed total width for horizontal layout. + + The width is dynamic and derived from the available viewport width, + not a hardcoded constant. + """ + if self._orientation != 'horizontal': + return + collapsed_width = self.collapsed_width_hint() + self.setFixedWidth(max(collapsed_width, width)) + + def set_vertical_section_height(self, height: int) -> None: + """Apply a computed total height for vertical layout.""" + if self._orientation != 'vertical': + return + collapsed_height = self.collapsed_height_hint() + self.setFixedHeight(max(collapsed_height, height)) + + def collapsed_width_hint(self) -> int: + margins = self._layout.contentsMargins() + return ( + margins.left() + margins.right() + self._button.sizeHint().width() + ) + + def collapsed_height_hint(self) -> int: + margins = self._layout.contentsMargins() + return ( + margins.top() + margins.bottom() + self._button.sizeHint().height() + ) + # ------------------------------------------------------------------ # Private helpers # ------------------------------------------------------------------ + def _section_size_hint(self, *, minimum: bool) -> QSize: + button_hint = ( + self._button.minimumSizeHint() + if minimum + else self._button.sizeHint() + ) + if not self._button.isChecked(): + content_hint = QSize(0, 0) + else: + content_hint = ( + self._expanding_area.minimumSizeHint() + if minimum + else self._expanding_area.sizeHint() + ) + + margins = self._layout.contentsMargins() + width = margins.left() + margins.right() + height = margins.top() + margins.bottom() + spacing = self._layout.spacing() if self._button.isChecked() else 0 + + if self._orientation == 'vertical': + width += max(button_hint.width(), content_hint.width()) + height += button_hint.height() + spacing + content_hint.height() + else: + width += button_hint.width() + spacing + content_hint.width() + height += max(button_hint.height(), content_hint.height()) + + return QSize(width, height) + def _on_button_toggled(self, checked: bool) -> None: """Respond to the toggle button state change.""" self._expanding_area.setVisible(checked) - self._sync_size() if self._on_toggle_callback is not None: self._on_toggle_callback(checked) indicator = '\u25bc' if checked else '\u25b6' self._button.setText(f'{indicator} {self._title}') - - self._expanding_area.updateGeometry() - self.updateGeometry() - - def _sync_size(self) -> None: - """Fix the expanding area's size to match its content hint.""" - current_widget = self._expanding_area.widget() - - # Use the button's checked state as the authoritative "is expanded" - # guard. isVisible() would return False whenever an ancestor widget - # is hidden (e.g. during programmatic rebuild before the dock is - # shown), causing the size to be wrongly zeroed out. - if not self._button.isChecked() or current_widget is None: - if self._orientation == 'vertical': - self._expanding_area.setFixedHeight(0) - else: - self._expanding_area.setFixedWidth(0) - return - - if self._orientation == 'vertical': - h_scrollbar = self._expanding_area.horizontalScrollBar() - scrollbar_h = ( - h_scrollbar.sizeHint().height() - if h_scrollbar is not None - else 0 - ) - frame = 2 * self._expanding_area.frameWidth() - # Activate the layout before reading sizeHint so the value is valid - # even when the widget hasn't had a paint pass yet (e.g. during a - # programmatic expand called from _do_rebuild_content). - layout = current_widget.layout() - if layout is not None: - layout.activate() - self._expanding_area.setFixedHeight( - current_widget.sizeHint().height() + scrollbar_h + frame - ) - else: # horizontal - v_scrollbar = self._expanding_area.verticalScrollBar() - scrollbar_w = ( - v_scrollbar.sizeHint().width() - if v_scrollbar is not None - else 0 - ) - frame = 2 * self._expanding_area.frameWidth() - layout = current_widget.layout() - if layout is not None: - layout.activate() - self._expanding_area.setFixedWidth( - current_widget.sizeHint().width() + scrollbar_w + frame - ) - - current_widget.updateGeometry() self._expanding_area.updateGeometry() self.updateGeometry() + parent = self.parentWidget() + if parent is not None: + parent.updateGeometry() class RotatedButton(QPushButton): From e4d3183303f1fe508176ba5fb78b460e64987781 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 12 Mar 2026 12:30:20 -0500 Subject: [PATCH 05/12] LLM generated size management for overall main widget --- src/napari_metadata/widgets/_main.py | 225 +++++++++++++++++++++++++-- 1 file changed, 213 insertions(+), 12 deletions(-) diff --git a/src/napari_metadata/widgets/_main.py b/src/napari_metadata/widgets/_main.py index 889daf0..6c8543f 100644 --- a/src/napari_metadata/widgets/_main.py +++ b/src/napari_metadata/widgets/_main.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, cast from napari.utils.notifications import show_info -from qtpy.QtCore import QObject, Qt +from qtpy.QtCore import QObject, Qt, QTimer from qtpy.QtGui import QShowEvent from qtpy.QtWidgets import ( QDockWidget, @@ -67,6 +67,9 @@ class MetadataWidget(QWidget): def __init__(self, napari_viewer: ViewerModel) -> None: super().__init__() + self.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) self._viewer = napari_viewer self._napari_viewer = napari_viewer self._selected_layer: Layer | None = None @@ -93,6 +96,9 @@ def __init__(self, napari_viewer: ViewerModel) -> None: # Content page wrapper — holds the orientation-specific scroll area self._content_page = QWidget(self) + self._content_page.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) self._content_page_layout = QVBoxLayout(self._content_page) self._content_page_layout.setContentsMargins(0, 0, 0, 0) self._stacked_layout.addWidget(self._content_page) # index 0 @@ -141,6 +147,32 @@ def showEvent(self, a0: QShowEvent | None) -> None: self._on_selected_layers_changed() self._already_shown = True + def resizeEvent(self, a0) -> None: + super().resizeEvent(a0) + self._schedule_section_size_update() + + def _request_preferred_size(self) -> None: + preferred = self.sizeHint().expandedTo(self.minimumSizeHint()) + if preferred.isValid(): + self.resize(self.size().expandedTo(preferred)) + + parent = self.parentWidget() + if isinstance(parent, QDockWidget): + parent.resize(parent.size().expandedTo(preferred)) + + def _schedule_section_size_update(self) -> None: + QTimer.singleShot(0, self._update_section_sizes) + + def sizeHint(self): + if self._stacked_layout.currentIndex() == _CONTENT_PAGE: + return self._content_page.sizeHint() + return super().sizeHint() + + def minimumSizeHint(self): + if self._stacked_layout.currentIndex() == _CONTENT_PAGE: + return self._content_page.minimumSizeHint() + return super().minimumSizeHint() + def _on_dock_location_changed(self) -> None: """Handle dock widget location change — rebuild if orientation changed.""" if self._selected_layer is None: @@ -253,6 +285,7 @@ def _do_rebuild_content(self, orientation: Orientation) -> None: scroll.setHorizontalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) + scroll.setWidgetResizable(True) else: scroll = HorizontalOnlyOuterScrollArea(self._content_page) scroll.setVerticalScrollBarPolicy( @@ -261,11 +294,19 @@ def _do_rebuild_content(self, orientation: Orientation) -> None: scroll.setHorizontalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAsNeeded ) - scroll.setWidgetResizable(True) + scroll.setWidgetResizable(True) self._scroll_area = scroll # Content inside the scroll area scroll_content = QWidget(scroll) + if is_vertical: + scroll_content.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred + ) + else: + scroll_content.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) layout_class = QVBoxLayout if is_vertical else QHBoxLayout sections_layout = layout_class(scroll_content) sections_layout.setContentsMargins(0, 0, 0, 0) @@ -299,7 +340,172 @@ def _do_rebuild_content(self, orientation: Orientation) -> None: self._content_page_layout.addWidget(scroll) self._current_orientation = orientation - self.setMinimumSize(50, 50) + self._request_preferred_size() + self._schedule_section_size_update() + self.updateGeometry() + parent = self.parentWidget() + if parent is not None: + parent.updateGeometry() + + def _update_section_sizes(self) -> None: + if self._current_orientation == 'horizontal': + self._update_horizontal_section_widths() + elif self._current_orientation == 'vertical': + self._update_vertical_section_heights() + + def _update_horizontal_section_widths(self) -> None: + if self._current_orientation != 'horizontal': + return + if self._scroll_area is None: + return + if ( + self._file_section is None + or self._axis_section is None + or self._inheritance_section is None + ): + return + + viewport_width = self._scroll_area.viewport().width() + if viewport_width <= 0: + return + + sections = [ + self._file_section, + self._axis_section, + self._inheritance_section, + ] + spacing = 3 * max(len(sections) - 1, 0) + + collapsed_total = 0 + expanded: list[CollapsibleSectionContainer] = [] + preferred: dict[CollapsibleSectionContainer, int] = {} + minimum: dict[CollapsibleSectionContainer, int] = {} + + for section in sections: + collapsed_width = section.collapsed_width_hint() + if section.isExpanded(): + expanded.append(section) + preferred[section] = max( + section.sizeHint().width(), collapsed_width + ) + minimum[section] = collapsed_width + else: + collapsed_total += collapsed_width + section.set_horizontal_section_width(collapsed_width) + + if not expanded: + return + + available = max(viewport_width - spacing - collapsed_total, 0) + min_total = sum(minimum.values()) + pref_total = sum(preferred.values()) + + if available <= min_total: + for section in expanded: + section.set_horizontal_section_width(minimum[section]) + return + + if available >= pref_total: + for section in expanded: + section.set_horizontal_section_width(preferred[section]) + return + + low = min(minimum.values()) + high = max(preferred.values()) + + for _ in range(24): + cap = (low + high) / 2 + total = sum( + max(minimum[section], min(preferred[section], int(cap))) + for section in expanded + ) + if total > available: + high = cap + else: + low = cap + + final_cap = int(low) + for section in expanded: + section.set_horizontal_section_width( + max(minimum[section], min(preferred[section], final_cap)) + ) + + def _update_vertical_section_heights(self) -> None: + if self._current_orientation != 'vertical': + return + if self._scroll_area is None: + return + if ( + self._file_section is None + or self._axis_section is None + or self._inheritance_section is None + ): + return + + viewport_height = self._scroll_area.viewport().height() + if viewport_height <= 0: + return + + sections = [ + self._file_section, + self._axis_section, + self._inheritance_section, + ] + spacing = 3 * max(len(sections) - 1, 0) + + collapsed_total = 0 + expanded: list[CollapsibleSectionContainer] = [] + preferred: dict[CollapsibleSectionContainer, int] = {} + minimum: dict[CollapsibleSectionContainer, int] = {} + + for section in sections: + collapsed_height = section.collapsed_height_hint() + if section.isExpanded(): + expanded.append(section) + preferred[section] = max( + section.sizeHint().height(), collapsed_height + ) + minimum[section] = collapsed_height + else: + collapsed_total += collapsed_height + section.set_vertical_section_height(collapsed_height) + + if not expanded: + return + + available = max(viewport_height - spacing - collapsed_total, 0) + min_total = sum(minimum.values()) + pref_total = sum(preferred.values()) + + if available <= min_total: + for section in expanded: + section.set_vertical_section_height(minimum[section]) + return + + if available >= pref_total: + for section in expanded: + section.set_vertical_section_height(preferred[section]) + return + + low = min(minimum.values()) + high = max(preferred.values()) + + for _ in range(24): + cap = (low + high) / 2 + total = sum( + max(minimum[section], min(preferred[section], int(cap))) + for section in expanded + ) + if total > available: + high = cap + else: + low = cap + + final_cap = int(low) + for section in expanded: + section.set_vertical_section_height( + max(minimum[section], min(preferred[section], final_cap)) + ) def _detach_component_widgets(self) -> None: """Reparent all persistent component widgets back to *self*. @@ -332,6 +538,7 @@ def _build_file_section( self, 'File metadata', orientation=orientation, + on_toggle=lambda _: self._schedule_section_size_update(), ) container = QWidget(self) grid = QGridLayout(container) @@ -347,6 +554,7 @@ def _build_axis_section( self, 'Axes metadata', orientation=orientation, + on_toggle=lambda _: self._schedule_section_size_update(), ) container = QWidget(self) grid = QGridLayout(container) @@ -363,7 +571,8 @@ def _build_inheritance_section( 'Axes inheritance', orientation=orientation, on_toggle=lambda checked: ( - self._axis_metadata_instance.set_checkboxes_visible(checked) + self._axis_metadata_instance.set_checkboxes_visible(checked), + self._schedule_section_size_update(), ), ) container = QWidget(self) @@ -508,10 +717,6 @@ def _populate_axis_grid_vertical( for entry in component.get_layout_entries(axis_index): for widget in entry.widgets: - widget.setSizePolicy( - QSizePolicy.Policy.Expanding, - QSizePolicy.Policy.Expanding, - ) grid.addWidget( widget, row, @@ -578,10 +783,6 @@ def _populate_axis_grid_horizontal( for entry in component.get_layout_entries(axis_index): for widget in entry.widgets: - widget.setSizePolicy( - QSizePolicy.Policy.Expanding, - QSizePolicy.Policy.Expanding, - ) grid.addWidget( widget, current_row, From 41375cacfc32908a1aae57c70b0571382d4c4a65 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 12 Mar 2026 12:31:05 -0500 Subject: [PATCH 06/12] LLM generated test validation of scrolling and sizing this was really useful for debugging and developed over time. I'm not sure how useful it is for perpetuity, I'll look closely at them and potentially remove later --- tests/widgets/test_containers.py | 71 +++++++++++++++++++++++ tests/widgets/test_main.py | 99 +++++++++++++++++++++++++------- 2 files changed, 148 insertions(+), 22 deletions(-) diff --git a/tests/widgets/test_containers.py b/tests/widgets/test_containers.py index d14a6c2..a236ebe 100644 --- a/tests/widgets/test_containers.py +++ b/tests/widgets/test_containers.py @@ -3,6 +3,7 @@ from __future__ import annotations import pytest +from qtpy.QtCore import Qt from napari_metadata.widgets._containers import ( CollapsibleSectionContainer, @@ -134,6 +135,76 @@ def test_replace_content_widget(self, qtbot): current = w._expanding_area.widget() assert current is not None + def test_vertical_content_area_uses_content_size_hint(self, qtbot): + from qtpy.QtCore import QSize + from qtpy.QtWidgets import QWidget + + class _HintWidget(QWidget): + def sizeHint(self): + return QSize(120, 80) + + w = CollapsibleSectionContainer(None, 'T', 'vertical') + qtbot.addWidget(w) + content = _HintWidget() + w.set_content_widget(content) + + expected = ( + content.sizeHint().height() + 2 * w._expanding_area.frameWidth() + ) + assert w._expanding_area.sizeHint().height() == expected + assert w._expanding_area.sizeHint().width() > 0 + + def test_horizontal_content_area_uses_wrapper_size_hint(self, qtbot): + from qtpy.QtWidgets import QLabel + + w = CollapsibleSectionContainer(None, 'T', 'horizontal') + qtbot.addWidget(w) + content = QLabel('hello') + w.set_content_widget(content) + + wrapper = w._expanding_area.widget() + assert wrapper is not None + expected = ( + wrapper.sizeHint().width() + 2 * w._expanding_area.frameWidth() + ) + assert w._expanding_area.sizeHint().width() == expected + assert w._expanding_area.sizeHint().height() == 0 + + +class TestScrollPolicies: + def test_vertical_sections_allow_visible_vertical_scrolling(self, qtbot): + w = CollapsibleSectionContainer(None, 'T', 'vertical') + qtbot.addWidget(w) + assert ( + w._expanding_area.verticalScrollBarPolicy() + == Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + + def test_horizontal_sections_allow_inner_horizontal_scrolling(self, qtbot): + w = CollapsibleSectionContainer(None, 'T', 'horizontal') + qtbot.addWidget(w) + assert ( + w._expanding_area.horizontalScrollBarPolicy() + == Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + assert w._expanding_area.widgetResizable() + + def test_vertical_section_button_expands_with_parent_width(self, qtbot): + from qtpy.QtWidgets import QVBoxLayout, QWidget + + parent = QWidget() + parent.resize(320, 200) + layout = QVBoxLayout(parent) + layout.setContentsMargins(0, 0, 0, 0) + + w = CollapsibleSectionContainer(parent, 'T', 'vertical') + layout.addWidget(w) + qtbot.addWidget(parent) + parent.show() + qtbot.waitExposed(parent) + + assert w._button.width() >= parent.width() - 20 + class TestRotatedButton: def test_size_hint_is_transposed(self, qtbot): diff --git a/tests/widgets/test_main.py b/tests/widgets/test_main.py index 3f1700e..f9840f0 100644 --- a/tests/widgets/test_main.py +++ b/tests/widgets/test_main.py @@ -16,7 +16,7 @@ import numpy as np import pytest -from qtpy.QtWidgets import QFrame, QGridLayout, QWidget +from qtpy.QtWidgets import QFrame, QGridLayout, QVBoxLayout, QWidget from napari_metadata.widgets._main import ( _CONTENT_PAGE, @@ -31,6 +31,26 @@ from napari.components import ViewerModel +def _assert_section_content_available(widget: MetadataWidget) -> None: + assert widget._file_section is not None + assert widget._axis_section is not None + assert widget._inheritance_section is not None + + for section in ( + widget._file_section, + widget._axis_section, + widget._inheritance_section, + ): + assert section._expanding_area.isVisibleTo(section) + assert section._expanding_area.sizeHint().isValid() + if widget._current_orientation == 'vertical': + assert section._expanding_area.sizeHint().height() > 0 + else: + assert section._expanding_area.sizeHint().width() > 0 + assert section.sizeHint().width() > 0 + assert section.sizeHint().height() > 0 + + @pytest.fixture def viewer_with_layer(viewer_model: ViewerModel): """ViewerModel with a single 2D image layer added.""" @@ -89,6 +109,19 @@ def test_has_axis_metadata_instance(self, metadata_widget: MetadataWidget): def test_has_inheritance_instance(self, metadata_widget: MetadataWidget): assert metadata_widget._inheritance_instance is not None + def test_content_page_size_hint_is_used_when_built( + self, viewer_with_layer, parent_widget: QWidget, qtbot + ): + viewer_model, layer = viewer_with_layer + widget = MetadataWidget(viewer_model) + widget.setParent(parent_widget) + qtbot.addWidget(widget) + + widget._selected_layer = layer + widget._refresh_page() + + assert widget.sizeHint() == widget._content_page.sizeHint() + class TestPageManagement: def test_refresh_page_shows_no_layer_when_none_selected( @@ -360,22 +393,49 @@ def test_expanded_sections_preserved_on_orientation_switch( assert widget._file_section.isExpanded() assert widget._axis_section.isExpanded() assert widget._inheritance_section.isExpanded() - # Content must actually be visible — not just the button in checked state. - # For unshown widgets, height()/width() is 0; but setFixedHeight/Width() - # sets minimumHeight/minimumWidth immediately. - # Vertical sections expand via setFixedHeight; horizontal via setFixedWidth. - if second == 'vertical': - assert widget._file_section._expanding_area.minimumHeight() > 0 - assert widget._axis_section._expanding_area.minimumHeight() > 0 - assert ( - widget._inheritance_section._expanding_area.minimumHeight() > 0 - ) - else: - assert widget._file_section._expanding_area.minimumWidth() > 0 - assert widget._axis_section._expanding_area.minimumWidth() > 0 - assert ( - widget._inheritance_section._expanding_area.minimumWidth() > 0 + _assert_section_content_available(widget) + + def test_horizontal_layout_exposes_outer_scrollbar_when_narrow( + self, + viewer_model: ViewerModel, + qtbot, + ): + layer = viewer_model.add_image( + np.zeros((4, 3, 2)), + axis_labels=('t', 'y', 'x'), + scale=(1.0, 1.0, 1.0), + translate=(0.0, 0.0, 0.0), + units=('pixel', 'pixel', 'pixel'), + ) + + parent = QWidget() + parent.resize(420, 180) + layout = QVBoxLayout(parent) + layout.setContentsMargins(0, 0, 0, 0) + + widget = MetadataWidget(viewer_model) + layout.addWidget(widget) + qtbot.addWidget(parent) + + widget._selected_layer = layer + widget._rebuild_content('horizontal') + + assert widget._file_section is not None + assert widget._axis_section is not None + assert widget._inheritance_section is not None + + widget._file_section.setExpanded(True) + widget._axis_section.setExpanded(True) + widget._inheritance_section.setExpanded(True) + + parent.show() + qtbot.waitExposed(parent) + qtbot.waitUntil( + lambda: ( + widget._scroll_area is not None + and widget._scroll_area.horizontalScrollBar().maximum() >= 0 ) + ) class TestLayerSelectionFlow: @@ -483,12 +543,7 @@ def test_expanded_sections_preserved_on_layer_change( assert widget._file_section.isExpanded() assert widget._axis_section.isExpanded() assert widget._inheritance_section.isExpanded() - # Content must actually be visible — not just the button in checked state. - # For unshown widgets, height() is 0; setFixedHeight() sets minimumHeight. - # This rebuild always uses 'vertical', so check minimumHeight. - assert widget._file_section._expanding_area.minimumHeight() > 0 - assert widget._axis_section._expanding_area.minimumHeight() > 0 - assert widget._inheritance_section._expanding_area.minimumHeight() > 0 + _assert_section_content_available(widget) class TestInheritanceCheckboxSync: From c464a586f53ab6cd2dedcd557bfbdc1c65d798ea Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 12 Mar 2026 13:17:38 -0500 Subject: [PATCH 07/12] refactor size management in _main --- src/napari_metadata/widgets/_main.py | 231 ++++++++++++--------------- 1 file changed, 105 insertions(+), 126 deletions(-) diff --git a/src/napari_metadata/widgets/_main.py b/src/napari_metadata/widgets/_main.py index 6c8543f..e1b6faf 100644 --- a/src/napari_metadata/widgets/_main.py +++ b/src/napari_metadata/widgets/_main.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, cast from napari.utils.notifications import show_info -from qtpy.QtCore import QObject, Qt, QTimer +from qtpy.QtCore import QEvent, QObject, Qt from qtpy.QtGui import QShowEvent from qtpy.QtWidgets import ( QDockWidget, @@ -149,19 +149,85 @@ def showEvent(self, a0: QShowEvent | None) -> None: def resizeEvent(self, a0) -> None: super().resizeEvent(a0) - self._schedule_section_size_update() + self._update_section_sizes() - def _request_preferred_size(self) -> None: - preferred = self.sizeHint().expandedTo(self.minimumSizeHint()) - if preferred.isValid(): - self.resize(self.size().expandedTo(preferred)) + def eventFilter(self, a0, a1) -> bool: + if ( + self._scroll_area is not None + and a0 is self._scroll_area.viewport() + and a1 is not None + and a1.type() + in ( + QEvent.Type.Resize, + QEvent.Type.Show, + QEvent.Type.LayoutRequest, + ) + ): + self._update_section_sizes() + return super().eventFilter(a0, a1) + + def _allocate_section_extent( + self, + *, + sections: list[CollapsibleSectionContainer], + available: int, + spacing: int, + collapsed_hint: callable, + preferred_hint: callable, + apply_extent: callable, + ) -> None: + collapsed_total = 0 + expanded: list[CollapsibleSectionContainer] = [] + preferred: dict[CollapsibleSectionContainer, int] = {} + minimum: dict[CollapsibleSectionContainer, int] = {} - parent = self.parentWidget() - if isinstance(parent, QDockWidget): - parent.resize(parent.size().expandedTo(preferred)) + for section in sections: + collapsed = collapsed_hint(section) + if section.isExpanded(): + expanded.append(section) + preferred[section] = max(preferred_hint(section), collapsed) + minimum[section] = collapsed + else: + collapsed_total += collapsed + apply_extent(section, collapsed) + + if not expanded: + return - def _schedule_section_size_update(self) -> None: - QTimer.singleShot(0, self._update_section_sizes) + usable = max(available - spacing - collapsed_total, 0) + min_total = sum(minimum.values()) + pref_total = sum(preferred.values()) + + if usable <= min_total: + for section in expanded: + apply_extent(section, minimum[section]) + return + + if usable >= pref_total: + for section in expanded: + apply_extent(section, preferred[section]) + return + + low = min(minimum.values()) + high = max(preferred.values()) + + for _ in range(24): + cap = (low + high) / 2 + total = sum( + max(minimum[section], min(preferred[section], int(cap))) + for section in expanded + ) + if total > usable: + high = cap + else: + low = cap + + final_cap = int(low) + for section in expanded: + apply_extent( + section, + max(minimum[section], min(preferred[section], final_cap)), + ) def sizeHint(self): if self._stacked_layout.currentIndex() == _CONTENT_PAGE: @@ -275,6 +341,9 @@ def _do_rebuild_content(self, orientation: Orientation) -> None: # Remove old scroll area if self._scroll_area is not None: + viewport = self._scroll_area.viewport() + if viewport is not None: + viewport.removeEventFilter(self) self._content_page_layout.removeWidget(self._scroll_area) self._scroll_area.deleteLater() self._scroll_area = None @@ -296,6 +365,7 @@ def _do_rebuild_content(self, orientation: Orientation) -> None: ) scroll.setWidgetResizable(True) self._scroll_area = scroll + scroll.viewport().installEventFilter(self) # Content inside the scroll area scroll_content = QWidget(scroll) @@ -340,8 +410,7 @@ def _do_rebuild_content(self, orientation: Orientation) -> None: self._content_page_layout.addWidget(scroll) self._current_orientation = orientation - self._request_preferred_size() - self._schedule_section_size_update() + self._update_section_sizes() self.updateGeometry() parent = self.parentWidget() if parent is not None: @@ -374,61 +443,16 @@ def _update_horizontal_section_widths(self) -> None: self._axis_section, self._inheritance_section, ] - spacing = 3 * max(len(sections) - 1, 0) - - collapsed_total = 0 - expanded: list[CollapsibleSectionContainer] = [] - preferred: dict[CollapsibleSectionContainer, int] = {} - minimum: dict[CollapsibleSectionContainer, int] = {} - - for section in sections: - collapsed_width = section.collapsed_width_hint() - if section.isExpanded(): - expanded.append(section) - preferred[section] = max( - section.sizeHint().width(), collapsed_width - ) - minimum[section] = collapsed_width - else: - collapsed_total += collapsed_width - section.set_horizontal_section_width(collapsed_width) - - if not expanded: - return - - available = max(viewport_width - spacing - collapsed_total, 0) - min_total = sum(minimum.values()) - pref_total = sum(preferred.values()) - - if available <= min_total: - for section in expanded: - section.set_horizontal_section_width(minimum[section]) - return - - if available >= pref_total: - for section in expanded: - section.set_horizontal_section_width(preferred[section]) - return - - low = min(minimum.values()) - high = max(preferred.values()) - - for _ in range(24): - cap = (low + high) / 2 - total = sum( - max(minimum[section], min(preferred[section], int(cap))) - for section in expanded - ) - if total > available: - high = cap - else: - low = cap - - final_cap = int(low) - for section in expanded: - section.set_horizontal_section_width( - max(minimum[section], min(preferred[section], final_cap)) - ) + self._allocate_section_extent( + sections=sections, + available=viewport_width, + spacing=3 * max(len(sections) - 1, 0), + collapsed_hint=lambda section: section.collapsed_width_hint(), + preferred_hint=lambda section: section.sizeHint().width(), + apply_extent=lambda section, extent: ( + section.set_horizontal_section_width(extent) + ), + ) def _update_vertical_section_heights(self) -> None: if self._current_orientation != 'vertical': @@ -451,61 +475,16 @@ def _update_vertical_section_heights(self) -> None: self._axis_section, self._inheritance_section, ] - spacing = 3 * max(len(sections) - 1, 0) - - collapsed_total = 0 - expanded: list[CollapsibleSectionContainer] = [] - preferred: dict[CollapsibleSectionContainer, int] = {} - minimum: dict[CollapsibleSectionContainer, int] = {} - - for section in sections: - collapsed_height = section.collapsed_height_hint() - if section.isExpanded(): - expanded.append(section) - preferred[section] = max( - section.sizeHint().height(), collapsed_height - ) - minimum[section] = collapsed_height - else: - collapsed_total += collapsed_height - section.set_vertical_section_height(collapsed_height) - - if not expanded: - return - - available = max(viewport_height - spacing - collapsed_total, 0) - min_total = sum(minimum.values()) - pref_total = sum(preferred.values()) - - if available <= min_total: - for section in expanded: - section.set_vertical_section_height(minimum[section]) - return - - if available >= pref_total: - for section in expanded: - section.set_vertical_section_height(preferred[section]) - return - - low = min(minimum.values()) - high = max(preferred.values()) - - for _ in range(24): - cap = (low + high) / 2 - total = sum( - max(minimum[section], min(preferred[section], int(cap))) - for section in expanded - ) - if total > available: - high = cap - else: - low = cap - - final_cap = int(low) - for section in expanded: - section.set_vertical_section_height( - max(minimum[section], min(preferred[section], final_cap)) - ) + self._allocate_section_extent( + sections=sections, + available=viewport_height, + spacing=3 * max(len(sections) - 1, 0), + collapsed_hint=lambda section: section.collapsed_height_hint(), + preferred_hint=lambda section: section.sizeHint().height(), + apply_extent=lambda section, extent: ( + section.set_vertical_section_height(extent) + ), + ) def _detach_component_widgets(self) -> None: """Reparent all persistent component widgets back to *self*. @@ -538,7 +517,7 @@ def _build_file_section( self, 'File metadata', orientation=orientation, - on_toggle=lambda _: self._schedule_section_size_update(), + on_toggle=lambda _: self._update_section_sizes(), ) container = QWidget(self) grid = QGridLayout(container) @@ -554,7 +533,7 @@ def _build_axis_section( self, 'Axes metadata', orientation=orientation, - on_toggle=lambda _: self._schedule_section_size_update(), + on_toggle=lambda _: self._update_section_sizes(), ) container = QWidget(self) grid = QGridLayout(container) @@ -572,7 +551,7 @@ def _build_inheritance_section( orientation=orientation, on_toggle=lambda checked: ( self._axis_metadata_instance.set_checkboxes_visible(checked), - self._schedule_section_size_update(), + self._update_section_sizes(), ), ) container = QWidget(self) From 55aab97fa8bc68de9fc9606fb14b5d9a2b6ce264 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 12 Mar 2026 13:40:21 -0500 Subject: [PATCH 08/12] comment and clean _base and _containers --- src/napari_metadata/widgets/_base.py | 3 --- src/napari_metadata/widgets/_containers.py | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/napari_metadata/widgets/_base.py b/src/napari_metadata/widgets/_base.py index 84983f2..08a78aa 100644 --- a/src/napari_metadata/widgets/_base.py +++ b/src/napari_metadata/widgets/_base.py @@ -86,9 +86,6 @@ def __init__( self._component_qlabel = QLabel(self._label_text, parent=parent_widget) self._component_qlabel.setStyleSheet('font-weight: bold') - self._component_qlabel.setAlignment( - Qt.AlignmentFlag.AlignLeft - ) # this seems to do nothing self._component_qlabel.setToolTip(self._tooltip_text) @property diff --git a/src/napari_metadata/widgets/_containers.py b/src/napari_metadata/widgets/_containers.py index 6fd20f4..d5a45c6 100644 --- a/src/napari_metadata/widgets/_containers.py +++ b/src/napari_metadata/widgets/_containers.py @@ -65,8 +65,11 @@ def sizeHint(self) -> QSize: hint = widget.sizeHint() frame = 2 * self.frameWidth() if self._orientation == 'vertical': + # Minimum width (parent stretches horizontally) but preferred + # height (avoid inner scrolling when possible). min_hint = widget.minimumSizeHint() return QSize(min_hint.width() + frame, hint.height() + frame) + # Horizontal: preferred width; zero height (parent controls it). return QSize(hint.width() + frame, 0) def minimumSizeHint(self) -> QSize: From 511c955af3260a6321079f7337f6ff471e433adc Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 12 Mar 2026 13:40:53 -0500 Subject: [PATCH 09/12] clean up section extent sizing with better water fill algo --- src/napari_metadata/widgets/_main.py | 71 +++++++++++++++------------- 1 file changed, 37 insertions(+), 34 deletions(-) diff --git a/src/napari_metadata/widgets/_main.py b/src/napari_metadata/widgets/_main.py index e1b6faf..aecd802 100644 --- a/src/napari_metadata/widgets/_main.py +++ b/src/napari_metadata/widgets/_main.py @@ -44,12 +44,18 @@ from napari_metadata.widgets._inheritance import InheritanceWidget if TYPE_CHECKING: + from collections.abc import Callable + from napari.components import ViewerModel from napari.layers import Layer _CONTENT_PAGE = 0 _NO_LAYER_PAGE = 1 +#: Spacing (px) between collapsible sections inside the outer scroll area. +#: Used both in the sections QLayout and in the manual size allocator. +_SECTIONS_SPACING = 3 + class MetadataWidget(QWidget): """Top-level dock widget for viewing and editing layer metadata. @@ -172,10 +178,18 @@ def _allocate_section_extent( sections: list[CollapsibleSectionContainer], available: int, spacing: int, - collapsed_hint: callable, - preferred_hint: callable, - apply_extent: callable, + collapsed_hint: Callable[[CollapsibleSectionContainer], int], + preferred_hint: Callable[[CollapsibleSectionContainer], int], + apply_extent: Callable[[CollapsibleSectionContainer, int], None], ) -> None: + """Distribute *available* pixels among *sections*. + + Qt's layout managers cannot negotiate optimal sizes across nested + scroll-area boundaries, so we compute allocations manually: + collapsed sections get their collapsed size; expanded sections + share the remainder via water-filling (smaller preferred sizes + are satisfied first). + """ collapsed_total = 0 expanded: list[CollapsibleSectionContainer] = [] preferred: dict[CollapsibleSectionContainer, int] = {} @@ -195,39 +209,26 @@ def _allocate_section_extent( return usable = max(available - spacing - collapsed_total, 0) - min_total = sum(minimum.values()) - pref_total = sum(preferred.values()) - if usable <= min_total: + if usable <= sum(minimum.values()): for section in expanded: apply_extent(section, minimum[section]) return - if usable >= pref_total: + if usable >= sum(preferred.values()): for section in expanded: apply_extent(section, preferred[section]) return - low = min(minimum.values()) - high = max(preferred.values()) - - for _ in range(24): - cap = (low + high) / 2 - total = sum( - max(minimum[section], min(preferred[section], int(cap))) - for section in expanded - ) - if total > usable: - high = cap - else: - low = cap - - final_cap = int(low) - for section in expanded: - apply_extent( - section, - max(minimum[section], min(preferred[section], final_cap)), - ) + # Water-fill: sort by preferred ascending so smaller sections + # reach their preferred size before larger ones are capped. + by_pref = sorted(expanded, key=lambda s: preferred[s]) + remaining = usable + for i, section in enumerate(by_pref): + share = remaining // (len(by_pref) - i) + extent = max(minimum[section], min(preferred[section], share)) + apply_extent(section, extent) + remaining -= extent def sizeHint(self): if self._stacked_layout.currentIndex() == _CONTENT_PAGE: @@ -380,7 +381,7 @@ def _do_rebuild_content(self, orientation: Orientation) -> None: layout_class = QVBoxLayout if is_vertical else QHBoxLayout sections_layout = layout_class(scroll_content) sections_layout.setContentsMargins(0, 0, 0, 0) - sections_layout.setSpacing(3) + sections_layout.setSpacing(_SECTIONS_SPACING) # Build three collapsible sections self._file_section = self._build_file_section(orientation) @@ -446,7 +447,7 @@ def _update_horizontal_section_widths(self) -> None: self._allocate_section_extent( sections=sections, available=viewport_width, - spacing=3 * max(len(sections) - 1, 0), + spacing=_SECTIONS_SPACING * max(len(sections) - 1, 0), collapsed_hint=lambda section: section.collapsed_width_hint(), preferred_hint=lambda section: section.sizeHint().width(), apply_extent=lambda section, extent: ( @@ -478,7 +479,7 @@ def _update_vertical_section_heights(self) -> None: self._allocate_section_extent( sections=sections, available=viewport_height, - spacing=3 * max(len(sections) - 1, 0), + spacing=_SECTIONS_SPACING * max(len(sections) - 1, 0), collapsed_hint=lambda section: section.collapsed_height_hint(), preferred_hint=lambda section: section.sizeHint().height(), apply_extent=lambda section, extent: ( @@ -505,6 +506,11 @@ def _detach_component_widgets(self) -> None: self._inheritance_instance.setParent(self) + def _on_inheritance_toggled(self, checked: bool) -> None: + """Handle inheritance section toggle — sync checkboxes and sizes.""" + self._axis_metadata_instance.set_checkboxes_visible(checked) + self._update_section_sizes() + # ------------------------------------------------------------------ # Section builders # ------------------------------------------------------------------ @@ -549,10 +555,7 @@ def _build_inheritance_section( self, 'Axes inheritance', orientation=orientation, - on_toggle=lambda checked: ( - self._axis_metadata_instance.set_checkboxes_visible(checked), - self._update_section_sizes(), - ), + on_toggle=self._on_inheritance_toggled, ) container = QWidget(self) layout = QGridLayout(container) From 48702da1bc31bb20fe60aaa5aa76d6918a2d3f45 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Sat, 14 Mar 2026 14:33:45 -0500 Subject: [PATCH 10/12] increase test coverage of containers for orientations --- tests/widgets/test_containers.py | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/widgets/test_containers.py b/tests/widgets/test_containers.py index a236ebe..ad94c25 100644 --- a/tests/widgets/test_containers.py +++ b/tests/widgets/test_containers.py @@ -170,6 +170,17 @@ def test_horizontal_content_area_uses_wrapper_size_hint(self, qtbot): assert w._expanding_area.sizeHint().width() == expected assert w._expanding_area.sizeHint().height() == 0 + @pytest.mark.parametrize('orientation', ORIENTATIONS) + def test_content_area_falls_back_to_base_hints_without_widget( + self, qtbot, orientation + ): + w = CollapsibleSectionContainer(None, 'T', orientation) + qtbot.addWidget(w) + + assert w._expanding_area.widget() is None + assert w._expanding_area.sizeHint().isValid() + assert w._expanding_area.minimumSizeHint().isValid() + class TestScrollPolicies: def test_vertical_sections_allow_visible_vertical_scrolling(self, qtbot): @@ -205,6 +216,28 @@ def test_vertical_section_button_expands_with_parent_width(self, qtbot): assert w._button.width() >= parent.width() - 20 + def test_horizontal_width_setter_is_ignored_for_vertical_sections( + self, qtbot + ): + w = CollapsibleSectionContainer(None, 'T', 'vertical') + qtbot.addWidget(w) + + before = w.maximumWidth() + w.set_horizontal_section_width(200) + + assert w.maximumWidth() == before + + def test_vertical_height_setter_is_ignored_for_horizontal_sections( + self, qtbot + ): + w = CollapsibleSectionContainer(None, 'T', 'horizontal') + qtbot.addWidget(w) + + before = w.maximumHeight() + w.set_vertical_section_height(200) + + assert w.maximumHeight() == before + class TestRotatedButton: def test_size_hint_is_transposed(self, qtbot): @@ -224,6 +257,20 @@ def test_minimum_size_hint_equals_size_hint(self, qtbot): class TestHorizontalOnlyOuterScrollArea: + def test_resize_event_pins_child_height_to_viewport(self, qtbot): + from qtpy.QtWidgets import QWidget + + area = HorizontalOnlyOuterScrollArea() + content = QWidget() + area.setWidget(content) + area.resize(240, 160) + qtbot.addWidget(area) + + area.show() + qtbot.waitExposed(area) + + assert content.height() == area.viewport().height() + def test_wheel_event_is_ignored(self, qtbot): """Wheel event must be flagged as ignored (propagated to parent).""" from qtpy.QtCore import QPoint, QPointF, Qt From f03b413efb7a41cb309880a326ed2687c478c55a Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Sat, 14 Mar 2026 14:52:55 -0500 Subject: [PATCH 11/12] huge tests to cover resizing, yuck --- tests/widgets/test_main.py | 423 +++++++++++++++++++++++++++++++++++++ 1 file changed, 423 insertions(+) diff --git a/tests/widgets/test_main.py b/tests/widgets/test_main.py index f9840f0..ed08419 100644 --- a/tests/widgets/test_main.py +++ b/tests/widgets/test_main.py @@ -16,6 +16,8 @@ import numpy as np import pytest +from qtpy.QtCore import QEvent +from qtpy.QtGui import QResizeEvent from qtpy.QtWidgets import QFrame, QGridLayout, QVBoxLayout, QWidget from napari_metadata.widgets._main import ( @@ -122,6 +124,13 @@ def test_content_page_size_hint_is_used_when_built( assert widget.sizeHint() == widget._content_page.sizeHint() + def test_size_hints_fall_back_to_super_on_no_layer_page( + self, metadata_widget: MetadataWidget + ): + assert metadata_widget._stacked_layout.currentIndex() == _NO_LAYER_PAGE + assert metadata_widget.sizeHint().isValid() + assert metadata_widget.minimumSizeHint().isValid() + class TestPageManagement: def test_refresh_page_shows_no_layer_when_none_selected( @@ -228,6 +237,420 @@ def test_rebuild_is_reentrant_safe( widget._rebuilding = False +class _FakeSection: + def __init__(self, name: str, expanded: bool) -> None: + self.name = name + self._expanded = expanded + + def isExpanded(self) -> bool: + return self._expanded + + +class _FakeViewport: + def __init__(self, *, width: int = 0, height: int = 0) -> None: + self._width = width + self._height = height + + def width(self) -> int: + return self._width + + def height(self) -> int: + return self._height + + +class _FakeScrollArea: + def __init__(self, *, width: int = 0, height: int = 0) -> None: + self._viewport = _FakeViewport(width=width, height=height) + + def viewport(self) -> _FakeViewport: + return self._viewport + + +class TestSizingLogic: + def test_resize_event_recomputes_section_sizes( + self, viewer_with_layer, parent_widget: QWidget, qtbot, monkeypatch + ): + viewer_model, layer = viewer_with_layer + widget = MetadataWidget(viewer_model) + widget.setParent(parent_widget) + qtbot.addWidget(widget) + widget._selected_layer = layer + widget._rebuild_content('vertical') + + calls: list[str] = [] + monkeypatch.setattr( + widget, + '_update_section_sizes', + lambda: calls.append('updated'), + ) + + event = QResizeEvent(widget.size(), widget.size()) + widget.resizeEvent(event) + + assert calls == ['updated'] + + def test_event_filter_updates_only_for_scroll_viewport_events( + self, viewer_with_layer, parent_widget: QWidget, qtbot, monkeypatch + ): + viewer_model, layer = viewer_with_layer + widget = MetadataWidget(viewer_model) + widget.setParent(parent_widget) + qtbot.addWidget(widget) + widget._selected_layer = layer + widget._rebuild_content('vertical') + + assert widget._scroll_area is not None + calls: list[str] = [] + monkeypatch.setattr( + widget, + '_update_section_sizes', + lambda: calls.append('updated'), + ) + + viewport = widget._scroll_area.viewport() + widget.eventFilter(viewport, QEvent(QEvent.Type.Resize)) + widget.eventFilter(widget, QEvent(QEvent.Type.Resize)) + widget.eventFilter(viewport, QEvent(QEvent.Type.MouseButtonPress)) + widget.eventFilter(viewport, None) + + assert calls == ['updated'] + + def test_allocate_section_extent_handles_all_collapsed_sections( + self, metadata_widget: MetadataWidget + ): + sections = [_FakeSection('a', False), _FakeSection('b', False)] + applied: dict[str, int] = {} + + metadata_widget._allocate_section_extent( + sections=sections, + available=100, + spacing=4, + collapsed_hint=lambda section: 10 if section.name == 'a' else 20, + preferred_hint=lambda section: 50, + apply_extent=lambda section, extent: applied.__setitem__( + section.name, extent + ), + ) + + assert applied == {'a': 10, 'b': 20} + + def test_allocate_section_extent_uses_minimum_when_space_is_tight( + self, metadata_widget: MetadataWidget + ): + sections = [_FakeSection('a', True), _FakeSection('b', True)] + applied: dict[str, int] = {} + + metadata_widget._allocate_section_extent( + sections=sections, + available=24, + spacing=4, + collapsed_hint=lambda _section: 10, + preferred_hint=lambda section: 30 if section.name == 'a' else 40, + apply_extent=lambda section, extent: applied.__setitem__( + section.name, extent + ), + ) + + assert applied == {'a': 10, 'b': 10} + + def test_allocate_section_extent_uses_preferred_when_space_is_plentiful( + self, metadata_widget: MetadataWidget + ): + sections = [_FakeSection('a', True), _FakeSection('b', True)] + applied: dict[str, int] = {} + + metadata_widget._allocate_section_extent( + sections=sections, + available=100, + spacing=4, + collapsed_hint=lambda _section: 10, + preferred_hint=lambda section: 30 if section.name == 'a' else 40, + apply_extent=lambda section, extent: applied.__setitem__( + section.name, extent + ), + ) + + assert applied == {'a': 30, 'b': 40} + + def test_allocate_section_extent_water_fills_partial_space( + self, metadata_widget: MetadataWidget + ): + sections = [_FakeSection('a', True), _FakeSection('b', True)] + applied: dict[str, int] = {} + + metadata_widget._allocate_section_extent( + sections=sections, + available=64, + spacing=4, + collapsed_hint=lambda _section: 10, + preferred_hint=lambda section: 20 if section.name == 'a' else 50, + apply_extent=lambda section, extent: applied.__setitem__( + section.name, extent + ), + ) + + assert applied == {'a': 20, 'b': 40} + + def test_update_horizontal_section_widths_applies_allocations( + self, viewer_model: ViewerModel, parent_widget: QWidget, qtbot + ): + layer = viewer_model.add_image(np.zeros((4, 3, 2))) + widget = MetadataWidget(viewer_model) + widget.setParent(parent_widget) + qtbot.addWidget(widget) + widget._selected_layer = layer + widget._rebuild_content('horizontal') + + assert widget._file_section is not None + assert widget._axis_section is not None + assert widget._inheritance_section is not None + + widget._file_section.setExpanded(True) + widget._axis_section.setExpanded(False) + widget._inheritance_section.setExpanded(True) + widget._scroll_area.resize(420, 200) + + widget._update_horizontal_section_widths() + + assert ( + widget._file_section.width() + >= widget._file_section.collapsed_width_hint() + ) + assert ( + widget._axis_section.width() + == widget._axis_section.collapsed_width_hint() + ) + assert ( + widget._inheritance_section.width() + >= widget._inheritance_section.collapsed_width_hint() + ) + + def test_update_vertical_section_heights_applies_allocations( + self, viewer_with_layer, parent_widget: QWidget, qtbot + ): + viewer_model, layer = viewer_with_layer + widget = MetadataWidget(viewer_model) + widget.setParent(parent_widget) + qtbot.addWidget(widget) + widget._selected_layer = layer + widget._rebuild_content('vertical') + + assert widget._file_section is not None + assert widget._axis_section is not None + assert widget._inheritance_section is not None + + widget._file_section.setExpanded(True) + widget._axis_section.setExpanded(False) + widget._inheritance_section.setExpanded(True) + widget._scroll_area.resize(240, 420) + + widget._update_vertical_section_heights() + + assert ( + widget._file_section.height() + >= widget._file_section.collapsed_height_hint() + ) + assert ( + widget._axis_section.height() + == widget._axis_section.collapsed_height_hint() + ) + assert ( + widget._inheritance_section.height() + >= widget._inheritance_section.collapsed_height_hint() + ) + + def test_update_section_sizes_ignores_missing_orientation( + self, metadata_widget: MetadataWidget, monkeypatch + ): + horizontal_calls: list[str] = [] + vertical_calls: list[str] = [] + monkeypatch.setattr( + metadata_widget, + '_update_horizontal_section_widths', + lambda: horizontal_calls.append('horizontal'), + ) + monkeypatch.setattr( + metadata_widget, + '_update_vertical_section_heights', + lambda: vertical_calls.append('vertical'), + ) + + metadata_widget._current_orientation = None + metadata_widget._update_section_sizes() + + assert horizontal_calls == [] + assert vertical_calls == [] + + def test_update_horizontal_section_widths_returns_without_horizontal_orientation( + self, metadata_widget: MetadataWidget, monkeypatch + ): + calls: list[str] = [] + monkeypatch.setattr( + metadata_widget, + '_allocate_section_extent', + lambda **_: calls.append('allocated'), + ) + + metadata_widget._current_orientation = 'vertical' + metadata_widget._update_horizontal_section_widths() + + assert calls == [] + + def test_update_horizontal_section_widths_returns_without_scroll_area( + self, metadata_widget: MetadataWidget, monkeypatch + ): + calls: list[str] = [] + monkeypatch.setattr( + metadata_widget, + '_allocate_section_extent', + lambda **_: calls.append('allocated'), + ) + + metadata_widget._current_orientation = 'horizontal' + metadata_widget._scroll_area = None + metadata_widget._update_horizontal_section_widths() + + assert calls == [] + + def test_update_horizontal_section_widths_returns_without_sections( + self, metadata_widget: MetadataWidget, monkeypatch + ): + calls: list[str] = [] + monkeypatch.setattr( + metadata_widget, + '_allocate_section_extent', + lambda **_: calls.append('allocated'), + ) + + metadata_widget._current_orientation = 'horizontal' + metadata_widget._scroll_area = _FakeScrollArea(width=300) + metadata_widget._file_section = None + metadata_widget._axis_section = None + metadata_widget._inheritance_section = None + metadata_widget._update_horizontal_section_widths() + + assert calls == [] + + def test_update_horizontal_section_widths_returns_for_zero_width( + self, viewer_with_layer, parent_widget: QWidget, qtbot, monkeypatch + ): + viewer_model, layer = viewer_with_layer + widget = MetadataWidget(viewer_model) + widget.setParent(parent_widget) + qtbot.addWidget(widget) + widget._selected_layer = layer + widget._rebuild_content('horizontal') + + calls: list[str] = [] + monkeypatch.setattr( + widget, + '_allocate_section_extent', + lambda **_: calls.append('allocated'), + ) + widget._scroll_area = _FakeScrollArea(width=0) + + widget._update_horizontal_section_widths() + + assert calls == [] + + def test_update_vertical_section_heights_returns_without_vertical_orientation( + self, metadata_widget: MetadataWidget, monkeypatch + ): + calls: list[str] = [] + monkeypatch.setattr( + metadata_widget, + '_allocate_section_extent', + lambda **_: calls.append('allocated'), + ) + + metadata_widget._current_orientation = 'horizontal' + metadata_widget._update_vertical_section_heights() + + assert calls == [] + + def test_update_vertical_section_heights_returns_without_scroll_area( + self, metadata_widget: MetadataWidget, monkeypatch + ): + calls: list[str] = [] + monkeypatch.setattr( + metadata_widget, + '_allocate_section_extent', + lambda **_: calls.append('allocated'), + ) + + metadata_widget._current_orientation = 'vertical' + metadata_widget._scroll_area = None + metadata_widget._update_vertical_section_heights() + + assert calls == [] + + def test_update_vertical_section_heights_returns_without_sections( + self, metadata_widget: MetadataWidget, monkeypatch + ): + calls: list[str] = [] + monkeypatch.setattr( + metadata_widget, + '_allocate_section_extent', + lambda **_: calls.append('allocated'), + ) + + metadata_widget._current_orientation = 'vertical' + metadata_widget._scroll_area = _FakeScrollArea(height=300) + metadata_widget._file_section = None + metadata_widget._axis_section = None + metadata_widget._inheritance_section = None + metadata_widget._update_vertical_section_heights() + + assert calls == [] + + def test_update_vertical_section_heights_returns_for_zero_height( + self, viewer_with_layer, parent_widget: QWidget, qtbot, monkeypatch + ): + viewer_model, layer = viewer_with_layer + widget = MetadataWidget(viewer_model) + widget.setParent(parent_widget) + qtbot.addWidget(widget) + widget._selected_layer = layer + widget._rebuild_content('vertical') + + calls: list[str] = [] + monkeypatch.setattr( + widget, + '_allocate_section_extent', + lambda **_: calls.append('allocated'), + ) + widget._scroll_area = _FakeScrollArea(height=0) + + widget._update_vertical_section_heights() + + assert calls == [] + + def test_on_inheritance_toggled_updates_checkboxes_and_sizes( + self, viewer_with_layer, parent_widget: QWidget, qtbot, monkeypatch + ): + viewer_model, layer = viewer_with_layer + widget = MetadataWidget(viewer_model) + widget.setParent(parent_widget) + qtbot.addWidget(widget) + widget._selected_layer = layer + + calls: list[bool | str] = [] + monkeypatch.setattr( + widget._axis_metadata_instance, + 'set_checkboxes_visible', + lambda checked: calls.append(checked), + ) + monkeypatch.setattr( + widget, + '_update_section_sizes', + lambda: calls.append('updated'), + ) + + widget._on_inheritance_toggled(False) + + assert calls == [False, 'updated'] + + class TestDetachComponentWidgets: def test_detach_reparents_file_components( self, From 742bdc8401c7a668e5c7da47a95b0376c453bca4 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Sat, 14 Mar 2026 15:08:10 -0500 Subject: [PATCH 12/12] pull main size allocation into helper function to simplify tests --- src/napari_metadata/widgets/_main.py | 286 +++++++++++++---------- tests/widgets/test_main.py | 329 +++++++-------------------- 2 files changed, 240 insertions(+), 375 deletions(-) diff --git a/src/napari_metadata/widgets/_main.py b/src/napari_metadata/widgets/_main.py index e9d89ec..633dfae 100644 --- a/src/napari_metadata/widgets/_main.py +++ b/src/napari_metadata/widgets/_main.py @@ -44,8 +44,6 @@ from napari_metadata.widgets._inheritance import InheritanceWidget if TYPE_CHECKING: - from collections.abc import Callable - from napari.components import ViewerModel from napari.layers import Layer @@ -57,6 +55,67 @@ _SECTIONS_SPACING = 3 +def _allocate_section_extents( + *, + expanded: list[bool], + collapsed_extents: list[int], + preferred_extents: list[int], + available: int, + spacing: int, +) -> list[int]: + """Distribute available pixels across collapsed and expanded sections. + + Collapsed sections always keep their collapsed extent. Expanded sections + share the remaining pixels with a water-filling strategy so smaller + preferred extents are satisfied first. + """ + extents = collapsed_extents.copy() + expanded_indices = [ + index for index, is_expanded in enumerate(expanded) if is_expanded + ] + if not expanded_indices: + return extents + + collapsed_total = sum( + extent + for extent, is_expanded in zip( + collapsed_extents, expanded, strict=True + ) + if not is_expanded + ) + usable = max(available - spacing - collapsed_total, 0) + + preferred_by_index = { + index: max(preferred_extents[index], collapsed_extents[index]) + for index in expanded_indices + } + minimum_total = sum(collapsed_extents[index] for index in expanded_indices) + if usable <= minimum_total: + return extents + + preferred_total = sum( + preferred_by_index[index] for index in expanded_indices + ) + if usable >= preferred_total: + for index in expanded_indices: + extents[index] = preferred_by_index[index] + return extents + + remaining = usable + for offset, index in enumerate( + sorted(expanded_indices, key=lambda item: preferred_by_index[item]) + ): + share = remaining // (len(expanded_indices) - offset) + extent = max( + collapsed_extents[index], + min(preferred_by_index[index], share), + ) + extents[index] = extent + remaining -= extent + + return extents + + class MetadataWidget(QWidget): """Top-level dock widget for viewing and editing layer metadata. @@ -158,9 +217,13 @@ def resizeEvent(self, a0) -> None: self._update_section_sizes() def eventFilter(self, a0, a1) -> bool: + viewport = ( + self._scroll_area.viewport() + if self._scroll_area is not None + else None + ) if ( - self._scroll_area is not None - and a0 is self._scroll_area.viewport() + a0 is viewport and a1 is not None and a1.type() in ( @@ -172,64 +235,6 @@ def eventFilter(self, a0, a1) -> bool: self._update_section_sizes() return super().eventFilter(a0, a1) - def _allocate_section_extent( - self, - *, - sections: list[CollapsibleSectionContainer], - available: int, - spacing: int, - collapsed_hint: Callable[[CollapsibleSectionContainer], int], - preferred_hint: Callable[[CollapsibleSectionContainer], int], - apply_extent: Callable[[CollapsibleSectionContainer, int], None], - ) -> None: - """Distribute *available* pixels among *sections*. - - Qt's layout managers cannot negotiate optimal sizes across nested - scroll-area boundaries, so we compute allocations manually: - collapsed sections get their collapsed size; expanded sections - share the remainder via water-filling (smaller preferred sizes - are satisfied first). - """ - collapsed_total = 0 - expanded: list[CollapsibleSectionContainer] = [] - preferred: dict[CollapsibleSectionContainer, int] = {} - minimum: dict[CollapsibleSectionContainer, int] = {} - - for section in sections: - collapsed = collapsed_hint(section) - if section.isExpanded(): - expanded.append(section) - preferred[section] = max(preferred_hint(section), collapsed) - minimum[section] = collapsed - else: - collapsed_total += collapsed - apply_extent(section, collapsed) - - if not expanded: - return - - usable = max(available - spacing - collapsed_total, 0) - - if usable <= sum(minimum.values()): - for section in expanded: - apply_extent(section, minimum[section]) - return - - if usable >= sum(preferred.values()): - for section in expanded: - apply_extent(section, preferred[section]) - return - - # Water-fill: sort by preferred ascending so smaller sections - # reach their preferred size before larger ones are capped. - by_pref = sorted(expanded, key=lambda s: preferred[s]) - remaining = usable - for i, section in enumerate(by_pref): - share = remaining // (len(by_pref) - i) - extent = max(minimum[section], min(preferred[section], share)) - apply_extent(section, extent) - remaining -= extent - def sizeHint(self): if self._stacked_layout.currentIndex() == _CONTENT_PAGE: return self._content_page.sizeHint() @@ -298,6 +303,7 @@ def _get_required_orientation(self) -> Orientation: def _refresh_page(self) -> None: """Show the correct page and rebuild content if a layer is active.""" if self._selected_layer is None: + self._teardown_content() self._stacked_layout.setCurrentIndex(_NO_LAYER_PAGE) self._current_orientation = None return @@ -337,17 +343,7 @@ def _do_rebuild_content(self, orientation: Orientation) -> None: else False ) - # Detach persistent widgets so they survive container deletion - self._detach_component_widgets() - - # Remove old scroll area - if self._scroll_area is not None: - viewport = self._scroll_area.viewport() - if viewport is not None: - viewport.removeEventFilter(self) - self._content_page_layout.removeWidget(self._scroll_area) - self._scroll_area.deleteLater() - self._scroll_area = None + self._teardown_content() # Create orientation-appropriate scroll area if is_vertical: @@ -366,7 +362,9 @@ def _do_rebuild_content(self, orientation: Orientation) -> None: ) scroll.setWidgetResizable(True) self._scroll_area = scroll - scroll.viewport().installEventFilter(self) + viewport = scroll.viewport() + if viewport is not None: + viewport.installEventFilter(self) # Content inside the scroll area scroll_content = QWidget(scroll) @@ -418,75 +416,103 @@ def _do_rebuild_content(self, orientation: Orientation) -> None: parent.updateGeometry() def _update_section_sizes(self) -> None: - if self._current_orientation == 'horizontal': - self._update_horizontal_section_widths() - elif self._current_orientation == 'vertical': - self._update_vertical_section_heights() + if self._current_orientation is None: + return + self._update_section_extents(self._current_orientation) def _update_horizontal_section_widths(self) -> None: - if self._current_orientation != 'horizontal': - return - if self._scroll_area is None: - return + self._update_section_extents('horizontal') + + def _update_vertical_section_heights(self) -> None: + self._update_section_extents('vertical') + + def _update_section_extents(self, orientation: Orientation) -> None: if ( - self._file_section is None - or self._axis_section is None - or self._inheritance_section is None + self._current_orientation != orientation + or self._scroll_area is None ): return - viewport_width = self._scroll_area.viewport().width() - if viewport_width <= 0: + sections = self._get_sections() + if sections is None: return - sections = [ - self._file_section, - self._axis_section, - self._inheritance_section, - ] - self._allocate_section_extent( - sections=sections, - available=viewport_width, + viewport = self._scroll_area.viewport() + if viewport is None: + return + + if orientation == 'horizontal': + available = viewport.width() + collapsed_extents = [ + section.collapsed_width_hint() for section in sections + ] + preferred_extents = [ + section.sizeHint().width() for section in sections + ] + else: + available = viewport.height() + collapsed_extents = [ + section.collapsed_height_hint() for section in sections + ] + preferred_extents = [ + section.sizeHint().height() for section in sections + ] + + if available <= 0: + return + + extents = _allocate_section_extents( + expanded=[section.isExpanded() for section in sections], + collapsed_extents=collapsed_extents, + preferred_extents=preferred_extents, + available=available, spacing=_SECTIONS_SPACING * max(len(sections) - 1, 0), - collapsed_hint=lambda section: section.collapsed_width_hint(), - preferred_hint=lambda section: section.sizeHint().width(), - apply_extent=lambda section, extent: ( - section.set_horizontal_section_width(extent) - ), ) + for section, extent in zip(sections, extents, strict=True): + if orientation == 'horizontal': + section.set_horizontal_section_width(extent) + else: + section.set_vertical_section_height(extent) - def _update_vertical_section_heights(self) -> None: - if self._current_orientation != 'vertical': - return - if self._scroll_area is None: - return + def _get_sections( + self, + ) -> ( + tuple[ + CollapsibleSectionContainer, + CollapsibleSectionContainer, + CollapsibleSectionContainer, + ] + | None + ): if ( self._file_section is None or self._axis_section is None or self._inheritance_section is None ): - return - - viewport_height = self._scroll_area.viewport().height() - if viewport_height <= 0: - return - - sections = [ + return None + return ( self._file_section, self._axis_section, self._inheritance_section, - ] - self._allocate_section_extent( - sections=sections, - available=viewport_height, - spacing=_SECTIONS_SPACING * max(len(sections) - 1, 0), - collapsed_hint=lambda section: section.collapsed_height_hint(), - preferred_hint=lambda section: section.sizeHint().height(), - apply_extent=lambda section, extent: ( - section.set_vertical_section_height(extent) - ), ) + def _teardown_content(self) -> None: + self._detach_component_widgets() + self._remove_scroll_area() + self._file_section = None + self._axis_section = None + self._inheritance_section = None + + def _remove_scroll_area(self) -> None: + if self._scroll_area is None: + return + viewport = self._scroll_area.viewport() + if viewport is not None: + viewport.removeEventFilter(self) + self._content_page_layout.removeWidget(self._scroll_area) + self._scroll_area.deleteLater() + self._scroll_area = None + def _detach_component_widgets(self) -> None: """Reparent all persistent component widgets back to *self*. @@ -511,6 +537,10 @@ def _on_inheritance_toggled(self, checked: bool) -> None: self._axis_metadata_instance.set_checkboxes_visible(checked) self._update_section_sizes() + def _on_section_toggled(self, _checked: bool) -> None: + """Recompute section sizes after any section expands or collapses.""" + self._update_section_sizes() + # ------------------------------------------------------------------ # Section builders # ------------------------------------------------------------------ @@ -523,7 +553,7 @@ def _build_file_section( self, 'File metadata', orientation=orientation, - on_toggle=lambda _: self._update_section_sizes(), + on_toggle=self._on_section_toggled, ) container = QWidget(self) grid = QGridLayout(container) @@ -539,7 +569,7 @@ def _build_axis_section( self, 'Axes metadata', orientation=orientation, - on_toggle=lambda _: self._update_section_sizes(), + on_toggle=self._on_section_toggled, ) container = QWidget(self) grid = QGridLayout(container) @@ -733,7 +763,9 @@ def _populate_axis_grid_vertical( grid.setColumnMinimumWidth(c, 0) grid.setColumnStretch(c, 0) grid.setColumnStretch(max_cols, 1) - grid.parentWidget().updateGeometry() + parent = grid.parentWidget() + if parent is not None: + parent.updateGeometry() def _populate_axis_grid_horizontal( @@ -805,7 +837,9 @@ def _populate_axis_grid_horizontal( grid.setColumnMinimumWidth(c, 0) grid.setColumnStretch(c, 0) grid.setColumnStretch(starting_col - 2, 1) - grid.parentWidget().updateGeometry() + parent = grid.parentWidget() + if parent is not None: + parent.updateGeometry() def _add_horizontal_separator( diff --git a/tests/widgets/test_main.py b/tests/widgets/test_main.py index ed08419..ce15b00 100644 --- a/tests/widgets/test_main.py +++ b/tests/widgets/test_main.py @@ -27,6 +27,7 @@ Orientation, _add_horizontal_separator, _add_vertical_separator, + _allocate_section_extents, ) if TYPE_CHECKING: @@ -78,6 +79,65 @@ def metadata_widget( return widget +class TestAllocateSectionExtents: + def test_collapsed_sections_keep_collapsed_extents(self): + extents = _allocate_section_extents( + expanded=[False, False], + collapsed_extents=[10, 20], + preferred_extents=[50, 60], + available=100, + spacing=4, + ) + + assert extents == [10, 20] + + def test_expanded_sections_use_collapsed_extents_when_space_is_tight(self): + extents = _allocate_section_extents( + expanded=[True, True], + collapsed_extents=[10, 10], + preferred_extents=[30, 40], + available=24, + spacing=4, + ) + + assert extents == [10, 10] + + def test_expanded_sections_use_preferred_extents_when_space_is_plentiful( + self, + ): + extents = _allocate_section_extents( + expanded=[True, True], + collapsed_extents=[10, 10], + preferred_extents=[30, 40], + available=100, + spacing=4, + ) + + assert extents == [30, 40] + + def test_expanded_sections_water_fill_partial_space(self): + extents = _allocate_section_extents( + expanded=[True, True], + collapsed_extents=[10, 10], + preferred_extents=[20, 50], + available=64, + spacing=4, + ) + + assert extents == [20, 40] + + def test_preferred_extents_are_never_smaller_than_collapsed_extents(self): + extents = _allocate_section_extents( + expanded=[True, False, True], + collapsed_extents=[12, 9, 15], + preferred_extents=[8, 50, 10], + available=80, + spacing=6, + ) + + assert extents == [12, 9, 15] + + class TestMetadataWidgetInit: def test_stacked_layout_has_two_pages( self, metadata_widget: MetadataWidget @@ -159,6 +219,26 @@ def test_refresh_page_shows_content_when_layer_selected( assert widget._stacked_layout.currentIndex() == _CONTENT_PAGE assert widget._current_orientation is not None + def test_refresh_page_clears_content_when_layer_is_removed( + self, viewer_with_layer, parent_widget: QWidget, qtbot + ): + viewer_model, layer = viewer_with_layer + widget = MetadataWidget(viewer_model) + widget.setParent(parent_widget) + qtbot.addWidget(widget) + + widget._selected_layer = layer + widget._refresh_page() + assert widget._scroll_area is not None + + widget._selected_layer = None + widget._refresh_page() + + assert widget._scroll_area is None + assert widget._file_section is None + assert widget._axis_section is None + assert widget._inheritance_section is None + class TestRebuildContent: @pytest.mark.parametrize('orientation', ['vertical', 'horizontal']) @@ -237,35 +317,6 @@ def test_rebuild_is_reentrant_safe( widget._rebuilding = False -class _FakeSection: - def __init__(self, name: str, expanded: bool) -> None: - self.name = name - self._expanded = expanded - - def isExpanded(self) -> bool: - return self._expanded - - -class _FakeViewport: - def __init__(self, *, width: int = 0, height: int = 0) -> None: - self._width = width - self._height = height - - def width(self) -> int: - return self._width - - def height(self) -> int: - return self._height - - -class _FakeScrollArea: - def __init__(self, *, width: int = 0, height: int = 0) -> None: - self._viewport = _FakeViewport(width=width, height=height) - - def viewport(self) -> _FakeViewport: - return self._viewport - - class TestSizingLogic: def test_resize_event_recomputes_section_sizes( self, viewer_with_layer, parent_widget: QWidget, qtbot, monkeypatch @@ -315,82 +366,6 @@ def test_event_filter_updates_only_for_scroll_viewport_events( assert calls == ['updated'] - def test_allocate_section_extent_handles_all_collapsed_sections( - self, metadata_widget: MetadataWidget - ): - sections = [_FakeSection('a', False), _FakeSection('b', False)] - applied: dict[str, int] = {} - - metadata_widget._allocate_section_extent( - sections=sections, - available=100, - spacing=4, - collapsed_hint=lambda section: 10 if section.name == 'a' else 20, - preferred_hint=lambda section: 50, - apply_extent=lambda section, extent: applied.__setitem__( - section.name, extent - ), - ) - - assert applied == {'a': 10, 'b': 20} - - def test_allocate_section_extent_uses_minimum_when_space_is_tight( - self, metadata_widget: MetadataWidget - ): - sections = [_FakeSection('a', True), _FakeSection('b', True)] - applied: dict[str, int] = {} - - metadata_widget._allocate_section_extent( - sections=sections, - available=24, - spacing=4, - collapsed_hint=lambda _section: 10, - preferred_hint=lambda section: 30 if section.name == 'a' else 40, - apply_extent=lambda section, extent: applied.__setitem__( - section.name, extent - ), - ) - - assert applied == {'a': 10, 'b': 10} - - def test_allocate_section_extent_uses_preferred_when_space_is_plentiful( - self, metadata_widget: MetadataWidget - ): - sections = [_FakeSection('a', True), _FakeSection('b', True)] - applied: dict[str, int] = {} - - metadata_widget._allocate_section_extent( - sections=sections, - available=100, - spacing=4, - collapsed_hint=lambda _section: 10, - preferred_hint=lambda section: 30 if section.name == 'a' else 40, - apply_extent=lambda section, extent: applied.__setitem__( - section.name, extent - ), - ) - - assert applied == {'a': 30, 'b': 40} - - def test_allocate_section_extent_water_fills_partial_space( - self, metadata_widget: MetadataWidget - ): - sections = [_FakeSection('a', True), _FakeSection('b', True)] - applied: dict[str, int] = {} - - metadata_widget._allocate_section_extent( - sections=sections, - available=64, - spacing=4, - collapsed_hint=lambda _section: 10, - preferred_hint=lambda section: 20 if section.name == 'a' else 50, - apply_extent=lambda section, extent: applied.__setitem__( - section.name, extent - ), - ) - - assert applied == {'a': 20, 'b': 40} - def test_update_horizontal_section_widths_applies_allocations( self, viewer_model: ViewerModel, parent_widget: QWidget, qtbot ): @@ -481,150 +456,6 @@ def test_update_section_sizes_ignores_missing_orientation( assert horizontal_calls == [] assert vertical_calls == [] - def test_update_horizontal_section_widths_returns_without_horizontal_orientation( - self, metadata_widget: MetadataWidget, monkeypatch - ): - calls: list[str] = [] - monkeypatch.setattr( - metadata_widget, - '_allocate_section_extent', - lambda **_: calls.append('allocated'), - ) - - metadata_widget._current_orientation = 'vertical' - metadata_widget._update_horizontal_section_widths() - - assert calls == [] - - def test_update_horizontal_section_widths_returns_without_scroll_area( - self, metadata_widget: MetadataWidget, monkeypatch - ): - calls: list[str] = [] - monkeypatch.setattr( - metadata_widget, - '_allocate_section_extent', - lambda **_: calls.append('allocated'), - ) - - metadata_widget._current_orientation = 'horizontal' - metadata_widget._scroll_area = None - metadata_widget._update_horizontal_section_widths() - - assert calls == [] - - def test_update_horizontal_section_widths_returns_without_sections( - self, metadata_widget: MetadataWidget, monkeypatch - ): - calls: list[str] = [] - monkeypatch.setattr( - metadata_widget, - '_allocate_section_extent', - lambda **_: calls.append('allocated'), - ) - - metadata_widget._current_orientation = 'horizontal' - metadata_widget._scroll_area = _FakeScrollArea(width=300) - metadata_widget._file_section = None - metadata_widget._axis_section = None - metadata_widget._inheritance_section = None - metadata_widget._update_horizontal_section_widths() - - assert calls == [] - - def test_update_horizontal_section_widths_returns_for_zero_width( - self, viewer_with_layer, parent_widget: QWidget, qtbot, monkeypatch - ): - viewer_model, layer = viewer_with_layer - widget = MetadataWidget(viewer_model) - widget.setParent(parent_widget) - qtbot.addWidget(widget) - widget._selected_layer = layer - widget._rebuild_content('horizontal') - - calls: list[str] = [] - monkeypatch.setattr( - widget, - '_allocate_section_extent', - lambda **_: calls.append('allocated'), - ) - widget._scroll_area = _FakeScrollArea(width=0) - - widget._update_horizontal_section_widths() - - assert calls == [] - - def test_update_vertical_section_heights_returns_without_vertical_orientation( - self, metadata_widget: MetadataWidget, monkeypatch - ): - calls: list[str] = [] - monkeypatch.setattr( - metadata_widget, - '_allocate_section_extent', - lambda **_: calls.append('allocated'), - ) - - metadata_widget._current_orientation = 'horizontal' - metadata_widget._update_vertical_section_heights() - - assert calls == [] - - def test_update_vertical_section_heights_returns_without_scroll_area( - self, metadata_widget: MetadataWidget, monkeypatch - ): - calls: list[str] = [] - monkeypatch.setattr( - metadata_widget, - '_allocate_section_extent', - lambda **_: calls.append('allocated'), - ) - - metadata_widget._current_orientation = 'vertical' - metadata_widget._scroll_area = None - metadata_widget._update_vertical_section_heights() - - assert calls == [] - - def test_update_vertical_section_heights_returns_without_sections( - self, metadata_widget: MetadataWidget, monkeypatch - ): - calls: list[str] = [] - monkeypatch.setattr( - metadata_widget, - '_allocate_section_extent', - lambda **_: calls.append('allocated'), - ) - - metadata_widget._current_orientation = 'vertical' - metadata_widget._scroll_area = _FakeScrollArea(height=300) - metadata_widget._file_section = None - metadata_widget._axis_section = None - metadata_widget._inheritance_section = None - metadata_widget._update_vertical_section_heights() - - assert calls == [] - - def test_update_vertical_section_heights_returns_for_zero_height( - self, viewer_with_layer, parent_widget: QWidget, qtbot, monkeypatch - ): - viewer_model, layer = viewer_with_layer - widget = MetadataWidget(viewer_model) - widget.setParent(parent_widget) - qtbot.addWidget(widget) - widget._selected_layer = layer - widget._rebuild_content('vertical') - - calls: list[str] = [] - monkeypatch.setattr( - widget, - '_allocate_section_extent', - lambda **_: calls.append('allocated'), - ) - widget._scroll_area = _FakeScrollArea(height=0) - - widget._update_vertical_section_heights() - - assert calls == [] - def test_on_inheritance_toggled_updates_checkboxes_and_sizes( self, viewer_with_layer, parent_widget: QWidget, qtbot, monkeypatch ):