Coverage for src / ezqt_widgets / widgets / misc / collapsible_section.py: 89.81%
131 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-01 22:46 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-01 22:46 +0000
1# ///////////////////////////////////////////////////////////////
2# COLLAPSIBLE_SECTION - Collapsible Section Widget
3# Project: ezqt_widgets
4# ///////////////////////////////////////////////////////////////
6"""
7Collapsible section widget module.
9Provides an accordion-style section widget with a clickable header and
10smooth expand/collapse animation using QPropertyAnimation on maximumHeight.
11"""
13from __future__ import annotations
15# ///////////////////////////////////////////////////////////////
16# IMPORTS
17# ///////////////////////////////////////////////////////////////
18# Standard library imports
19import contextlib
21# Third-party imports
22from PySide6.QtCore import (
23 QEasingCurve,
24 QPropertyAnimation,
25 QSize,
26 Qt,
27 Signal,
28)
29from PySide6.QtGui import QMouseEvent
30from PySide6.QtWidgets import (
31 QHBoxLayout,
32 QLabel,
33 QSizePolicy,
34 QVBoxLayout,
35 QWidget,
36)
38# Local imports
39from ...types import WidgetParent
40from ..shared import ANIMATION_DURATION_FAST
41from .toggle_icon import ToggleIcon
43# ///////////////////////////////////////////////////////////////
44# CONSTANTS
45# ///////////////////////////////////////////////////////////////
47_ANIMATION_DURATION: int = ANIMATION_DURATION_FAST
49# ///////////////////////////////////////////////////////////////
50# CLASSES
51# ///////////////////////////////////////////////////////////////
54class _HeaderWidget(QWidget):
55 """Internal clickable header for CollapsibleSection.
57 Emits a clicked signal when the user presses anywhere on the header.
58 """
60 clicked = Signal()
62 def __init__(self, parent: WidgetParent = None) -> None:
63 """Initialize the header widget."""
64 super().__init__(parent)
65 self.setCursor(Qt.CursorShape.PointingHandCursor)
67 def mousePressEvent(self, event: QMouseEvent) -> None:
68 """Emit clicked on left mouse button press.
70 Args:
71 event: The mouse event.
72 """
73 if event.button() == Qt.MouseButton.LeftButton:
74 self.clicked.emit()
75 super().mousePressEvent(event)
78class CollapsibleSection(QWidget):
79 """Accordion-style section widget with animated expand/collapse.
81 The header is always visible. Clicking anywhere on the header (or
82 calling toggle()) animates the content area between 0 height and its
83 natural size hint height.
85 Features:
86 - Clickable header with title label and ToggleIcon chevron
87 - Smooth height animation via QPropertyAnimation on maximumHeight
88 - Supports an arbitrary QWidget as content via setContentWidget()
89 - expand()/collapse()/toggle() public API
90 - Theme propagation to the ToggleIcon chevron
92 Args:
93 parent: The parent widget (default: None).
94 title: Header title text (default: "").
95 expanded: Initial expanded state (default: True).
97 Properties:
98 title: Get or set the header title text.
99 is_expanded: Get the current expanded state.
101 Signals:
102 expandedChanged(bool): Emitted when the expanded state changes.
104 Example:
105 >>> from ezqt_widgets import CollapsibleSection
106 >>> section = CollapsibleSection(title="Settings", expanded=False)
107 >>> section.setContentWidget(my_form_widget)
108 >>> section.expandedChanged.connect(lambda e: print(f"Expanded: {e}"))
109 >>> section.show()
110 """
112 expandedChanged = Signal(bool)
114 # ///////////////////////////////////////////////////////////////
115 # INIT
116 # ///////////////////////////////////////////////////////////////
118 def __init__(
119 self,
120 parent: WidgetParent = None,
121 *,
122 title: str = "",
123 expanded: bool = True,
124 ) -> None:
125 """Initialize the collapsible section."""
126 super().__init__(parent)
127 self.setProperty("type", "CollapsibleSection")
129 # Initialize private state
130 self._expanded: bool = expanded
131 self._content_widget: QWidget | None = None
132 self._animation: QPropertyAnimation | None = None
134 # Setup UI
135 self._setup_widget(title)
136 self._setup_animation()
138 # Apply initial state without animation
139 self._apply_initial_state()
141 # ------------------------------------------------
142 # PRIVATE METHODS
143 # ------------------------------------------------
145 def _setup_widget(self, title: str) -> None:
146 """Setup the widget layout, header, and content area.
148 Args:
149 title: Initial header title text.
150 """
151 # ---- Header ----
152 self._header = _HeaderWidget(self)
153 self._header.setSizePolicy(
154 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
155 )
157 self._title_label = QLabel(title)
158 self._title_label.setSizePolicy(
159 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
160 )
162 # ToggleIcon acts as chevron: "opened" = expanded, "closed" = collapsed
163 self._toggle_icon = ToggleIcon(
164 icon_size=14,
165 initial_state="opened" if self._expanded else "closed",
166 )
167 self._toggle_icon.setFixedSize(QSize(20, 20))
169 header_layout = QHBoxLayout(self._header)
170 header_layout.setContentsMargins(8, 6, 8, 6)
171 header_layout.setSpacing(6)
172 header_layout.addWidget(self._toggle_icon)
173 header_layout.addWidget(self._title_label)
174 header_layout.addStretch()
176 self._header.clicked.connect(self.toggle)
178 # ---- Content area ----
179 self._content_area = QWidget()
180 self._content_area.setSizePolicy(
181 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
182 )
184 self._content_layout = QVBoxLayout(self._content_area)
185 self._content_layout.setContentsMargins(0, 0, 0, 0)
186 self._content_layout.setSpacing(0)
188 # ---- Main layout ----
189 main_layout = QVBoxLayout(self)
190 main_layout.setContentsMargins(0, 0, 0, 0)
191 main_layout.setSpacing(0)
192 main_layout.addWidget(self._header)
193 main_layout.addWidget(self._content_area)
195 def _setup_animation(self) -> None:
196 """Setup the QPropertyAnimation on maximumHeight of the content area."""
197 self._animation = QPropertyAnimation(self._content_area, b"maximumHeight")
198 self._animation.setDuration(_ANIMATION_DURATION)
199 self._animation.setEasingCurve(QEasingCurve.Type.InOutCubic)
201 def _apply_initial_state(self) -> None:
202 """Apply the initial expanded/collapsed state without animation."""
203 if self._expanded:
204 # Allow natural height
205 self._content_area.setMaximumHeight(16777215) # Qt QWIDGETSIZE_MAX
206 else:
207 self._content_area.setMaximumHeight(0)
209 def _get_content_height(self) -> int:
210 """Calculate the target expanded height of the content area.
212 Returns:
213 The content area's size hint height, minimum 0.
214 """
215 if self._content_widget is not None:
216 hint = self._content_widget.sizeHint().height()
217 else:
218 hint = self._content_area.sizeHint().height()
219 return max(0, hint)
221 def _run_animation(self, expanding: bool) -> None:
222 """Run the expand or collapse animation.
224 Args:
225 expanding: True to expand, False to collapse.
226 """
227 animation = self._animation
228 if animation is None: 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true
229 return
231 current = self._content_area.maximumHeight()
232 # Cap the current value to avoid QWIDGETSIZE_MAX as start
233 if current > 16777214:
234 current = self._get_content_height()
236 if expanding:
237 end = self._get_content_height()
238 if end == 0: 238 ↛ 243line 238 didn't jump to line 243 because the condition on line 238 was always true
239 end = 100 # Fallback height for empty content
240 else:
241 end = 0
243 animation.setStartValue(current)
244 animation.setEndValue(end)
245 animation.start()
247 # After expanding, release the maximum height cap
248 if expanding:
249 animation.finished.connect(self._on_expand_finished)
251 def _on_expand_finished(self) -> None:
252 """Release the maximumHeight cap after expand animation completes."""
253 animation = self._animation
254 if animation is None:
255 return
256 with contextlib.suppress(RuntimeError):
257 animation.finished.disconnect(self._on_expand_finished)
258 self._content_area.setMaximumHeight(16777215)
260 # ///////////////////////////////////////////////////////////////
261 # PROPERTIES
262 # ///////////////////////////////////////////////////////////////
264 @property
265 def title(self) -> str:
266 """Get the header title text.
268 Returns:
269 The current title string.
270 """
271 return self._title_label.text()
273 @title.setter
274 def title(self, value: str) -> None:
275 """Set the header title text.
277 Args:
278 value: The new title string.
279 """
280 self._title_label.setText(str(value))
282 @property
283 def is_expanded(self) -> bool:
284 """Get the current expanded state.
286 Returns:
287 True if the section is expanded, False if collapsed.
288 """
289 return self._expanded
291 # ///////////////////////////////////////////////////////////////
292 # PUBLIC METHODS
293 # ///////////////////////////////////////////////////////////////
295 def setContentWidget(self, widget: QWidget) -> None:
296 """Set the widget displayed in the collapsible content area.
298 Replaces any previously set content widget. The section keeps
299 its current expanded/collapsed state.
301 Args:
302 widget: The widget to display as content.
303 """
304 # Remove previous content
305 if self._content_widget is not None:
306 self._content_layout.removeWidget(self._content_widget)
307 self._content_widget.setParent(None)
309 self._content_widget = widget
310 self._content_layout.addWidget(widget)
312 # Re-apply state to reflect new content height
313 self._apply_initial_state()
315 def expand(self) -> None:
316 """Expand the content area with animation."""
317 if self._expanded:
318 return
319 self._expanded = True
320 self._toggle_icon.setStateOpened()
321 self._run_animation(expanding=True)
322 self.expandedChanged.emit(True)
324 def collapse(self) -> None:
325 """Collapse the content area with animation."""
326 if not self._expanded:
327 return
328 self._expanded = False
329 self._toggle_icon.setStateClosed()
330 self._run_animation(expanding=False)
331 self.expandedChanged.emit(False)
333 def toggle(self) -> None:
334 """Toggle between expanded and collapsed states."""
335 if self._expanded:
336 self.collapse()
337 else:
338 self.expand()
340 def setTheme(self, theme: str) -> None:
341 """Update the toggle icon color for the given theme.
343 Can be connected directly to a theme-change signal to keep
344 the icon in sync with the application's color scheme.
346 Args:
347 theme: The new theme (``"dark"`` or ``"light"``).
348 """
349 self._toggle_icon.setTheme(theme)
351 # ///////////////////////////////////////////////////////////////
352 # STYLE METHODS
353 # ///////////////////////////////////////////////////////////////
355 def refreshStyle(self) -> None:
356 """Refresh the widget style.
358 Useful after dynamic stylesheet changes.
359 """
360 self.style().unpolish(self)
361 self.style().polish(self)
362 self.update()
365# ///////////////////////////////////////////////////////////////
366# PUBLIC API
367# ///////////////////////////////////////////////////////////////
369__all__ = ["CollapsibleSection"]