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

1# /////////////////////////////////////////////////////////////// 

2# COLLAPSIBLE_SECTION - Collapsible Section Widget 

3# Project: ezqt_widgets 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Collapsible section widget module. 

8 

9Provides an accordion-style section widget with a clickable header and 

10smooth expand/collapse animation using QPropertyAnimation on maximumHeight. 

11""" 

12 

13from __future__ import annotations 

14 

15# /////////////////////////////////////////////////////////////// 

16# IMPORTS 

17# /////////////////////////////////////////////////////////////// 

18# Standard library imports 

19import contextlib 

20 

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) 

37 

38# Local imports 

39from ...types import WidgetParent 

40from ..shared import ANIMATION_DURATION_FAST 

41from .toggle_icon import ToggleIcon 

42 

43# /////////////////////////////////////////////////////////////// 

44# CONSTANTS 

45# /////////////////////////////////////////////////////////////// 

46 

47_ANIMATION_DURATION: int = ANIMATION_DURATION_FAST 

48 

49# /////////////////////////////////////////////////////////////// 

50# CLASSES 

51# /////////////////////////////////////////////////////////////// 

52 

53 

54class _HeaderWidget(QWidget): 

55 """Internal clickable header for CollapsibleSection. 

56 

57 Emits a clicked signal when the user presses anywhere on the header. 

58 """ 

59 

60 clicked = Signal() 

61 

62 def __init__(self, parent: WidgetParent = None) -> None: 

63 """Initialize the header widget.""" 

64 super().__init__(parent) 

65 self.setCursor(Qt.CursorShape.PointingHandCursor) 

66 

67 def mousePressEvent(self, event: QMouseEvent) -> None: 

68 """Emit clicked on left mouse button press. 

69 

70 Args: 

71 event: The mouse event. 

72 """ 

73 if event.button() == Qt.MouseButton.LeftButton: 

74 self.clicked.emit() 

75 super().mousePressEvent(event) 

76 

77 

78class CollapsibleSection(QWidget): 

79 """Accordion-style section widget with animated expand/collapse. 

80 

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. 

84 

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 

91 

92 Args: 

93 parent: The parent widget (default: None). 

94 title: Header title text (default: ""). 

95 expanded: Initial expanded state (default: True). 

96 

97 Properties: 

98 title: Get or set the header title text. 

99 is_expanded: Get the current expanded state. 

100 

101 Signals: 

102 expandedChanged(bool): Emitted when the expanded state changes. 

103 

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 """ 

111 

112 expandedChanged = Signal(bool) 

113 

114 # /////////////////////////////////////////////////////////////// 

115 # INIT 

116 # /////////////////////////////////////////////////////////////// 

117 

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") 

128 

129 # Initialize private state 

130 self._expanded: bool = expanded 

131 self._content_widget: QWidget | None = None 

132 self._animation: QPropertyAnimation | None = None 

133 

134 # Setup UI 

135 self._setup_widget(title) 

136 self._setup_animation() 

137 

138 # Apply initial state without animation 

139 self._apply_initial_state() 

140 

141 # ------------------------------------------------ 

142 # PRIVATE METHODS 

143 # ------------------------------------------------ 

144 

145 def _setup_widget(self, title: str) -> None: 

146 """Setup the widget layout, header, and content area. 

147 

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 ) 

156 

157 self._title_label = QLabel(title) 

158 self._title_label.setSizePolicy( 

159 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed 

160 ) 

161 

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)) 

168 

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() 

175 

176 self._header.clicked.connect(self.toggle) 

177 

178 # ---- Content area ---- 

179 self._content_area = QWidget() 

180 self._content_area.setSizePolicy( 

181 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding 

182 ) 

183 

184 self._content_layout = QVBoxLayout(self._content_area) 

185 self._content_layout.setContentsMargins(0, 0, 0, 0) 

186 self._content_layout.setSpacing(0) 

187 

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) 

194 

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) 

200 

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) 

208 

209 def _get_content_height(self) -> int: 

210 """Calculate the target expanded height of the content area. 

211 

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) 

220 

221 def _run_animation(self, expanding: bool) -> None: 

222 """Run the expand or collapse animation. 

223 

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 

229 

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() 

234 

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 

241 

242 self._animation.setStartValue(current) 

243 self._animation.setEndValue(end) 

244 self._animation.start() 

245 

246 # After expanding, release the maximum height cap 

247 if expanding: 

248 self._animation.finished.connect(self._on_expand_finished) 

249 

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) 

255 

256 # /////////////////////////////////////////////////////////////// 

257 # PROPERTIES 

258 # /////////////////////////////////////////////////////////////// 

259 

260 @property 

261 def title(self) -> str: 

262 """Get the header title text. 

263 

264 Returns: 

265 The current title string. 

266 """ 

267 return self._title_label.text() 

268 

269 @title.setter 

270 def title(self, value: str) -> None: 

271 """Set the header title text. 

272 

273 Args: 

274 value: The new title string. 

275 """ 

276 self._title_label.setText(str(value)) 

277 

278 @property 

279 def is_expanded(self) -> bool: 

280 """Get the current expanded state. 

281 

282 Returns: 

283 True if the section is expanded, False if collapsed. 

284 """ 

285 return self._expanded 

286 

287 # /////////////////////////////////////////////////////////////// 

288 # PUBLIC METHODS 

289 # /////////////////////////////////////////////////////////////// 

290 

291 def setContentWidget(self, widget: QWidget) -> None: 

292 """Set the widget displayed in the collapsible content area. 

293 

294 Replaces any previously set content widget. The section keeps 

295 its current expanded/collapsed state. 

296 

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) 

304 

305 self._content_widget = widget 

306 self._content_layout.addWidget(widget) 

307 

308 # Re-apply state to reflect new content height 

309 self._apply_initial_state() 

310 

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) 

319 

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) 

328 

329 def toggle(self) -> None: 

330 """Toggle between expanded and collapsed states.""" 

331 if self._expanded: 

332 self.collapse() 

333 else: 

334 self.expand() 

335 

336 def setTheme(self, theme: str) -> None: 

337 """Update the toggle icon color for the given theme. 

338 

339 Can be connected directly to a theme-change signal to keep 

340 the icon in sync with the application's color scheme. 

341 

342 Args: 

343 theme: The new theme (``"dark"`` or ``"light"``). 

344 """ 

345 self._toggle_icon.setTheme(theme) 

346 

347 # /////////////////////////////////////////////////////////////// 

348 # STYLE METHODS 

349 # /////////////////////////////////////////////////////////////// 

350 

351 def refreshStyle(self) -> None: 

352 """Refresh the widget style. 

353 

354 Useful after dynamic stylesheet changes. 

355 """ 

356 self.style().unpolish(self) 

357 self.style().polish(self) 

358 self.update() 

359 

360 

361# /////////////////////////////////////////////////////////////// 

362# PUBLIC API 

363# /////////////////////////////////////////////////////////////// 

364 

365__all__ = ["CollapsibleSection"]