Coverage for src / ezqt_app / widgets / core / header.py: 82.90%

177 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-26 07:07 +0000

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

2# WIDGETS.CORE.HEADER - Application header widget 

3# Project: ezqt_app 

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

5 

6"""Header widget with logo, application name, and window control buttons.""" 

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, QMargins, QRect, QSize, Qt 

18from PySide6.QtGui import QCursor, QPixmap 

19from PySide6.QtWidgets import ( 

20 QFrame, 

21 QHBoxLayout, 

22 QLabel, 

23 QPushButton, 

24 QSizePolicy, 

25 QSpacerItem, 

26 QWidget, 

27) 

28 

29# Local imports 

30from ...services.settings import get_settings_service 

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

32from ...shared.resources import Icons 

33 

34# /////////////////////////////////////////////////////////////// 

35# CLASSES 

36# /////////////////////////////////////////////////////////////// 

37 

38 

39class Header(QFrame): 

40 """ 

41 Application header with logo, name and control buttons. 

42 

43 This class provides a customizable header bar with 

44 the application logo, its name, description and window 

45 control buttons (minimize, maximize, close). 

46 """ 

47 

48 # /////////////////////////////////////////////////////////////// 

49 # INIT 

50 # /////////////////////////////////////////////////////////////// 

51 

52 def __init__( 

53 self, 

54 app_name: str = "", 

55 description: str = "", 

56 parent: QWidget | None = None, 

57 *args: Any, 

58 **kwargs: Any, 

59 ) -> None: 

60 """ 

61 Initialize the application header. 

62 

63 Args: 

64 app_name: Application name (default: ""). 

65 description: Application description (default: ""). 

66 parent: The parent widget (default: None). 

67 *args: Additional positional arguments. 

68 **kwargs: Additional keyword arguments. 

69 """ 

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

71 self._buttons: list[QPushButton] = [] 

72 self._icons: list[Any] = [] 

73 

74 # Store originals for retranslation 

75 self._app_name: str = app_name 

76 self._description: str = description 

77 

78 # Widget properties 

79 self.setObjectName("header_container") 

80 self.setFixedHeight(50) 

81 self.setFrameShape(QFrame.Shape.NoFrame) 

82 self.setFrameShadow(QFrame.Shadow.Raised) 

83 

84 # Size policy initialization 

85 if ( 85 ↛ 94line 85 didn't jump to line 94 because the condition on line 85 was always true

86 hasattr(SizePolicy, "H_EXPANDING_V_PREFERRED") 

87 and SizePolicy.H_EXPANDING_V_PREFERRED is not None 

88 ): 

89 self.setSizePolicy(SizePolicy.H_EXPANDING_V_PREFERRED) 

90 SizePolicy.H_EXPANDING_V_PREFERRED.setHeightForWidth( 

91 self.sizePolicy().hasHeightForWidth() 

92 ) 

93 else: 

94 default_policy = QSizePolicy( 

95 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred 

96 ) 

97 default_policy.setHorizontalStretch(0) 

98 default_policy.setVerticalStretch(0) 

99 self.setSizePolicy(default_policy) 

100 

101 # Main layout 

102 self._layout = QHBoxLayout(self) 

103 self._layout.setSpacing(0) 

104 self._layout.setObjectName("header_layout") 

105 self._layout.setContentsMargins(0, 0, 10, 0) 

106 

107 # Meta info section 

108 self._info_frame = QFrame(self) 

109 self._info_frame.setObjectName("info_frame") 

110 self._info_frame.setMinimumSize(QSize(0, 50)) 

111 self._info_frame.setMaximumSize(QSize(16777215, 50)) 

112 self._info_frame.setFrameShape(QFrame.Shape.NoFrame) 

113 self._info_frame.setFrameShadow(QFrame.Shadow.Raised) 

114 self._layout.addWidget(self._info_frame) 

115 

116 # App logo 

117 self._logo_label = QLabel(self._info_frame) 

118 self._logo_label.setObjectName("app_logo") 

119 self._logo_label.setGeometry(QRect(10, 4, 40, 40)) 

120 self._logo_label.setMinimumSize(QSize(40, 40)) 

121 self._logo_label.setMaximumSize(QSize(40, 40)) 

122 self._logo_label.setFrameShape(QFrame.Shape.NoFrame) 

123 self._logo_label.setFrameShadow(QFrame.Shadow.Raised) 

124 

125 # App title 

126 self._title_label = QLabel(app_name, self._info_frame) 

127 self._title_label.setObjectName("app_title") 

128 self._title_label.setGeometry(QRect(65, 6, 160, 20)) 

129 

130 if hasattr(Fonts, "SEGOE_UI_12_SB") and Fonts.SEGOE_UI_12_SB is not None: 130 ↛ 133line 130 didn't jump to line 133 because the condition on line 130 was always true

131 self._title_label.setFont(Fonts.SEGOE_UI_12_SB) 

132 else: 

133 try: 

134 from PySide6.QtGui import QFont 

135 

136 default_font = QFont() 

137 default_font.setFamily("Segoe UI") 

138 default_font.setPointSize(12) 

139 self._title_label.setFont(default_font) 

140 except ImportError: 

141 pass 

142 

143 self._title_label.setAlignment( 

144 Qt.AlignmentFlag.AlignLeading 

145 | Qt.AlignmentFlag.AlignLeft 

146 | Qt.AlignmentFlag.AlignTop 

147 ) 

148 

149 # App subtitle 

150 self._subtitle_label = QLabel(description, self._info_frame) 

151 self._subtitle_label.setObjectName("app_subtitle") 

152 self._subtitle_label.setGeometry(QRect(65, 26, 16777215, 16)) 

153 self._subtitle_label.setMaximumSize(QSize(16777215, 16)) 

154 

155 if hasattr(Fonts, "SEGOE_UI_8_REG") and Fonts.SEGOE_UI_8_REG is not None: 155 ↛ 158line 155 didn't jump to line 158 because the condition on line 155 was always true

156 self._subtitle_label.setFont(Fonts.SEGOE_UI_8_REG) 

157 else: 

158 try: 

159 from PySide6.QtGui import QFont 

160 

161 default_font = QFont() 

162 default_font.setFamily("Segoe UI") 

163 default_font.setPointSize(8) 

164 self._subtitle_label.setFont(default_font) 

165 except ImportError: 

166 pass 

167 

168 self._subtitle_label.setAlignment( 

169 Qt.AlignmentFlag.AlignLeading 

170 | Qt.AlignmentFlag.AlignLeft 

171 | Qt.AlignmentFlag.AlignTop 

172 ) 

173 

174 # Spacer 

175 self._spacer = QSpacerItem( 

176 20, 20, QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred 

177 ) 

178 self._layout.addItem(self._spacer) 

179 

180 # Buttons frame 

181 self._buttons_frame = QFrame(self) 

182 self._buttons_frame.setObjectName("buttons_frame") 

183 self._buttons_frame.setMinimumSize(QSize(0, 28)) 

184 self._buttons_frame.setFrameShape(QFrame.Shape.NoFrame) 

185 self._buttons_frame.setFrameShadow(QFrame.Shadow.Raised) 

186 self._layout.addWidget(self._buttons_frame, 0, Qt.AlignmentFlag.AlignRight) 

187 

188 self._buttons_layout = QHBoxLayout(self._buttons_frame) 

189 self._buttons_layout.setSpacing(5) 

190 self._buttons_layout.setObjectName("buttons_layout") 

191 self._buttons_layout.setContentsMargins(0, 0, 0, 0) 

192 

193 # Theme buttons 

194 from ezqt_widgets import ThemeIcon 

195 

196 current_theme = get_settings_service().gui.THEME 

197 

198 # Settings button 

199 self.settings_btn = QPushButton(self._buttons_frame) 

200 self._buttons.append(self.settings_btn) 

201 self.settings_btn.setObjectName("settings_btn") 

202 self.settings_btn.setMinimumSize(QSize(28, 28)) 

203 self.settings_btn.setMaximumSize(QSize(28, 28)) 

204 self.settings_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) 

205 

206 icon_settings = ThemeIcon(Icons.icon_settings, theme=current_theme) 

207 self._icons.append(icon_settings) 

208 self.settings_btn.setIcon(icon_settings) 

209 self.settings_btn.setIconSize(QSize(20, 20)) 

210 self._buttons_layout.addWidget(self.settings_btn) 

211 

212 # Minimize button 

213 self.minimize_btn = QPushButton(self._buttons_frame) 

214 self._buttons.append(self.minimize_btn) 

215 self.minimize_btn.setObjectName("minimize_btn") 

216 self.minimize_btn.setMinimumSize(QSize(28, 28)) 

217 self.minimize_btn.setMaximumSize(QSize(28, 28)) 

218 self.minimize_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) 

219 

220 icon_minimize = ThemeIcon(Icons.icon_minimize, theme=current_theme) 

221 self._icons.append(icon_minimize) 

222 self.minimize_btn.setIcon(icon_minimize) 

223 self.minimize_btn.setIconSize(QSize(20, 20)) 

224 self._buttons_layout.addWidget(self.minimize_btn) 

225 

226 # Maximize button 

227 self.maximize_restore_btn = QPushButton(self._buttons_frame) 

228 self._buttons.append(self.maximize_restore_btn) 

229 self.maximize_restore_btn.setObjectName("maximize_restore_btn") 

230 self.maximize_restore_btn.setMinimumSize(QSize(28, 28)) 

231 self.maximize_restore_btn.setMaximumSize(QSize(28, 28)) 

232 

233 if hasattr(Fonts, "SEGOE_UI_10_REG") and Fonts.SEGOE_UI_10_REG is not None: 233 ↛ 236line 233 didn't jump to line 236 because the condition on line 233 was always true

234 self.maximize_restore_btn.setFont(Fonts.SEGOE_UI_10_REG) 

235 else: 

236 try: 

237 from PySide6.QtGui import QFont 

238 

239 default_font = QFont() 

240 default_font.setFamily("Segoe UI") 

241 default_font.setPointSize(10) 

242 self.maximize_restore_btn.setFont(default_font) 

243 except ImportError: 

244 pass 

245 

246 self.maximize_restore_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) 

247 icon_maximize = ThemeIcon(Icons.icon_maximize, theme=current_theme) 

248 self._icons.append(icon_maximize) 

249 self.maximize_restore_btn.setIcon(icon_maximize) 

250 self.maximize_restore_btn.setIconSize(QSize(20, 20)) 

251 self._buttons_layout.addWidget(self.maximize_restore_btn) 

252 

253 # Close button 

254 self.close_btn = QPushButton(self._buttons_frame) 

255 self._buttons.append(self.close_btn) 

256 self.close_btn.setObjectName("close_btn") 

257 self.close_btn.setMinimumSize(QSize(28, 28)) 

258 self.close_btn.setMaximumSize(QSize(28, 28)) 

259 self.close_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) 

260 

261 icon_close = ThemeIcon(Icons.icon_close, theme=current_theme) 

262 self._icons.append(icon_close) 

263 self.close_btn.setIcon(icon_close) 

264 self.close_btn.setIconSize(QSize(20, 20)) 

265 self._buttons_layout.addWidget(self.close_btn) 

266 

267 self.retranslate_ui() 

268 

269 # /////////////////////////////////////////////////////////////// 

270 # PUBLIC METHODS 

271 # /////////////////////////////////////////////////////////////// 

272 

273 def set_app_name(self, app_name: str) -> None: 

274 """ 

275 Set the application name in the header. 

276 

277 Args: 

278 app_name: The new application name. 

279 """ 

280 self._app_name = app_name 

281 self._title_label.setText(QCoreApplication.translate("EzQt_App", app_name)) 

282 

283 def set_app_description(self, description: str) -> None: 

284 """ 

285 Set the application description in the header. 

286 

287 Args: 

288 description: The new application description. 

289 """ 

290 self._description = description 

291 self._subtitle_label.setText( 

292 QCoreApplication.translate("EzQt_App", description) 

293 ) 

294 

295 def retranslate_ui(self) -> None: 

296 """Apply current translations to all owned text labels and tooltips.""" 

297 self._title_label.setText( 

298 QCoreApplication.translate("EzQt_App", self._app_name) 

299 ) 

300 self._subtitle_label.setText( 

301 QCoreApplication.translate("EzQt_App", self._description) 

302 ) 

303 

304 self.settings_btn.setToolTip(QCoreApplication.translate("EzQt_App", "Settings")) 

305 self.minimize_btn.setToolTip(QCoreApplication.translate("EzQt_App", "Minimize")) 

306 self.maximize_restore_btn.setToolTip( 

307 QCoreApplication.translate("EzQt_App", "Maximize") 

308 ) 

309 self.close_btn.setToolTip(QCoreApplication.translate("EzQt_App", "Close")) 

310 

311 def changeEvent(self, event: QEvent) -> None: 

312 """ 

313 Handle Qt change events, triggering UI retranslation on language change. 

314 

315 Args: 

316 event: The QEvent instance. 

317 """ 

318 if event.type() == QEvent.Type.LanguageChange: 

319 self.retranslate_ui() 

320 super().changeEvent(event) 

321 

322 def set_app_logo( 

323 self, logo: str | QPixmap, y_shrink: int = 0, y_offset: int = 0 

324 ) -> None: 

325 """ 

326 Set the application logo in the header. 

327 

328 Args: 

329 logo: The logo to display (file path or QPixmap). 

330 y_shrink: Vertical reduction of the logo (default: 0). 

331 y_offset: Vertical offset of the logo (default: 0). 

332 """ 

333 

334 def offsetY(y_offset: int = 0, x_offset: int = 0) -> None: 

335 """Apply offset to logo.""" 

336 current_rect = self._logo_label.geometry() 

337 new_rect = QRect( 

338 current_rect.x() + x_offset, 

339 current_rect.y() + y_offset, 

340 current_rect.width(), 

341 current_rect.height(), 

342 ) 

343 self._logo_label.setGeometry(new_rect) 

344 

345 # Process logo 

346 pixmap_logo = QPixmap(logo) if isinstance(logo, str) else logo 

347 if pixmap_logo.size() != self._logo_label.minimumSize(): 347 ↛ 356line 347 didn't jump to line 356 because the condition on line 347 was always true

348 pixmap_logo = pixmap_logo.scaled( 

349 self._logo_label.minimumSize().shrunkBy( 

350 QMargins(0, y_shrink, 0, y_shrink) 

351 ), 

352 Qt.AspectRatioMode.KeepAspectRatio, 

353 Qt.TransformationMode.SmoothTransformation, 

354 ) 

355 

356 self._logo_label.setPixmap(pixmap_logo) 

357 offsetY(y_offset, y_shrink) 

358 

359 def set_settings_panel_open(self, is_open: bool) -> None: 

360 """ 

361 Update the ``open`` dynamic property on the settings button. 

362 

363 The property is used by QSS to apply an accent background when the 

364 settings panel is visible. Calling this method forces Qt to 

365 re-evaluate the style rules for the button immediately. 

366 

367 Args: 

368 is_open: ``True`` when the settings panel is opening, 

369 ``False`` when it is closing. 

370 """ 

371 self.settings_btn.setProperty("open", is_open) 

372 self.settings_btn.style().unpolish(self.settings_btn) 

373 self.settings_btn.style().polish(self.settings_btn) 

374 self.settings_btn.update() 

375 

376 def update_all_theme_icons(self) -> None: 

377 """Update all button icons according to current theme.""" 

378 current_theme = get_settings_service().gui.THEME 

379 for i, btn in enumerate(self._buttons): 

380 icon = self._icons[i] 

381 setter = getattr(icon, "setTheme", None) 

382 if callable(setter): 382 ↛ 384line 382 didn't jump to line 384 because the condition on line 382 was always true

383 setter(current_theme) 

384 btn.setIcon(icon) 

385 

386 

387# /////////////////////////////////////////////////////////////// 

388# PUBLIC API 

389# /////////////////////////////////////////////////////////////// 

390 

391__all__ = ["Header"]