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
320from __future__ import annotations
421
2138if 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
2747class 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
223254class 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
254285class 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
265304class 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