Coverage for src / ezqt_widgets / widgets / misc / collapsible_section.py: 92.72%
127 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-31 10:03 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-31 10:03 +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 if self._animation is None: 227 ↛ 228line 227 didn't jump to line 228 because the condition on line 227 was never true
228 return
230 current = self._content_area.maximumHeight()
231 # Cap the current value to avoid QWIDGETSIZE_MAX as start
232 if current > 16777214:
233 current = self._get_content_height()
235 if expanding:
236 end = self._get_content_height()
237 if end == 0: 237 ↛ 242line 237 didn't jump to line 242 because the condition on line 237 was always true
238 end = 100 # Fallback height for empty content
239 else:
240 end = 0
242 self._animation.setStartValue(current)
243 self._animation.setEndValue(end)
244 self._animation.start()
246 # After expanding, release the maximum height cap
247 if expanding:
248 self._animation.finished.connect(self._on_expand_finished)
250 def _on_expand_finished(self) -> None:
251 """Release the maximumHeight cap after expand animation completes."""
252 with contextlib.suppress(RuntimeError):
253 self._animation.finished.disconnect(self._on_expand_finished) # type: ignore[union-attr]
254 self._content_area.setMaximumHeight(16777215)
256 # ///////////////////////////////////////////////////////////////
257 # PROPERTIES
258 # ///////////////////////////////////////////////////////////////
260 @property
261 def title(self) -> str:
262 """Get the header title text.
264 Returns:
265 The current title string.
266 """
267 return self._title_label.text()
269 @title.setter
270 def title(self, value: str) -> None:
271 """Set the header title text.
273 Args:
274 value: The new title string.
275 """
276 self._title_label.setText(str(value))
278 @property
279 def is_expanded(self) -> bool:
280 """Get the current expanded state.
282 Returns:
283 True if the section is expanded, False if collapsed.
284 """
285 return self._expanded
287 # ///////////////////////////////////////////////////////////////
288 # PUBLIC METHODS
289 # ///////////////////////////////////////////////////////////////
291 def setContentWidget(self, widget: QWidget) -> None:
292 """Set the widget displayed in the collapsible content area.
294 Replaces any previously set content widget. The section keeps
295 its current expanded/collapsed state.
297 Args:
298 widget: The widget to display as content.
299 """
300 # Remove previous content
301 if self._content_widget is not None:
302 self._content_layout.removeWidget(self._content_widget)
303 self._content_widget.setParent(None)
305 self._content_widget = widget
306 self._content_layout.addWidget(widget)
308 # Re-apply state to reflect new content height
309 self._apply_initial_state()
311 def expand(self) -> None:
312 """Expand the content area with animation."""
313 if self._expanded:
314 return
315 self._expanded = True
316 self._toggle_icon.setStateOpened()
317 self._run_animation(expanding=True)
318 self.expandedChanged.emit(True)
320 def collapse(self) -> None:
321 """Collapse the content area with animation."""
322 if not self._expanded:
323 return
324 self._expanded = False
325 self._toggle_icon.setStateClosed()
326 self._run_animation(expanding=False)
327 self.expandedChanged.emit(False)
329 def toggle(self) -> None:
330 """Toggle between expanded and collapsed states."""
331 if self._expanded:
332 self.collapse()
333 else:
334 self.expand()
336 def setTheme(self, theme: str) -> None:
337 """Update the toggle icon color for the given theme.
339 Can be connected directly to a theme-change signal to keep
340 the icon in sync with the application's color scheme.
342 Args:
343 theme: The new theme (``"dark"`` or ``"light"``).
344 """
345 self._toggle_icon.setTheme(theme)
347 # ///////////////////////////////////////////////////////////////
348 # STYLE METHODS
349 # ///////////////////////////////////////////////////////////////
351 def refreshStyle(self) -> None:
352 """Refresh the widget style.
354 Useful after dynamic stylesheet changes.
355 """
356 self.style().unpolish(self)
357 self.style().polish(self)
358 self.update()
361# ///////////////////////////////////////////////////////////////
362# PUBLIC API
363# ///////////////////////////////////////////////////////////////
365__all__ = ["CollapsibleSection"]