Skip to content

Commit b1fc9d6

Browse files
authored
Refactor and abstractions for main widget orientation and parts (#98)
# References and relevant issues Follow-up to #97 # Description Complete overhaul of _main.py (the MetadataWidget) inhtending to remove repetitiveness. Importantly, we remove 12 duplicated container and layout variables, as well as 6 duplicate CollapsibleSectionContainers (with no monkey patching needed). This also creates helper functions to create the grid layouts and the separators, which were repetitive before. Now, we have a `_rebuild_content(orientation)` to build the content depending on the orientaiton. Notably, the MetadataComponent's persist, but the widgets are recreated. In addition, we now track the state of the button checks to know properly on layer switches whether the container should be shown. Which allows us also to track and keep this state between orientation switches, which was not possible before. Fixes bug where in `_update_orientation` when the orientation didn't change did not reload all the metadata 😅 because it returned early. This adds great test coverage for both _main and _containers now, trying to test the real orientation switch and section state tracking.
1 parent 7a41654 commit b1fc9d6

4 files changed

Lines changed: 1679 additions & 771 deletions

File tree

Lines changed: 147 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,21 @@
1-
"""Unified collapsible section container supporting both vertical and horizontal orientations."""
1+
"""Container widgets for napari-metadata.
2+
3+
``Orientation`` — type alias used by the collapsible section and the main widget.
4+
5+
``CollapsibleSectionContainer`` — a button-gated, scrollable content area
6+
supporting both vertical and horizontal orientations.
7+
8+
``RotatedButton`` — a ``QPushButton`` that draws its label rotated 90°
9+
counterclockwise, used as the header button for horizontal sections.
10+
11+
``HorizontalOnlyOuterScrollArea`` — an outer scroll area that constrains its
12+
child to the viewport height and absorbs wheel events (so horizontal scrolling
13+
is never triggered by the mouse wheel).
14+
15+
``DisableWheelScrollingFilter`` — an event filter that swallows wheel events
16+
on a specific scrollbar, preventing accidental horizontal scroll inside a
17+
vertical ``CollapsibleSectionContainer``.
18+
"""
219

320
from __future__ import annotations
421

@@ -21,61 +38,67 @@
2138
if TYPE_CHECKING:
2239
from collections.abc import Callable
2340

24-
import napari.viewer
41+
#: Orientation of a ``CollapsibleSectionContainer`` (and the enclosing layout).
42+
#: ``'vertical'`` sections expand downward; ``'horizontal'`` sections expand
43+
#: rightward. This alias is the single source of truth for both modules.
44+
Orientation = Literal['vertical', 'horizontal']
2545

2646

2747
class CollapsibleSectionContainer(QWidget):
28-
"""A collapsible section that can be oriented vertically or horizontally.
48+
"""A titled, collapsible section that can be oriented vertically or
49+
horizontally.
50+
51+
Vertical sections expand *downward* (suitable for left/right dock areas).
52+
Horizontal sections expand *rightward* (suitable for top/bottom dock areas).
2953
3054
Parameters
3155
----------
32-
viewer : napari.viewer.Viewer
33-
The napari viewer instance.
34-
container_nake : str
35-
The name of the container.
56+
parent : QWidget
57+
Owning parent widget.
58+
title : str
59+
Text shown on the toggle button.
3660
orientation : {'vertical', 'horizontal'}
37-
The orientation of the container. Vertical containers expand downward
38-
with horizontal scrolling. Horizontal containers expand rightward with
39-
vertical scrolling.
61+
Layout direction. Defaults to ``'vertical'``.
62+
on_toggle : callable, optional
63+
Called with ``checked: bool`` whenever the section is expanded or
64+
collapsed.
4065
"""
4166

4267
def __init__(
4368
self,
44-
viewer: napari.viewer.Viewer,
45-
container_name: str,
4669
parent: QWidget,
47-
orientation: Literal['vertical', 'horizontal'] = 'vertical',
70+
title: str,
71+
orientation: Orientation = 'vertical',
4872
*,
4973
on_toggle: Callable[[bool], None] | None = None,
50-
):
74+
) -> None:
5175
super().__init__(parent=parent)
52-
self._viewer = viewer
53-
self._container_name = container_name
5476
self._on_toggle_callback = on_toggle
5577
self._orientation = orientation
56-
self._set_text = ' '
78+
self._title = title
5779

58-
# Create layout based on orientation
80+
# Outer layout — vertical stacks button above content;
81+
# horizontal places button beside content.
5982
layout_class = (
6083
QVBoxLayout if orientation == 'vertical' else QHBoxLayout
6184
)
6285
self._layout = layout_class(self)
6386
self._layout.setContentsMargins(5, 5, 5, 5)
6487
self._layout.setSpacing(4)
6588

66-
# Create button (rotated for horizontal orientation)
89+
# Toggle button rotated for horizontal sections to save vertical space.
6790
button_class = (
6891
QPushButton if orientation == 'vertical' else RotatedButton
6992
)
70-
self._button = button_class(' ')
93+
self._button: QPushButton = button_class('')
7194
font = self._button.font()
7295
font.setBold(True)
7396
self._button.setFont(font)
7497
self._button.setCheckable(True)
75-
self._button.toggled.connect(self._expanding_area_set_visible)
98+
self._button.toggled.connect(self._on_button_toggled)
7699
self._layout.addWidget(self._button, 0)
77100

78-
# Create expanding area
101+
# Expanding content area
79102
self._expanding_area = QScrollArea(self)
80103
self._expanding_area.setWidgetResizable(True)
81104

@@ -86,15 +109,12 @@ def __init__(
86109
self._expanding_area.setHorizontalScrollBarPolicy(
87110
Qt.ScrollBarPolicy.ScrollBarAsNeeded
88111
)
89-
# Disable wheel scrolling on horizontal scrollbar for vertical containers
90-
self.disable_horizontal_scrolling_wheel_filter = (
91-
DisableWheelScrollingFilter()
92-
)
112+
# Prevent the horizontal scrollbar from consuming mouse-wheel events
113+
# that should scroll the outer container.
114+
self._wheel_filter = DisableWheelScrollingFilter()
93115
h_scrollbar = self._expanding_area.horizontalScrollBar()
94116
if h_scrollbar is not None:
95-
h_scrollbar.installEventFilter(
96-
self.disable_horizontal_scrolling_wheel_filter
97-
)
117+
h_scrollbar.installEventFilter(self._wheel_filter)
98118
self._expanding_area.setSizePolicy(
99119
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
100120
)
@@ -111,128 +131,140 @@ def __init__(
111131
self._expanding_area.setFixedWidth(0)
112132

113133
self._expanding_area.setVisible(False)
134+
# Stretch factor 1 for horizontal so the content area fills available space
114135
self._layout.addWidget(
115136
self._expanding_area, 0 if orientation == 'vertical' else 1
116137
)
117138

118-
def _expanding_area_set_visible(self, checked: bool) -> None:
119-
"""Toggle the visibility of the expanding area."""
139+
# Initialise button text with the collapsed indicator.
140+
self._button.setText(f'\u25b6 {title}')
141+
142+
# ------------------------------------------------------------------
143+
# Public API
144+
# ------------------------------------------------------------------
145+
146+
def set_content_widget(self, widget: QWidget) -> None:
147+
"""Set (or replace) the widget shown inside the collapsible area.
148+
149+
The previous content widget, if any, is scheduled for deletion.
150+
For horizontal sections a wrapper with a vertical stretch is inserted
151+
automatically so the content stays top-aligned.
152+
"""
153+
old = self._expanding_area.takeWidget()
154+
if old is not None:
155+
old.deleteLater()
156+
157+
if self._orientation == 'vertical':
158+
widget.setSizePolicy(
159+
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred
160+
)
161+
self._expanding_area.setWidget(widget)
162+
else: # horizontal — wrap to keep content top-aligned
163+
wrapper = QWidget(self._expanding_area)
164+
wrapper_layout = QVBoxLayout(wrapper)
165+
wrapper_layout.setContentsMargins(0, 0, 0, 0)
166+
wrapper_layout.addWidget(widget)
167+
wrapper_layout.addStretch(1)
168+
self._expanding_area.setWidget(wrapper)
169+
170+
self._sync_size()
171+
172+
def isExpanded(self) -> bool:
173+
"""Return ``True`` if the section is currently expanded."""
174+
return self._button.isChecked()
175+
176+
def setExpanded(self, checked: bool) -> None:
177+
"""Expand or collapse the section programmatically.
178+
179+
Equivalent to clicking the toggle button; the ``on_toggle`` callback
180+
and button-text update are performed automatically.
181+
"""
182+
self._button.setChecked(checked)
183+
184+
# ------------------------------------------------------------------
185+
# Private helpers
186+
# ------------------------------------------------------------------
187+
188+
def _on_button_toggled(self, checked: bool) -> None:
189+
"""Respond to the toggle button state change."""
120190
self._expanding_area.setVisible(checked)
121-
self._sync_body_size()
191+
self._sync_size()
122192

123193
if self._on_toggle_callback is not None:
124194
self._on_toggle_callback(checked)
125195

126-
# Update button text
127-
if not checked:
128-
self._button.setText('▶ ' + self._set_text)
129-
else:
130-
self._button.setText('▼ ' + self._set_text)
196+
indicator = '\u25bc' if checked else '\u25b6'
197+
self._button.setText(f'{indicator} {self._title}')
131198

132199
self._expanding_area.updateGeometry()
133200
self.updateGeometry()
134201

135-
def _sync_body_size(self) -> None:
136-
"""Synchronize the expanding area size based on orientation."""
202+
def _sync_size(self) -> None:
203+
"""Fix the expanding area's size to match its content hint."""
137204
current_widget = self._expanding_area.widget()
138205

139-
if not self._expanding_area.isVisible() or current_widget is None:
206+
# Use the button's checked state as the authoritative "is expanded"
207+
# guard. isVisible() would return False whenever an ancestor widget
208+
# is hidden (e.g. during programmatic rebuild before the dock is
209+
# shown), causing the size to be wrongly zeroed out.
210+
if not self._button.isChecked() or current_widget is None:
140211
if self._orientation == 'vertical':
141212
self._expanding_area.setFixedHeight(0)
142213
else:
143214
self._expanding_area.setFixedWidth(0)
144215
return
145216

146217
if self._orientation == 'vertical':
147-
widget_height = current_widget.sizeHint().height()
148218
h_scrollbar = self._expanding_area.horizontalScrollBar()
149-
scroll_bar_height = (
219+
scrollbar_h = (
150220
h_scrollbar.sizeHint().height()
151221
if h_scrollbar is not None
152222
else 0
153223
)
154224
frame = 2 * self._expanding_area.frameWidth()
225+
# Activate the layout before reading sizeHint so the value is valid
226+
# even when the widget hasn't had a paint pass yet (e.g. during a
227+
# programmatic expand called from _do_rebuild_content).
228+
layout = current_widget.layout()
229+
if layout is not None:
230+
layout.activate()
155231
self._expanding_area.setFixedHeight(
156-
widget_height + scroll_bar_height + frame
232+
current_widget.sizeHint().height() + scrollbar_h + frame
157233
)
158234
else: # horizontal
159-
widget_width = current_widget.sizeHint().width()
160235
v_scrollbar = self._expanding_area.verticalScrollBar()
161-
scroll_bar_width = (
236+
scrollbar_w = (
162237
v_scrollbar.sizeHint().width()
163238
if v_scrollbar is not None
164239
else 0
165240
)
166241
frame = 2 * self._expanding_area.frameWidth()
242+
layout = current_widget.layout()
243+
if layout is not None:
244+
layout.activate()
167245
self._expanding_area.setFixedWidth(
168-
widget_width + scroll_bar_width + frame
246+
current_widget.sizeHint().width() + scrollbar_w + frame
169247
)
170248

171249
current_widget.updateGeometry()
172250
self._expanding_area.updateGeometry()
173251
self.updateGeometry()
174252

175-
def isExpanded(self) -> bool:
176-
return self._button.isChecked()
177-
178-
def onToggled(self, callback) -> None:
179-
self._button.toggled.connect(callback)
180-
181-
def _set_expanding_area_widget(self, setting_widget: QWidget) -> None:
182-
old = self._expanding_area.takeWidget()
183-
if old is not None:
184-
old.deleteLater()
185-
186-
if self._orientation == 'vertical':
187-
setting_widget.setSizePolicy(
188-
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred
189-
)
190-
self._expanding_area.setWidget(setting_widget)
191-
else: # horizontal
192-
# Horizontal containers need a wrapper with stretch
193-
wrapper = QWidget(self._expanding_area)
194-
wrapper_layout = QVBoxLayout(wrapper)
195-
wrapper_layout.setContentsMargins(0, 0, 0, 0)
196-
wrapper_layout.addWidget(setting_widget)
197-
wrapper_layout.addStretch(1)
198-
self._expanding_area.setWidget(wrapper)
199-
200-
self._sync_body_size()
201-
202-
def _set_button_text(self, button_text: str) -> None:
203-
self._set_text = button_text
204-
self._expanding_area_set_visible(False)
205-
206-
def expandedHeight(self) -> int:
207-
if self._orientation != 'vertical':
208-
return 0
209-
current_widget = self._expanding_area.widget()
210-
if not self.isExpanded() or current_widget is None:
211-
return 0
212-
return max(1, current_widget.sizeHint().height())
213-
214-
def expandedWidth(self) -> int:
215-
if self._orientation != 'horizontal':
216-
return 0
217-
current_widget = self._expanding_area.widget()
218-
if not self.isExpanded() or current_widget is None:
219-
return 0
220-
return max(1, current_widget.sizeHint().width())
221-
222253

223254
class RotatedButton(QPushButton):
224-
"""A button that renders text rotated 90 degrees counterclockwise.
255+
"""A ``QPushButton`` that renders its label rotated 90° counterclockwise.
225256
226-
Used for horizontal collapsible sections to save space.
257+
Used as the header button for horizontal ``CollapsibleSectionContainer``
258+
instances, keeping the button narrow while still readable.
227259
"""
228260

229-
def __init__(self, text, parent=None):
261+
def __init__(self, text: str, parent: QWidget | None = None) -> None:
230262
super().__init__(text, parent)
231263
self.setSizePolicy(
232264
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding
233265
)
234266

235-
def paintEvent(self, a0):
267+
def paintEvent(self, a0) -> None:
236268
painter = QStylePainter(self)
237269
painter.rotate(-90)
238270
painter.translate(-self.height(), 0)
@@ -244,26 +276,37 @@ def paintEvent(self, a0):
244276
painter.drawControl(QStyle.ControlElement.CE_PushButton, opt)
245277

246278
def sizeHint(self):
247-
size = super().sizeHint()
248-
return size.transposed()
279+
return super().sizeHint().transposed()
249280

250281
def minimumSizeHint(self):
251282
return self.sizeHint()
252283

253284

254285
class HorizontalOnlyOuterScrollArea(QScrollArea):
255-
def resizeEvent(self, a0):
286+
"""A scroll area that constrains its child height and ignores wheel events.
287+
288+
Used as the outermost scroll area in horizontal dock layouts. The child is
289+
pinned to the viewport height (no vertical scroll) and wheel events are
290+
passed to the parent so the outer container can handle them.
291+
"""
292+
293+
def resizeEvent(self, a0) -> None:
256294
super().resizeEvent(a0)
257295
w = self.widget()
258296
if w is not None:
259297
w.setFixedHeight(self.viewport().height())
260298

261-
def wheelEvent(self, a0: QWheelEvent | None):
262-
a0.ignore()
299+
def wheelEvent(self, a0: QWheelEvent | None) -> None:
300+
if a0 is not None:
301+
a0.ignore()
263302

264303

265304
class DisableWheelScrollingFilter(QObject):
266-
"""Event filter to disable mouse wheel scrolling on scroll bars."""
305+
"""Event filter that swallows wheel events on a specific scrollbar.
306+
307+
Install on a ``QScrollBar`` to prevent mouse-wheel events from
308+
accidentally scrolling that bar.
309+
"""
267310

268-
def eventFilter(self, a0, a1):
311+
def eventFilter(self, a0, a1) -> bool:
269312
return bool(a1 is not None and a1.type() == QEvent.Type.Wheel)

0 commit comments

Comments
 (0)