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
« 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# ///////////////////////////////////////////////////////////////
6"""Header widget with logo, application name, and window control buttons."""
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, 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)
29# Local imports
30from ...services.settings import get_settings_service
31from ...services.ui import Fonts, SizePolicy
32from ...shared.resources import Icons
34# ///////////////////////////////////////////////////////////////
35# CLASSES
36# ///////////////////////////////////////////////////////////////
39class Header(QFrame):
40 """
41 Application header with logo, name and control buttons.
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 """
48 # ///////////////////////////////////////////////////////////////
49 # INIT
50 # ///////////////////////////////////////////////////////////////
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.
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] = []
74 # Store originals for retranslation
75 self._app_name: str = app_name
76 self._description: str = description
78 # Widget properties
79 self.setObjectName("header_container")
80 self.setFixedHeight(50)
81 self.setFrameShape(QFrame.Shape.NoFrame)
82 self.setFrameShadow(QFrame.Shadow.Raised)
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)
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)
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)
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)
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))
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
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
143 self._title_label.setAlignment(
144 Qt.AlignmentFlag.AlignLeading
145 | Qt.AlignmentFlag.AlignLeft
146 | Qt.AlignmentFlag.AlignTop
147 )
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))
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
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
168 self._subtitle_label.setAlignment(
169 Qt.AlignmentFlag.AlignLeading
170 | Qt.AlignmentFlag.AlignLeft
171 | Qt.AlignmentFlag.AlignTop
172 )
174 # Spacer
175 self._spacer = QSpacerItem(
176 20, 20, QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred
177 )
178 self._layout.addItem(self._spacer)
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)
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)
193 # Theme buttons
194 from ezqt_widgets import ThemeIcon
196 current_theme = get_settings_service().gui.THEME
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))
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)
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))
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)
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))
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
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
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)
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))
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)
267 self.retranslate_ui()
269 # ///////////////////////////////////////////////////////////////
270 # PUBLIC METHODS
271 # ///////////////////////////////////////////////////////////////
273 def set_app_name(self, app_name: str) -> None:
274 """
275 Set the application name in the header.
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))
283 def set_app_description(self, description: str) -> None:
284 """
285 Set the application description in the header.
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 )
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 )
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"))
311 def changeEvent(self, event: QEvent) -> None:
312 """
313 Handle Qt change events, triggering UI retranslation on language change.
315 Args:
316 event: The QEvent instance.
317 """
318 if event.type() == QEvent.Type.LanguageChange:
319 self.retranslate_ui()
320 super().changeEvent(event)
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.
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 """
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)
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 )
356 self._logo_label.setPixmap(pixmap_logo)
357 offsetY(y_offset, y_shrink)
359 def set_settings_panel_open(self, is_open: bool) -> None:
360 """
361 Update the ``open`` dynamic property on the settings button.
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.
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()
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)
387# ///////////////////////////////////////////////////////////////
388# PUBLIC API
389# ///////////////////////////////////////////////////////////////
391__all__ = ["Header"]