Coverage for src / ezqt_app / widgets / core / menu.py: 64.74%

141 statements  

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

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

2# WIDGETS.CORE.MENU - Menu container widget 

3# Project: ezqt_app 

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

5 

6"""Menu container with toggle button and expandable navigation.""" 

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 QCoreApplication, QEvent, QSize, Qt 

18from PySide6.QtGui import QCursor, QIcon 

19from PySide6.QtWidgets import QFrame, QVBoxLayout, QWidget 

20 

21# Local imports 

22from ...services.settings import get_settings_service 

23from ...services.ui import Fonts, SizePolicy 

24from ...shared.resources import Icons 

25 

26 

27# /////////////////////////////////////////////////////////////// 

28# CLASSES 

29# /////////////////////////////////////////////////////////////// 

30class Menu(QFrame): 

31 """ 

32 Menu container with expansion/reduction support. 

33 

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

38 

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. 

47 

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) 

58 

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] = [] 

65 

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) 

72 

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) 

78 

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) 

85 

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) 

91 

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) 

99 

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) 

105 

106 # ////// SETUP TOGGLE BUTTON 

107 # Lazy import to avoid circular imports 

108 from ezqt_widgets import ThemeIcon 

109 

110 from ...widgets.extended.menu_button import MenuButton 

111 

112 settings_service = get_settings_service() 

113 

114 # Store the toggle button label so retranslate_ui() can re-apply it. 

115 self._toggle_text: str = "Hide" 

116 

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) 

135 

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) 

141 

142 self._toggle_layout.addWidget(self.toggle_button) 

143 

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) 

150 

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) 

156 

157 # ////// SYNC INITIAL STATE 

158 self._sync_initial_state() 

159 

160 # /////////////////////////////////////////////////////////////// 

161 # UTILITY FUNCTIONS 

162 

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

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

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

166 

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

175 

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

184 

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) 

190 

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) 

196 

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 

201 

202 from ...widgets.extended.menu_button import MenuButton 

203 

204 current_theme = get_settings_service().gui.THEME 

205 

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) 

225 

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) 

230 

231 # Connect to the toggle button to sync state 

232 self.toggle_button.stateChanged.connect(menu.set_state) 

233 

234 self._top_menu_layout.addWidget(menu) 

235 self.menus[name] = menu 

236 

237 return menu 

238 

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) 

250 

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) 

257 

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 

263 

264 def get_shrink_width(self) -> int: 

265 """Get the configured shrink width.""" 

266 return self._shrink_width 

267 

268 def get_extended_width(self) -> int: 

269 """Get the configured extended width.""" 

270 return self._extended_width 

271 

272 

273# /////////////////////////////////////////////////////////////// 

274# PUBLIC API 

275# /////////////////////////////////////////////////////////////// 

276__all__ = ["Menu"]