Coverage for src / ezqt_app / widgets / extended / menu_button.py: 80.51%

194 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-06 13:12 +0000

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

2# WIDGETS.EXTENDED.MENU_BUTTON - Animated menu button widget 

3# Project: ezqt_app 

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

5 

6"""MenuButton — expandable/shrinkable button for navigation menus.""" 

7 

8from __future__ import annotations 

9 

10# /////////////////////////////////////////////////////////////// 

11# IMPORTS 

12# /////////////////////////////////////////////////////////////// 

13# Standard library imports 

14from typing import Any 

15 

16# Third-party imports 

17from PySide6.QtCore import QEasingCurve, QPropertyAnimation, QSize, Qt, Signal 

18from PySide6.QtGui import QIcon 

19from PySide6.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QToolButton 

20 

21# Local imports 

22from ezqt_app.utils.diagnostics import warn_tech 

23from ezqt_app.utils.icon_utils import colorize_pixmap, load_icon_from_source 

24 

25 

26# /////////////////////////////////////////////////////////////// 

27# CLASSES 

28# /////////////////////////////////////////////////////////////// 

29class MenuButton(QToolButton): 

30 """ 

31 Enhanced menu button with automatic shrink/extended state management. 

32 

33 Features: 

34 - Automatic shrink/extended state management 

35 - Icon support from various sources (QIcon, path, URL, SVG) 

36 - Text visibility based on state (visible in extended, hidden in shrink) 

37 - Customizable shrink size and icon positioning 

38 - Property access to icon and text 

39 - Signals for state changes and interactions 

40 - Hover and click effects 

41 """ 

42 

43 iconChanged = Signal(QIcon) 

44 textChanged = Signal(str) 

45 stateChanged = Signal(bool) # True for extended, False for shrink 

46 

47 def __init__( 

48 self, 

49 parent: Any | None = None, 

50 icon: QIcon | str | None = None, 

51 text: str = "", 

52 icon_size: QSize | tuple[int, int] = QSize(20, 20), 

53 shrink_size: int = 60, # Will be overridden by Menu class 

54 spacing: int = 10, 

55 min_height: int | None = None, 

56 duration: int = 300, # Animation duration in milliseconds 

57 *args: Any, 

58 **kwargs: Any, 

59 ) -> None: 

60 """ 

61 Initialize the menu button. 

62 

63 Parameters 

64 ---------- 

65 parent : Any, optional 

66 The parent widget (default: None). 

67 icon : QIcon or str, optional 

68 The icon to display (default: None). 

69 text : str, optional 

70 The button text (default: ""). 

71 icon_size : QSize or tuple, optional 

72 Icon size (default: QSize(20, 20)). 

73 shrink_size : int, optional 

74 Width in shrink state (default: 60). 

75 spacing : int, optional 

76 Spacing between icon and text (default: 10). 

77 min_height : int, optional 

78 Minimum button height (default: None). 

79 duration : int, optional 

80 Animation duration in milliseconds (default: 300). 

81 *args : Any 

82 Additional positional arguments. 

83 **kwargs : Any 

84 Additional keyword arguments. 

85 """ 

86 super().__init__(parent, *args, **kwargs) 

87 self.setProperty("type", "MenuButton") 

88 

89 # ////// INITIALIZE VARIABLES 

90 self._icon_size: QSize = ( 

91 QSize(*icon_size) 

92 if isinstance(icon_size, (tuple, list)) 

93 else QSize(icon_size) 

94 ) 

95 self._shrink_size: int = shrink_size 

96 self._spacing: int = spacing 

97 self._min_height: int | None = min_height 

98 self._duration: int = duration 

99 self._current_icon: QIcon | None = None 

100 self._is_extended: bool = ( 

101 False # Start in shrink state (menu is shrinked at startup) 

102 ) 

103 

104 # ////// CALCULATE ICON POSITION 

105 self._icon_x_position = (self._shrink_size - self._icon_size.width()) // 2 

106 

107 # ////// SETUP UI COMPONENTS 

108 self._icon_label = QLabel() 

109 self._text_label = QLabel() 

110 

111 # ////// CONFIGURE ICON LABEL 

112 self._icon_label.setAlignment( 

113 Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter 

114 ) 

115 self._icon_label.setStyleSheet("background-color: transparent;") 

116 

117 # ////// CONFIGURE TEXT LABEL 

118 self._text_label.setAlignment( 

119 Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter 

120 ) 

121 self._text_label.setWordWrap(True) 

122 self._text_label.setStyleSheet("background-color: transparent;") 

123 

124 # ////// SETUP LAYOUT 

125 self._layout = QHBoxLayout(self) 

126 self._layout.setContentsMargins(0, 0, 0, 0) 

127 self._layout.setSpacing(0) 

128 self._layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) 

129 self._layout.addWidget(self._icon_label) 

130 self._layout.addWidget(self._text_label) 

131 

132 # ////// CONFIGURE SIZE POLICY 

133 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 

134 

135 # ////// SET INITIAL VALUES 

136 self._original_text = text 

137 if icon: 

138 self.icon = icon 

139 if text: 

140 self.text = text 

141 

142 # ////// INITIALIZE STATE 

143 self._update_state_display(animate=False) 

144 

145 # /////////////////////////////////////////////////////////////// 

146 # TRANSLATION FUNCTIONS 

147 

148 def _tr(self, text: str) -> str: 

149 """Shortcut for translation with global context.""" 

150 from PySide6.QtCore import QCoreApplication 

151 

152 return QCoreApplication.translate("EzQt_App", text) 

153 

154 def retranslate_ui(self) -> None: 

155 """Update button text after a language change.""" 

156 if self._original_text: 

157 self.text = self._tr(self._original_text) 

158 

159 # /////////////////////////////////////////////////////////////// 

160 # PROPERTY FUNCTIONS 

161 

162 @property 

163 def icon(self) -> QIcon | None: 

164 """Get or set the button icon.""" 

165 return self._current_icon 

166 

167 @icon.setter 

168 def icon(self, value: QIcon | str | None) -> None: 

169 """Set the button icon from various sources.""" 

170 icon = load_icon_from_source(value) 

171 if icon: 171 ↛ 177line 171 didn't jump to line 177 because the condition on line 171 was always true

172 self._current_icon = icon 

173 self._icon_label.setPixmap(icon.pixmap(self._icon_size)) 

174 self._icon_label.setFixedSize(self._icon_size) 

175 self._icon_x_position = (self._shrink_size - self._icon_size.width()) // 2 

176 self.iconChanged.emit(icon) 

177 elif value is not None: 

178 warn_tech( 

179 code="widgets.menu_button.icon_load_failed", 

180 message=f"Could not load icon from source: {value}", 

181 ) 

182 

183 @property 

184 def text(self) -> str: 

185 """Get or set the button text.""" 

186 return self._text_label.text() 

187 

188 @text.setter 

189 def text(self, value: str) -> None: 

190 """Set the button text.""" 

191 if value != self._text_label.text(): 191 ↛ exitline 191 didn't return from function 'text' because the condition on line 191 was always true

192 self._text_label.setText(str(value)) 

193 self.textChanged.emit(str(value)) 

194 

195 @property 

196 def icon_size(self) -> QSize: 

197 """Get or set the icon size.""" 

198 return self._icon_size 

199 

200 @icon_size.setter 

201 def icon_size(self, value: QSize | tuple[int, int]) -> None: 

202 """Set the icon size.""" 

203 self._icon_size = ( 

204 QSize(*value) if isinstance(value, (tuple, list)) else QSize(value) 

205 ) 

206 if self._current_icon: 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true

207 self._icon_label.setPixmap(self._current_icon.pixmap(self._icon_size)) 

208 self._icon_label.setFixedSize(self._icon_size) 

209 self._icon_x_position = (self._shrink_size - self._icon_size.width()) // 2 

210 

211 @property 

212 def shrink_size(self) -> int: 

213 """Get or set the shrink width.""" 

214 return self._shrink_size 

215 

216 @shrink_size.setter 

217 def shrink_size(self, value: int) -> None: 

218 """Set the shrink width.""" 

219 self._shrink_size = int(value) 

220 self._icon_x_position = (self._shrink_size - self._icon_size.width()) // 2 

221 self._update_state_display(animate=False) 

222 

223 @property 

224 def is_extended(self) -> bool: 

225 """Get the current state (True for extended, False for shrink).""" 

226 return self._is_extended 

227 

228 @property 

229 def spacing(self) -> int: 

230 """Get or set the spacing between icon and text.""" 

231 return self._spacing 

232 

233 @spacing.setter 

234 def spacing(self, value: int) -> None: 

235 """Set the spacing between icon and text.""" 

236 self._spacing = int(value) 

237 if self._layout: 

238 self._layout.setSpacing(self._spacing) 

239 

240 @property 

241 def min_height(self) -> int | None: 

242 """Get or set the minimum button height.""" 

243 return self._min_height 

244 

245 @min_height.setter 

246 def min_height(self, value: int | None) -> None: 

247 """Set the minimum button height.""" 

248 self._min_height = value 

249 self.updateGeometry() 

250 

251 @property 

252 def duration(self) -> int: 

253 """Get or set the animation duration in milliseconds.""" 

254 return self._duration 

255 

256 @duration.setter 

257 def duration(self, value: int) -> None: 

258 """Set the animation duration in milliseconds.""" 

259 self._duration = int(value) 

260 

261 # /////////////////////////////////////////////////////////////// 

262 # UTILITY FUNCTIONS 

263 

264 def clear_icon(self) -> None: 

265 """Remove the current icon.""" 

266 self._current_icon = None 

267 self._icon_label.clear() 

268 self.iconChanged.emit(QIcon()) 

269 

270 def clear_text(self) -> None: 

271 """Clear the button text.""" 

272 self.text = "" 

273 

274 def toggle_state(self) -> None: 

275 """Toggle the button state.""" 

276 self.set_state(not self._is_extended) 

277 

278 def set_state(self, extended: bool) -> None: 

279 """ 

280 Set the button state. 

281 

282 Parameters 

283 ---------- 

284 extended : bool 

285 True for extended, False for shrink. 

286 """ 

287 if extended != self._is_extended: 

288 self._is_extended = extended 

289 self._update_state_display() 

290 self.stateChanged.emit(extended) 

291 

292 def set_icon_color(self, color: str = "#FFFFFF", opacity: float = 0.5) -> None: 

293 """ 

294 Apply a color and opacity to the current icon. 

295 

296 Parameters 

297 ---------- 

298 color : str, optional 

299 The color to apply (default: "#FFFFFF"). 

300 opacity : float, optional 

301 The opacity to apply (default: 0.5). 

302 """ 

303 if self._current_icon: 

304 pixmap = self._current_icon.pixmap(self._icon_size) 

305 colored_pixmap = colorize_pixmap(pixmap, color, opacity) 

306 self._icon_label.setPixmap(colored_pixmap) 

307 

308 def update_theme_icon(self, theme_icon: QIcon) -> None: 

309 """ 

310 Update the icon with a theme icon. 

311 

312 Parameters 

313 ---------- 

314 theme_icon : QIcon 

315 The new theme icon. 

316 """ 

317 if theme_icon: 317 ↛ exitline 317 didn't return from function 'update_theme_icon' because the condition on line 317 was always true

318 self._icon_label.setPixmap(theme_icon.pixmap(self._icon_size)) 

319 

320 def _update_state_display(self, animate: bool = True) -> None: 

321 """ 

322 Update the display based on current state. 

323 

324 Parameters 

325 ---------- 

326 animate : bool, optional 

327 Enable animation (default: True). 

328 """ 

329 if self._is_extended: 

330 # ////// EXTENDED STATE 

331 self._text_label.show() 

332 self.setMaximumWidth(16777215) 

333 min_width = self._icon_size.width() + self._spacing + 20 

334 if self.text: 

335 min_width += self._text_label.fontMetrics().horizontalAdvance(self.text) 

336 self.setMinimumWidth(min_width) 

337 if self._layout: 337 ↛ 358line 337 didn't jump to line 358 because the condition on line 337 was always true

338 self._layout.setAlignment( 

339 Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter 

340 ) 

341 left_margin = self._icon_x_position 

342 self._layout.setContentsMargins(left_margin, 2, 8, 2) 

343 self._layout.setSpacing(self._spacing) 

344 else: 

345 # ////// SHRINK STATE 

346 self._text_label.hide() 

347 self.setMinimumWidth(self._shrink_size) 

348 self.setMaximumWidth(self._shrink_size) 

349 if self._layout: 349 ↛ 358line 349 didn't jump to line 358 because the condition on line 349 was always true

350 self._layout.setAlignment( 

351 Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter 

352 ) 

353 icon_center = self._shrink_size // 2 

354 icon_left = icon_center - (self._icon_size.width() // 2) 

355 self._layout.setContentsMargins(icon_left, 2, icon_left, 2) 

356 self._layout.setSpacing(0) 

357 

358 if animate: 

359 self._animate_state_change() 

360 

361 def _animate_state_change(self) -> None: 

362 """Animate the state change.""" 

363 if ( 

364 hasattr(self, "animation") 

365 and self.animation.state() == QPropertyAnimation.State.Running 

366 ): 

367 self.animation.stop() 

368 

369 current_rect = self.geometry() 

370 

371 if self._is_extended: 

372 icon_width = self._icon_size.width() if self._current_icon else 0 

373 text_width = 0 

374 if self.text: 

375 text_width = self._text_label.fontMetrics().horizontalAdvance(self.text) 

376 target_width = ( 

377 self._icon_x_position + icon_width + self._spacing + text_width + 8 

378 ) 

379 else: 

380 target_width = self._shrink_size 

381 

382 target_rect = current_rect 

383 target_rect.setWidth(target_width) 

384 

385 self.animation = QPropertyAnimation(self, b"geometry") 

386 self.animation.setDuration(self._duration) 

387 self.animation.setStartValue(current_rect) 

388 self.animation.setEndValue(target_rect) 

389 self.animation.setEasingCurve(QEasingCurve.Type.OutCubic) 

390 self.animation.start() 

391 

392 # /////////////////////////////////////////////////////////////// 

393 # OVERRIDE FUNCTIONS 

394 

395 def sizeHint(self) -> QSize: 

396 """Get the recommended size for the button.""" 

397 return QSize(100, 40) 

398 

399 def minimumSizeHint(self) -> QSize: 

400 """Get the minimum recommended size for the button.""" 

401 base_size = super().minimumSizeHint() 

402 

403 if self._is_extended: 

404 icon_width = self._icon_size.width() if self._current_icon else 0 

405 text_width = 0 

406 if self.text: 406 ↛ 408line 406 didn't jump to line 408 because the condition on line 406 was always true

407 text_width = self._text_label.fontMetrics().horizontalAdvance(self.text) 

408 total_width = ( 

409 self._icon_x_position + icon_width + self._spacing + text_width + 8 

410 ) 

411 else: 

412 total_width = self._shrink_size 

413 

414 min_height = ( 

415 self._min_height 

416 if self._min_height is not None 

417 else max(base_size.height(), self._icon_size.height() + 8) 

418 ) 

419 

420 return QSize(total_width, min_height) 

421 

422 # /////////////////////////////////////////////////////////////// 

423 # STYLE FUNCTIONS 

424 

425 def refresh_style(self) -> None: 

426 """Refresh the widget style.""" 

427 self.style().unpolish(self) 

428 self.style().polish(self) 

429 self.update() 

430 

431 

432# /////////////////////////////////////////////////////////////// 

433# PUBLIC API 

434# /////////////////////////////////////////////////////////////// 

435__all__ = ["MenuButton"]