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

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

230 

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

235 

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 

242 

243 animation.setStartValue(current) 

244 animation.setEndValue(end) 

245 animation.start() 

246 

247 # After expanding, release the maximum height cap 

248 if expanding: 

249 animation.finished.connect(self._on_expand_finished) 

250 

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) 

259 

260 # /////////////////////////////////////////////////////////////// 

261 # PROPERTIES 

262 # /////////////////////////////////////////////////////////////// 

263 

264 @property 

265 def title(self) -> str: 

266 """Get the header title text. 

267 

268 Returns: 

269 The current title string. 

270 """ 

271 return self._title_label.text() 

272 

273 @title.setter 

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

275 """Set the header title text. 

276 

277 Args: 

278 value: The new title string. 

279 """ 

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

281 

282 @property 

283 def is_expanded(self) -> bool: 

284 """Get the current expanded state. 

285 

286 Returns: 

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

288 """ 

289 return self._expanded 

290 

291 # /////////////////////////////////////////////////////////////// 

292 # PUBLIC METHODS 

293 # /////////////////////////////////////////////////////////////// 

294 

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

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

297 

298 Replaces any previously set content widget. The section keeps 

299 its current expanded/collapsed state. 

300 

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) 

308 

309 self._content_widget = widget 

310 self._content_layout.addWidget(widget) 

311 

312 # Re-apply state to reflect new content height 

313 self._apply_initial_state() 

314 

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) 

323 

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) 

332 

333 def toggle(self) -> None: 

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

335 if self._expanded: 

336 self.collapse() 

337 else: 

338 self.expand() 

339 

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

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

342 

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

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

345 

346 Args: 

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

348 """ 

349 self._toggle_icon.setTheme(theme) 

350 

351 # /////////////////////////////////////////////////////////////// 

352 # STYLE METHODS 

353 # /////////////////////////////////////////////////////////////// 

354 

355 def refreshStyle(self) -> None: 

356 """Refresh the widget style. 

357 

358 Useful after dynamic stylesheet changes. 

359 """ 

360 self.style().unpolish(self) 

361 self.style().polish(self) 

362 self.update() 

363 

364 

365# /////////////////////////////////////////////////////////////// 

366# PUBLIC API 

367# /////////////////////////////////////////////////////////////// 

368 

369__all__ = ["CollapsibleSection"]