Coverage for src / ezqt_app / widgets / core / menu.py: 64.74%
141 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.CORE.MENU - Menu container widget
3# Project: ezqt_app
4# ///////////////////////////////////////////////////////////////
6"""Menu container with toggle button and expandable navigation."""
8from __future__ import annotations
10# ///////////////////////////////////////////////////////////////
11# IMPORTS
12# ///////////////////////////////////////////////////////////////
13# Standard library imports
14from typing import Any
16# Third-party imports
17from PySide6.QtCore import QCoreApplication, QEvent, QSize, Qt
18from PySide6.QtGui import QCursor, QIcon
19from PySide6.QtWidgets import QFrame, QVBoxLayout, QWidget
21# Local imports
22from ...services.settings import get_settings_service
23from ...services.ui import Fonts, SizePolicy
24from ...shared.resources import Icons
27# ///////////////////////////////////////////////////////////////
28# CLASSES
29# ///////////////////////////////////////////////////////////////
30class Menu(QFrame):
31 """
32 Menu container with expansion/reduction support.
34 This class provides a menu container with a toggle button
35 to expand or reduce the menu width. The menu contains an upper
36 section for menu items and a lower section for the toggle button.
37 """
39 def __init__(
40 self,
41 parent: QWidget | None = None,
42 shrink_width: int = 60,
43 extended_width: int = 240,
44 ) -> None:
45 """
46 Initialize the menu container.
48 Parameters
49 ----------
50 parent : QWidget, optional
51 The parent widget (default: None).
52 shrink_width : int, optional
53 Width when menu is shrunk (default: 60).
54 extended_width : int, optional
55 Width when menu is extended (default: 240).
56 """
57 super().__init__(parent)
59 # ////// STORE CONFIGURATION
60 self._shrink_width = shrink_width
61 self._extended_width = extended_width
62 self.menus: dict[str, Any] = {}
63 self._buttons: list[Any] = []
64 self._icons: list[Any | None] = []
66 # ////// SETUP WIDGET PROPERTIES
67 self.setObjectName("menu_container")
68 self.setMinimumSize(QSize(self._shrink_width, 0))
69 self.setMaximumSize(QSize(self._shrink_width, 16777215))
70 self.setFrameShape(QFrame.Shape.NoFrame)
71 self.setFrameShadow(QFrame.Shadow.Raised)
73 # ////// SETUP MAIN LAYOUT
74 self._menu_layout = QVBoxLayout(self)
75 self._menu_layout.setSpacing(0)
76 self._menu_layout.setObjectName("menu_layout")
77 self._menu_layout.setContentsMargins(0, 0, 0, 0)
79 # ////// SETUP MAIN MENU FRAME
80 self._main_menu_frame = QFrame(self)
81 self._main_menu_frame.setObjectName("main_menu_frame")
82 self._main_menu_frame.setFrameShape(QFrame.Shape.NoFrame)
83 self._main_menu_frame.setFrameShadow(QFrame.Shadow.Raised)
84 self._menu_layout.addWidget(self._main_menu_frame)
86 # ////// SETUP MAIN MENU LAYOUT
87 self._main_menu_layout = QVBoxLayout(self._main_menu_frame)
88 self._main_menu_layout.setSpacing(0)
89 self._main_menu_layout.setObjectName("main_menu_layout")
90 self._main_menu_layout.setContentsMargins(0, 0, 0, 0)
92 # ////// SETUP TOGGLE CONTAINER
93 self._toggle_container = QFrame(self._main_menu_frame)
94 self._toggle_container.setObjectName("toggle_container")
95 self._toggle_container.setMaximumSize(QSize(16777215, 45))
96 self._toggle_container.setFrameShape(QFrame.Shape.NoFrame)
97 self._toggle_container.setFrameShadow(QFrame.Shadow.Raised)
98 self._main_menu_layout.addWidget(self._toggle_container)
100 # ////// SETUP TOGGLE LAYOUT
101 self._toggle_layout = QVBoxLayout(self._toggle_container)
102 self._toggle_layout.setSpacing(0)
103 self._toggle_layout.setObjectName("toggle_layout")
104 self._toggle_layout.setContentsMargins(0, 0, 0, 0)
106 # ////// SETUP TOGGLE BUTTON
107 # Lazy import to avoid circular imports
108 from ezqt_widgets import ThemeIcon
110 from ...widgets.extended.menu_button import MenuButton
112 settings_service = get_settings_service()
114 # Store the toggle button label so retranslate_ui() can re-apply it.
115 self._toggle_text: str = "Hide"
117 self.toggle_button = MenuButton(
118 parent=self._toggle_container,
119 icon=Icons.icon_menu,
120 text=self._toggle_text,
121 shrink_size=self._shrink_width,
122 spacing=15,
123 duration=settings_service.gui.TIME_ANIMATION,
124 )
125 self.toggle_button.setObjectName("toggle_button")
126 _sp = SizePolicy.H_EXPANDING_V_FIXED
127 if _sp is not None: 127 ↛ 130line 127 didn't jump to line 130 because the condition on line 127 was always true
128 _sp.setHeightForWidth(self.toggle_button.sizePolicy().hasHeightForWidth())
129 self.toggle_button.setSizePolicy(_sp)
130 self.toggle_button.setMinimumSize(QSize(0, 45))
131 if Fonts.SEGOE_UI_10_REG is not None: 131 ↛ 133line 131 didn't jump to line 133 because the condition on line 131 was always true
132 self.toggle_button.setFont(Fonts.SEGOE_UI_10_REG)
133 self.toggle_button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
134 self.toggle_button.setLayoutDirection(Qt.LayoutDirection.LeftToRight)
136 icon_menu = ThemeIcon(Icons.icon_menu, theme=settings_service.gui.THEME)
137 self._buttons.append(self.toggle_button)
138 self._icons.append(icon_menu)
139 # Connect to the toggle_state method
140 self.toggle_button.clicked.connect(self.toggle_button.toggle_state)
142 self._toggle_layout.addWidget(self.toggle_button)
144 # ////// SETUP TOP MENU
145 self.top_menu = QFrame(self._main_menu_frame)
146 self.top_menu.setObjectName("top_menu")
147 self.top_menu.setFrameShape(QFrame.Shape.NoFrame)
148 self.top_menu.setFrameShadow(QFrame.Shadow.Raised)
149 self._main_menu_layout.addWidget(self.top_menu, 0, Qt.AlignmentFlag.AlignTop)
151 # ////// SETUP TOP MENU LAYOUT
152 self._top_menu_layout = QVBoxLayout(self.top_menu)
153 self._top_menu_layout.setSpacing(0)
154 self._top_menu_layout.setObjectName("top_menu_layout")
155 self._top_menu_layout.setContentsMargins(0, 0, 0, 0)
157 # ////// SYNC INITIAL STATE
158 self._sync_initial_state()
160 # ///////////////////////////////////////////////////////////////
161 # UTILITY FUNCTIONS
163 def _tr(self, text: str) -> str:
164 """Shortcut for translation with global context."""
165 return QCoreApplication.translate("EzQt_App", text)
167 def retranslate_ui(self) -> None:
168 """Apply current translations to all owned text labels and tooltips."""
169 # The buttons are now autonomous and know how to retranslate themselves
170 # as they store their original untranslated text.
171 if hasattr(self.toggle_button, "retranslate_ui"):
172 self.toggle_button.retranslate_ui()
173 # Dynamic tooltip for the toggle button
174 self.toggle_button.setToolTip(self._tr(self._toggle_text))
176 for menu_btn in self.menus.values():
177 if hasattr(menu_btn, "retranslate_ui"):
178 menu_btn.retranslate_ui()
179 # Dynamic tooltip: very useful when the menu is shrunk
180 # menu_btn._original_text contains the key (e.g. "Home")
181 original_text = getattr(menu_btn, "_original_text", "")
182 if original_text:
183 menu_btn.setToolTip(self._tr(original_text))
185 def changeEvent(self, event: QEvent) -> None:
186 """Handle Qt change events, triggering UI retranslation on language change."""
187 if event.type() == QEvent.Type.LanguageChange:
188 self.retranslate_ui()
189 super().changeEvent(event)
191 def _sync_initial_state(self) -> None:
192 """Sync the initial state of all buttons with the container state."""
193 if hasattr(self, "toggle_button"): 193 ↛ exitline 193 didn't return from function '_sync_initial_state' because the condition on line 193 was always true
194 self.toggle_button.set_state(False)
195 self.sync_all_menu_states(False)
197 def add_menu(self, name: str, icon: QIcon | str | None = None):
198 """Add a menu item to the container."""
199 # Lazy import to avoid circular imports
200 from ezqt_widgets import ThemeIcon
202 from ...widgets.extended.menu_button import MenuButton
204 current_theme = get_settings_service().gui.THEME
206 menu = MenuButton(
207 parent=self.top_menu,
208 icon=icon,
209 text=name,
210 shrink_size=self._shrink_width,
211 spacing=15,
212 duration=get_settings_service().gui.TIME_ANIMATION,
213 )
214 menu.setObjectName(f"menu_{name}")
215 menu.setProperty("class", "inactive")
216 _sp = SizePolicy.H_EXPANDING_V_FIXED
217 if _sp is not None:
218 _sp.setHeightForWidth(menu.sizePolicy().hasHeightForWidth())
219 menu.setSizePolicy(_sp)
220 menu.setMinimumSize(QSize(0, 45))
221 if Fonts.SEGOE_UI_10_REG is not None:
222 menu.setFont(Fonts.SEGOE_UI_10_REG)
223 menu.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
224 menu.setLayoutDirection(Qt.LayoutDirection.LeftToRight)
226 # ////// SETUP THEME ICON
227 theme_icon = ThemeIcon(icon, theme=current_theme) if icon is not None else None
228 self._buttons.append(menu)
229 self._icons.append(theme_icon)
231 # Connect to the toggle button to sync state
232 self.toggle_button.stateChanged.connect(menu.set_state)
234 self._top_menu_layout.addWidget(menu)
235 self.menus[name] = menu
237 return menu
239 def update_all_theme_icons(self) -> None:
240 """Update theme icons for all buttons."""
241 current_theme = get_settings_service().gui.THEME
242 for i, btn in enumerate(self._buttons):
243 icon = self._icons[i]
244 setter = getattr(icon, "setTheme", None)
245 if callable(setter): 245 ↛ 247line 245 didn't jump to line 247 because the condition on line 245 was always true
246 setter(current_theme)
247 updater = getattr(btn, "update_theme_icon", None)
248 if icon is not None and callable(updater): 248 ↛ 242line 248 didn't jump to line 242 because the condition on line 248 was always true
249 updater(icon)
251 def sync_all_menu_states(self, extended: bool) -> None:
252 """Sync all menu buttons to the given state."""
253 for btn in self._buttons:
254 setter = getattr(btn, "set_state", None)
255 if btn != self.toggle_button and callable(setter): 255 ↛ 256line 255 didn't jump to line 256 because the condition on line 255 was never true
256 setter(extended)
258 def get_menu_state(self) -> bool:
259 """Get the current menu state."""
260 if hasattr(self, "toggle_button"):
261 return self.toggle_button.is_extended
262 return False
264 def get_shrink_width(self) -> int:
265 """Get the configured shrink width."""
266 return self._shrink_width
268 def get_extended_width(self) -> int:
269 """Get the configured extended width."""
270 return self._extended_width
273# ///////////////////////////////////////////////////////////////
274# PUBLIC API
275# ///////////////////////////////////////////////////////////////
276__all__ = ["Menu"]