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-03-26 07:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 07:07 +0000
1# ///////////////////////////////////////////////////////////////
2# WIDGETS.EXTENDED.MENU_BUTTON - Animated menu button widget
3# Project: ezqt_app
4# ///////////////////////////////////////////////////////////////
6"""MenuButton — expandable/shrinkable button for navigation menus."""
8from __future__ import annotations
10# ///////////////////////////////////////////////////////////////
11# IMPORTS
12# ///////////////////////////////////////////////////////////////
13# Standard library imports
14from typing import Any
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
21# Local imports
22from ezqt_app.utils.diagnostics import warn_tech
23from ezqt_app.utils.icon_utils import colorize_pixmap, load_icon_from_source
26# ///////////////////////////////////////////////////////////////
27# CLASSES
28# ///////////////////////////////////////////////////////////////
29class MenuButton(QToolButton):
30 """
31 Enhanced menu button with automatic shrink/extended state management.
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 """
43 iconChanged = Signal(QIcon)
44 textChanged = Signal(str)
45 stateChanged = Signal(bool) # True for extended, False for shrink
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.
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")
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 )
104 # ////// CALCULATE ICON POSITION
105 self._icon_x_position = (self._shrink_size - self._icon_size.width()) // 2
107 # ////// SETUP UI COMPONENTS
108 self._icon_label = QLabel()
109 self._text_label = QLabel()
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;")
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;")
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)
132 # ////// CONFIGURE SIZE POLICY
133 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
135 # ////// SET INITIAL VALUES
136 self._original_text = text
137 if icon:
138 self.icon = icon
139 if text:
140 self.text = text
142 # ////// INITIALIZE STATE
143 self._update_state_display(animate=False)
145 # ///////////////////////////////////////////////////////////////
146 # TRANSLATION FUNCTIONS
148 def _tr(self, text: str) -> str:
149 """Shortcut for translation with global context."""
150 from PySide6.QtCore import QCoreApplication
152 return QCoreApplication.translate("EzQt_App", text)
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)
159 # ///////////////////////////////////////////////////////////////
160 # PROPERTY FUNCTIONS
162 @property
163 def icon(self) -> QIcon | None:
164 """Get or set the button icon."""
165 return self._current_icon
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 )
183 @property
184 def text(self) -> str:
185 """Get or set the button text."""
186 return self._text_label.text()
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))
195 @property
196 def icon_size(self) -> QSize:
197 """Get or set the icon size."""
198 return self._icon_size
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
211 @property
212 def shrink_size(self) -> int:
213 """Get or set the shrink width."""
214 return self._shrink_size
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)
223 @property
224 def is_extended(self) -> bool:
225 """Get the current state (True for extended, False for shrink)."""
226 return self._is_extended
228 @property
229 def spacing(self) -> int:
230 """Get or set the spacing between icon and text."""
231 return self._spacing
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)
240 @property
241 def min_height(self) -> int | None:
242 """Get or set the minimum button height."""
243 return self._min_height
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()
251 @property
252 def duration(self) -> int:
253 """Get or set the animation duration in milliseconds."""
254 return self._duration
256 @duration.setter
257 def duration(self, value: int) -> None:
258 """Set the animation duration in milliseconds."""
259 self._duration = int(value)
261 # ///////////////////////////////////////////////////////////////
262 # UTILITY FUNCTIONS
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())
270 def clear_text(self) -> None:
271 """Clear the button text."""
272 self.text = ""
274 def toggle_state(self) -> None:
275 """Toggle the button state."""
276 self.set_state(not self._is_extended)
278 def set_state(self, extended: bool) -> None:
279 """
280 Set the button state.
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)
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.
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)
308 def update_theme_icon(self, theme_icon: QIcon) -> None:
309 """
310 Update the icon with a theme icon.
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))
320 def _update_state_display(self, animate: bool = True) -> None:
321 """
322 Update the display based on current state.
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)
358 if animate:
359 self._animate_state_change()
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()
369 current_rect = self.geometry()
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
382 target_rect = current_rect
383 target_rect.setWidth(target_width)
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()
392 # ///////////////////////////////////////////////////////////////
393 # OVERRIDE FUNCTIONS
395 def sizeHint(self) -> QSize:
396 """Get the recommended size for the button."""
397 return QSize(100, 40)
399 def minimumSizeHint(self) -> QSize:
400 """Get the minimum recommended size for the button."""
401 base_size = super().minimumSizeHint()
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
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 )
420 return QSize(total_width, min_height)
422 # ///////////////////////////////////////////////////////////////
423 # STYLE FUNCTIONS
425 def refresh_style(self) -> None:
426 """Refresh the widget style."""
427 self.style().unpolish(self)
428 self.style().polish(self)
429 self.update()
432# ///////////////////////////////////////////////////////////////
433# PUBLIC API
434# ///////////////////////////////////////////////////////////////
435__all__ = ["MenuButton"]