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

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

2# WIDGETS.CORE.BOTTOM_BAR - Application bottom bar widget 

3# Project: ezqt_app 

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

5 

6"""BottomBar widget with credits, version, and resize area.""" 

7 

8from __future__ import annotations 

9 

10# /////////////////////////////////////////////////////////////// 

11# IMPORTS 

12# /////////////////////////////////////////////////////////////// 

13# Standard library imports 

14import importlib.util 

15import re 

16import sys 

17from pathlib import Path 

18 

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 

23 

24# Local imports 

25from ...services.ui import Fonts 

26from ...utils.diagnostics import warn_tech 

27 

28 

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

30# CLASSES 

31# /////////////////////////////////////////////////////////////// 

32class BottomBar(QFrame): 

33 """ 

34 Bottom bar for the main window. 

35 

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

40 

41 def __init__(self, parent: QWidget | None = None) -> None: 

42 """ 

43 Initialize the bottom bar. 

44 

45 Parameters 

46 ---------- 

47 parent : Any, optional 

48 The parent widget (default: None). 

49 """ 

50 super().__init__(parent) 

51 

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

58 

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) 

65 

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) 

71 

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) 

84 

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) 

100 

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) 

116 

117 # Push the version block to the right, keeping credits + indicator grouped left. 

118 self._layout.addStretch(1) 

119 

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) 

129 

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) 

139 

140 # ////// INITIALIZE DEFAULT VALUES 

141 self.retranslate_ui() 

142 self.set_version_auto() 

143 

144 # /////////////////////////////////////////////////////////////// 

145 # UTILITY FUNCTIONS 

146 

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

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

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

150 

151 def show_translation_indicator(self) -> None: 

152 """Show the translation-in-progress indicator in the bottom bar. 

153 

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) 

159 

160 def hide_translation_indicator(self) -> None: 

161 """Hide the translation indicator once all pending translations are done. 

162 

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) 

168 

169 def set_credits(self, credits: str | dict[str, str]) -> None: 

170 """ 

171 Set credits with support for simple text or dictionary. 

172 

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 

181 

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

187 

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) 

195 

196 def _create_clickable_credits(self, credits_data: dict[str, str]) -> None: 

197 """ 

198 Create a clickable link for credits with name and email. 

199 

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

208 

209 # Build translatable base text then append the name (untranslated). 

210 base = self._tr("Made with ❤️ by") 

211 credits_text = f"{base} {name}" 

212 

213 self._credits_label.setText(credits_text) 

214 

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

227 

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) 

235 

236 def _open_email(self, email: str) -> None: 

237 """ 

238 Open default email client with specified address. 

239 

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 ) 

253 

254 def set_version_auto(self) -> None: 

255 """ 

256 Automatically detect user project version. 

257 

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__ 

268 

269 self.set_version(f"v{__version__}") 

270 except ImportError: 

271 self.set_version("") # Default version 

272 

273 def set_version_forced(self, version: str) -> None: 

274 """ 

275 Force displayed version (ignore automatic detection). 

276 

277 Parameters 

278 ---------- 

279 version : str 

280 Version to display (ex: "v1.0.0" or "1.0.0"). 

281 """ 

282 self.set_version(version) 

283 

284 def _detect_project_version(self) -> str | None: 

285 """ 

286 Detect user project version by looking for __version__ in main.py. 

287 

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 

300 

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 

308 

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 

316 

317 # Method 4: Try to import main module 

318 try: 

319 import importlib 

320 

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 

326 

327 # Method 5: Fallback to EzQt_App version 

328 try: 

329 from ...version import __version__ 

330 

331 return f"v{__version__}" 

332 except ImportError: 

333 pass 

334 

335 return None 

336 

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 

345 

346 def _extract_version_from_file(self, file_path: Path) -> str | None: 

347 """ 

348 Extract version from a Python file. 

349 

350 Parameters 

351 ---------- 

352 file_path : Path 

353 Path to Python file. 

354 

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

364 

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

369 

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) 

376 

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 ) 

385 

386 return None 

387 

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 

395 

396 def set_version(self, text: str) -> None: 

397 """ 

398 Set version text with translation system support. 

399 

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

408 

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) 

413 

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

423 

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

425 """Handle Qt change events, triggering UI retranslation on language change. 

426 

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) 

435 

436 

437# /////////////////////////////////////////////////////////////// 

438# PUBLIC API 

439# /////////////////////////////////////////////////////////////// 

440__all__ = ["BottomBar"]