Coverage for src / ezqt_app / widgets / core / bottom_bar.py: 65.89%
176 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.BOTTOM_BAR - Application bottom bar widget
3# Project: ezqt_app
4# ///////////////////////////////////////////////////////////////
6"""BottomBar widget with credits, version, and resize area."""
8from __future__ import annotations
10# ///////////////////////////////////////////////////////////////
11# IMPORTS
12# ///////////////////////////////////////////////////////////////
13# Standard library imports
14import importlib.util
15import re
16import sys
17from pathlib import Path
19# Third-party imports
20from PySide6.QtCore import QCoreApplication, QEvent, QSize, Qt, QUrl
21from PySide6.QtGui import QDesktopServices
22from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget
24# Local imports
25from ...services.ui import Fonts
26from ...utils.diagnostics import warn_tech
29# ///////////////////////////////////////////////////////////////
30# CLASSES
31# ///////////////////////////////////////////////////////////////
32class BottomBar(QFrame):
33 """
34 Bottom bar for the main window.
36 This class provides a bottom bar with credits,
37 version and resize area. Credits can be clickable
38 and open an email client.
39 """
41 def __init__(self, parent: QWidget | None = None) -> None:
42 """
43 Initialize the bottom bar.
45 Parameters
46 ----------
47 parent : Any, optional
48 The parent widget (default: None).
49 """
50 super().__init__(parent)
52 # ////// INITIALIZE TRANSLATION STORAGE
53 # These are set by set_credits() and set_version() — initialised here
54 # so retranslate_ui() can safely reference them before the setters run.
55 self._default_credits: str = "Made with ❤️ by EzQt_App"
56 self._credits_data: str | dict[str, str] = self._default_credits
57 self._version_text: str = ""
59 # ////// SETUP WIDGET PROPERTIES
60 self.setObjectName("bottom_bar")
61 self.setMinimumSize(QSize(0, 22))
62 self.setMaximumSize(QSize(16777215, 22))
63 self.setFrameShape(QFrame.Shape.NoFrame)
64 self.setFrameShadow(QFrame.Shadow.Raised)
66 # ////// SETUP MAIN LAYOUT
67 self._layout = QHBoxLayout(self)
68 self._layout.setSpacing(0)
69 self._layout.setObjectName("bottom_bar_layout")
70 self._layout.setContentsMargins(0, 0, 0, 0)
72 # ////// SETUP CREDITS LABEL
73 self._credits_label = QLabel(self)
74 self._credits_label.setObjectName("credits_label")
75 self._credits_label.setMaximumSize(QSize(16777215, 16))
76 if Fonts.SEGOE_UI_10_REG is not None: 76 ↛ 78line 76 didn't jump to line 78 because the condition on line 76 was always true
77 self._credits_label.setFont(Fonts.SEGOE_UI_10_REG)
78 self._credits_label.setAlignment(
79 Qt.AlignmentFlag.AlignLeading
80 | Qt.AlignmentFlag.AlignLeft
81 | Qt.AlignmentFlag.AlignVCenter
82 )
83 self._layout.addWidget(self._credits_label)
85 # ////// SETUP TRANSLATION SEPARATOR
86 # Displayed only while the translation indicator is visible.
87 self._trans_sep_label = QLabel(self)
88 self._trans_sep_label.setObjectName("trans_sep_label")
89 self._trans_sep_label.setMaximumSize(QSize(16777215, 16))
90 if Fonts.SEGOE_UI_10_REG is not None: 90 ↛ 92line 90 didn't jump to line 92 because the condition on line 90 was always true
91 self._trans_sep_label.setFont(Fonts.SEGOE_UI_10_REG)
92 self._trans_sep_label.setAlignment(
93 Qt.AlignmentFlag.AlignLeading
94 | Qt.AlignmentFlag.AlignLeft
95 | Qt.AlignmentFlag.AlignVCenter
96 )
97 self._trans_sep_label.setText("•")
98 self._trans_sep_label.setVisible(False)
99 self._layout.addWidget(self._trans_sep_label)
101 # ////// SETUP TRANSLATION INDICATOR LABEL
102 # Shown only while async auto-translations are in flight.
103 self._trans_ind_label = QLabel(self)
104 self._trans_ind_label.setObjectName("trans_ind_label")
105 self._trans_ind_label.setMaximumSize(QSize(16777215, 16))
106 if Fonts.SEGOE_UI_10_REG is not None: 106 ↛ 108line 106 didn't jump to line 108 because the condition on line 106 was always true
107 self._trans_ind_label.setFont(Fonts.SEGOE_UI_10_REG)
108 self._trans_ind_label.setAlignment(
109 Qt.AlignmentFlag.AlignLeading
110 | Qt.AlignmentFlag.AlignLeft
111 | Qt.AlignmentFlag.AlignVCenter
112 )
113 # Hidden by default — only visible while translations are pending.
114 self._trans_ind_label.setVisible(False)
115 self._layout.addWidget(self._trans_ind_label)
117 # Push the version block to the right, keeping credits + indicator grouped left.
118 self._layout.addStretch(1)
120 # ////// SETUP VERSION LABEL
121 self._version_label = QLabel(self)
122 self._version_label.setObjectName("version_label")
123 self._version_label.setAlignment(
124 Qt.AlignmentFlag.AlignRight
125 | Qt.AlignmentFlag.AlignTrailing
126 | Qt.AlignmentFlag.AlignVCenter
127 )
128 self._layout.addWidget(self._version_label)
130 # ////// SETUP SIZE GRIP
131 # Keeping variable name 'size_grip_spacer' for MainWindowProtocol compatibility.
132 self.size_grip_spacer = QFrame(self)
133 self.size_grip_spacer.setObjectName("size_grip_spacer")
134 self.size_grip_spacer.setMinimumSize(QSize(20, 0))
135 self.size_grip_spacer.setMaximumSize(QSize(20, 16777215))
136 self.size_grip_spacer.setFrameShape(QFrame.Shape.NoFrame)
137 self.size_grip_spacer.setFrameShadow(QFrame.Shadow.Raised)
138 self._layout.addWidget(self.size_grip_spacer)
140 # ////// INITIALIZE DEFAULT VALUES
141 self.retranslate_ui()
142 self.set_version_auto()
144 # ///////////////////////////////////////////////////////////////
145 # UTILITY FUNCTIONS
147 def _tr(self, text: str) -> str:
148 """Shortcut for translation with global context."""
149 return QCoreApplication.translate("EzQt_App", text)
151 def show_translation_indicator(self) -> None:
152 """Show the translation-in-progress indicator in the bottom bar.
154 Called via signal/slot when the first async auto-translation is enqueued.
155 Safe to call from any thread that posts to the Qt event loop.
156 """
157 self._trans_sep_label.setVisible(True)
158 self._trans_ind_label.setVisible(True)
160 def hide_translation_indicator(self) -> None:
161 """Hide the translation indicator once all pending translations are done.
163 Called via signal/slot when the pending auto-translation count reaches zero.
164 Safe to call from any thread that posts to the Qt event loop.
165 """
166 self._trans_sep_label.setVisible(False)
167 self._trans_ind_label.setVisible(False)
169 def set_credits(self, credits: str | dict[str, str]) -> None:
170 """
171 Set credits with support for simple text or dictionary.
173 Parameters
174 ----------
175 credits : str or Dict[str, str]
176 Credits as simple text or dictionary with 'name' and 'email'.
177 """
178 try:
179 # Store original data so retranslate_ui() can re-apply on language change.
180 self._credits_data = credits
182 if isinstance(credits, dict):
183 # Credits with name and email
184 self._create_clickable_credits(credits)
185 else:
186 self._credits_label.setText(self._tr(credits))
188 except Exception as e:
189 warn_tech(
190 code="widgets.bottom_bar.set_credits_failed",
191 message="Could not apply credits data",
192 error=e,
193 )
194 self._credits_label.setText(self._default_credits)
196 def _create_clickable_credits(self, credits_data: dict[str, str]) -> None:
197 """
198 Create a clickable link for credits with name and email.
200 Parameters
201 ----------
202 credits_data : Dict[str, str]
203 Dictionary with 'name' and 'email'.
204 """
205 try:
206 name = credits_data.get("name", "Unknown")
207 email = credits_data.get("email", "")
209 # Build translatable base text then append the name (untranslated).
210 base = self._tr("Made with ❤️ by")
211 credits_text = f"{base} {name}"
213 self._credits_label.setText(credits_text)
215 # Make label clickable if email is provided
216 if email:
217 self._credits_label.setCursor(Qt.CursorShape.PointingHandCursor)
218 self._credits_label.mousePressEvent = lambda _event: self._open_email( # type: ignore[method-assign]
219 email
220 )
221 self._credits_label.setStyleSheet(
222 "color: #0078d4; text-decoration: underline;"
223 )
224 else:
225 self._credits_label.setCursor(Qt.CursorShape.ArrowCursor)
226 self._credits_label.setStyleSheet("")
228 except Exception as e:
229 warn_tech(
230 code="widgets.bottom_bar.create_clickable_credits_failed",
231 message="Could not create clickable credits",
232 error=e,
233 )
234 self._credits_label.setText(self._default_credits)
236 def _open_email(self, email: str) -> None:
237 """
238 Open default email client with specified address.
240 Parameters
241 ----------
242 email : str
243 Email address to open.
244 """
245 try:
246 QDesktopServices.openUrl(QUrl(f"mailto:{email}"))
247 except Exception as e:
248 warn_tech(
249 code="widgets.bottom_bar.open_email_failed",
250 message=f"Could not open mailto link for {email}",
251 error=e,
252 )
254 def set_version_auto(self) -> None:
255 """
256 Automatically detect user project version.
258 First look for __version__ in main module,
259 otherwise use default value.
260 """
261 detected_version = self._detect_project_version()
262 if detected_version: 262 ↛ 266line 262 didn't jump to line 266 because the condition on line 262 was always true
263 self.set_version(detected_version)
264 else:
265 # Fallback to EzQt_App version if no version found
266 try:
267 from ...version import __version__
269 self.set_version(f"v{__version__}")
270 except ImportError:
271 self.set_version("") # Default version
273 def set_version_forced(self, version: str) -> None:
274 """
275 Force displayed version (ignore automatic detection).
277 Parameters
278 ----------
279 version : str
280 Version to display (ex: "v1.0.0" or "1.0.0").
281 """
282 self.set_version(version)
284 def _detect_project_version(self) -> str | None:
285 """
286 Detect user project version by looking for __version__ in main.py.
288 Returns
289 -------
290 str or None
291 Detected version or None if not found.
292 """
293 try:
294 # Method 1: Look in current directory
295 main_py_path = Path.cwd() / "main.py"
296 if main_py_path.exists(): 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true
297 version = self._extract_version_from_file(main_py_path)
298 if version:
299 return version
301 # Method 2: Look in main script directory
302 script_dir = Path(sys.argv[0]).parent if sys.argv else Path.cwd()
303 main_py_path = script_dir / "main.py"
304 if main_py_path.exists(): 304 ↛ 305line 304 didn't jump to line 305 because the condition on line 304 was never true
305 version = self._extract_version_from_file(main_py_path)
306 if version:
307 return version
309 # Method 3: Look in parent directory (case where exe is in subfolder)
310 parent_dir = Path.cwd().parent
311 main_py_path = parent_dir / "main.py"
312 if main_py_path.exists(): 312 ↛ 313line 312 didn't jump to line 313 because the condition on line 312 was never true
313 version = self._extract_version_from_file(main_py_path)
314 if version:
315 return version
317 # Method 4: Try to import main module
318 try:
319 import importlib
321 main_mod = importlib.import_module("main")
322 if hasattr(main_mod, "__version__"):
323 return f"v{main_mod.__version__}"
324 except ImportError:
325 pass
327 # Method 5: Fallback to EzQt_App version
328 try:
329 from ...version import __version__
331 return f"v{__version__}"
332 except ImportError:
333 pass
335 return None
337 except Exception as e:
338 warn_tech(
339 code="widgets.bottom_bar.detect_project_version_failed",
340 message="Could not detect project version",
341 error=e,
342 )
343 # In case of error, return None
344 return None
346 def _extract_version_from_file(self, file_path: Path) -> str | None:
347 """
348 Extract version from a Python file.
350 Parameters
351 ----------
352 file_path : Path
353 Path to Python file.
355 Returns
356 -------
357 str or None
358 Extracted version or None if not found.
359 """
360 try:
361 # Read file content
362 with open(file_path, encoding="utf-8") as f:
363 content = f.read()
365 # Look for __version__ = "..." in content
366 version_match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
367 if version_match:
368 return f"v{version_match.group(1)}"
370 # If not found with regex, try to import module
371 try:
372 spec = importlib.util.spec_from_file_location("main", file_path)
373 if spec and spec.loader:
374 main_module = importlib.util.module_from_spec(spec)
375 spec.loader.exec_module(main_module)
377 if hasattr(main_module, "__version__"):
378 return f"v{main_module.__version__}"
379 except Exception as e:
380 warn_tech(
381 code="widgets.bottom_bar.import_main_for_version_failed",
382 message=f"Could not import {file_path} to extract __version__",
383 error=e,
384 )
386 return None
388 except Exception as e:
389 warn_tech(
390 code="widgets.bottom_bar.extract_version_failed",
391 message=f"Could not extract version from {file_path}",
392 error=e,
393 )
394 return None
396 def set_version(self, text: str) -> None:
397 """
398 Set version text with translation system support.
400 Parameters
401 ----------
402 text : str
403 Version text (can be "v1.0.0" or just "1.0.0").
404 """
405 # Ensure version starts with "v"
406 if not text.startswith("v"): 406 ↛ 407line 406 didn't jump to line 407 because the condition on line 406 was never true
407 text = f"v{text}"
409 # Store so retranslate_ui() can re-apply the version string after a language
410 # change (version strings are not translated, but the label must be refreshed).
411 self._version_text = text
412 self._version_label.setText(text)
414 def retranslate_ui(self) -> None:
415 """Apply current translations to all owned text labels."""
416 # Re-apply credits through the standard setter so display logic is consistent.
417 self.set_credits(self._credits_data)
418 # Version strings are not translatable but must be refreshed on language change.
419 if self._version_text:
420 self._version_label.setText(self._version_text)
421 # Refresh the indicator label text (visibility is unchanged here).
422 self._trans_ind_label.setText(self._tr("Translating..."))
424 def changeEvent(self, event: QEvent) -> None:
425 """Handle Qt change events, triggering UI retranslation on language change.
427 Parameters
428 ----------
429 event : QEvent
430 The Qt change event.
431 """
432 if event.type() == QEvent.Type.LanguageChange:
433 self.retranslate_ui()
434 super().changeEvent(event)
437# ///////////////////////////////////////////////////////////////
438# PUBLIC API
439# ///////////////////////////////////////////////////////////////
440__all__ = ["BottomBar"]