Coverage for paintmaskanimator/theme.py: 76%

221 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""Application-wide visual theme (light / dark) and a status-bar helper. 

2 

3This module owns two things: 

4 

5* A pair of colour palettes (``light`` / ``dark``) and a function that turns 

6 the active palette into a global Qt style sheet applied to the whole 

7 ``QApplication``. Only the main window chrome is themed here (menu bar, 

8 buttons, sliders, scroll bars, docks, the status bar); individual dialogs 

9 keep their own local styles. 

10* :class:`StatusBar`, a ``QStatusBar`` subclass that shows severity-coloured 

11 messages (info / success / warning / error) at the very bottom of the 

12 window, with a coloured accent stripe and a small status icon. 

13 

14The chosen theme is persisted through :mod:`.config` so it survives restarts. 

15""" 

16import sys 

17 

18from PySide6.QtCore import Qt, QTimer 

19from PySide6.QtGui import QColor, QGuiApplication, QPalette 

20from PySide6.QtWidgets import ( 

21 QStatusBar, QLabel, QHBoxLayout, QWidget, QFrame, QSizePolicy, 

22) 

23 

24from . import config 

25from .i18n import tr 

26from .logging_setup import get_logger 

27 

28log = get_logger(__name__) 

29 

30IS_MAC = sys.platform == "darwin" 

31 

32CONFIG_KEY = "ui_theme" 

33ACCENT_KEY = "ui_accent" 

34# Palette used when nothing else applies. 

35DEFAULT_THEME = "light" 

36DEFAULT_ACCENT = "#2f6fed" 

37# "system" follows the OS appearance / accent colour. It is the default on 

38# macOS, where apps are expected to follow System Settings; elsewhere the 

39# previous fixed defaults are kept so existing users see no change. 

40SYSTEM = "system" 

41DEFAULT_THEME_SETTING = SYSTEM if IS_MAC else DEFAULT_THEME 

42DEFAULT_ACCENT_SETTING = SYSTEM if IS_MAC else DEFAULT_ACCENT 

43 

44# The OS accent colour, read from the platform palette before the app installs 

45# its own palette (after that, QApplication.palette() returns ours). 

46_system_accent = None 

47 

48# Named accent presets offered in the "表示 > アクセントカラー" menu. The 

49# custom picker can still choose any colour; these are just quick presets. 

50# 

51# A function rather than a constant: ``tr()`` at module level would run at import 

52# time, before the translator is installed, and freeze the source language in. 

53def accent_presets(): 

54 """(display name, hex) for the accent-colour menu, translated at call time.""" 

55 return ( 

56 (tr("ブルー"), "#2f6fed"), 

57 (tr("ティール"), "#0d9488"), 

58 (tr("グリーン"), "#1a8a4a"), 

59 (tr("パープル"), "#7c4dff"), 

60 (tr("オレンジ"), "#e0730a"), 

61 (tr("レッド"), "#d64545"), 

62 ) 

63 

64# --------------------------------------------------------------------------- 

65# Palettes 

66# --------------------------------------------------------------------------- 

67# Each palette is a flat dict of named colours. Severity colours (info/ 

68# success/warning/error) are shared by the status bar. Keeping them here means 

69# both the global style sheet and the status bar read from one source. 

70PALETTES = { 

71 "light": { 

72 "window": "#f4f5f7", 

73 "surface": "#ffffff", 

74 "surface_alt": "#eceef1", 

75 "border": "#d3d7de", 

76 "text": "#1f2329", 

77 "text_muted": "#6b7280", 

78 "accent": "#2f6fed", 

79 "accent_hover": "#245ad0", 

80 "accent_text": "#ffffff", 

81 "hover": "#e4e8ef", 

82 "selection": "#cfe0ff", 

83 "statusbar": "#ffffff", 

84 "info": "#2f6fed", 

85 "success": "#1a8a4a", 

86 "warning": "#b8730a", 

87 "error": "#c62828", 

88 "timeline_key": "#bfe7f4", 

89 "timeline_key_text": "#0d6694", 

90 "timeline_hold": "#d9f0f7", 

91 "timeline_hold_text": "#256b88", 

92 "timeline_sheet_key": "#ffe08a", 

93 "timeline_sheet_key_text": "#795300", 

94 "timeline_sheet_hold": "#fff1b8", 

95 "timeline_sheet_hold_text": "#80621a", 

96 "timeline_blank": "#eaf7fb", 

97 "timeline_blank_text": "#648b9b", 

98 "timeline_uncreated": "#f5f5f5", 

99 "timeline_uncreated_text": "#777d85", 

100 "timeline_tween": "#f1dcef", 

101 "timeline_tween_text": "#8a2f84", 

102 }, 

103 "dark": { 

104 "window": "#22252b", 

105 "surface": "#2b2f37", 

106 "surface_alt": "#31363f", 

107 "border": "#3c424c", 

108 "text": "#e6e9ee", 

109 "text_muted": "#9aa2ad", 

110 "accent": "#4d8bff", 

111 "accent_hover": "#3f79e6", 

112 "accent_text": "#ffffff", 

113 "hover": "#3a404a", 

114 "selection": "#33507f", 

115 "statusbar": "#1d2025", 

116 "info": "#6ba0ff", 

117 "success": "#4cc47c", 

118 "warning": "#e0a63a", 

119 "error": "#ff6b6b", 

120 "timeline_key": "#16445a", 

121 "timeline_key_text": "#8ed8f5", 

122 "timeline_hold": "#213c49", 

123 "timeline_hold_text": "#a8d5e8", 

124 "timeline_sheet_key": "#594514", 

125 "timeline_sheet_key_text": "#ffd86a", 

126 "timeline_sheet_hold": "#463b21", 

127 "timeline_sheet_hold_text": "#ead28b", 

128 "timeline_blank": "#283840", 

129 "timeline_blank_text": "#82aeba", 

130 "timeline_uncreated": "#292d34", 

131 "timeline_uncreated_text": "#9aa2ad", 

132 "timeline_tween": "#4a2747", 

133 "timeline_tween_text": "#f0a8e8", 

134 }, 

135} 

136 

137 

138# macOS: neutral greys closer to the system's own window, sidebar and 

139# separator colours, so the app sits naturally next to native apps. 

140MAC_OVERRIDES = { 

141 "light": { 

142 "window": "#ececec", 

143 "surface": "#ffffff", 

144 "surface_alt": "#f5f5f5", 

145 "border": "#d6d6d6", 

146 "text": "#1d1d1f", 

147 "text_muted": "#6e6e73", 

148 "hover": "#e2e2e4", 

149 "statusbar": "#ececec", 

150 }, 

151 "dark": { 

152 "window": "#1e1e1e", 

153 "surface": "#2b2b2b", 

154 "surface_alt": "#323232", 

155 "border": "#3f3f3f", 

156 "text": "#e8e8ea", 

157 "text_muted": "#98989d", 

158 "hover": "#3a3a3c", 

159 "statusbar": "#1e1e1e", 

160 }, 

161} 

162 

163 

164def available_themes(): 

165 """Theme settings offered in the menu: follow the OS, or a fixed palette.""" 

166 return (SYSTEM,) + tuple(PALETTES.keys()) 

167 

168 

169def system_theme(): 

170 """``"dark"`` or ``"light"`` from the OS appearance (light if unknown).""" 

171 app = QGuiApplication.instance() 

172 if app is None: 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true

173 return DEFAULT_THEME 

174 scheme = QGuiApplication.styleHints().colorScheme() 

175 return "dark" if scheme == Qt.ColorScheme.Dark else "light" 

176 

177 

178def resolved_theme(name=None): 

179 """The palette name actually used for a theme setting.""" 

180 if name is None: 

181 name = current_theme() 

182 if name == SYSTEM: 

183 return system_theme() 

184 return name if name in PALETTES else DEFAULT_THEME 

185 

186 

187def system_accent(): 

188 """The OS accent colour, or ``DEFAULT_ACCENT`` when it is unavailable.""" 

189 global _system_accent 

190 if _system_accent is None: 190 ↛ 197line 190 didn't jump to line 197 because the condition on line 190 was always true

191 app = QGuiApplication.instance() 

192 if app is None: 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true

193 return DEFAULT_ACCENT 

194 role = getattr(QPalette.ColorRole, "Accent", QPalette.ColorRole.Highlight) 

195 color = QGuiApplication.palette().color(role) 

196 _system_accent = color.name() if color.isValid() else DEFAULT_ACCENT 

197 return _system_accent 

198 

199 

200def _mix(a, b, ratio): 

201 """Blend two hex colours; ``ratio`` is the weight of ``a`` (0..1).""" 

202 ca, cb = QColor(a), QColor(b) 

203 r = round(ca.red() * ratio + cb.red() * (1 - ratio)) 

204 g = round(ca.green() * ratio + cb.green() * (1 - ratio)) 

205 bl = round(ca.blue() * ratio + cb.blue() * (1 - ratio)) 

206 return QColor(r, g, bl).name() 

207 

208 

209def _contrast_text(background): 

210 """Return black or white, whichever is more legible on ``background``.""" 

211 color = QColor(background) 

212 

213 def luminance(channel): 

214 value = channel / 255.0 

215 return value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 

216 

217 level = ( 

218 0.2126 * luminance(color.red()) 

219 + 0.7152 * luminance(color.green()) 

220 + 0.0722 * luminance(color.blue()) 

221 ) 

222 white_ratio = 1.05 / (level + 0.05) 

223 black_ratio = (level + 0.05) / 0.05 

224 return "#ffffff" if white_ratio >= black_ratio else "#000000" 

225 

226 

227# テーマ×アクセントごとの配色。タイムラインはセル1つ描くたびに palette() を 

228# 引くので、混色の計算を毎回やり直さない。 

229_palette_cache = {} 

230 

231 

232def palette(name=None): 

233 """Return the colour dict for ``name`` (or the active theme). 

234 

235 A copy is returned with the user's accent colour blended in, so callers 

236 always read a consistent, up-to-date accent. 

237 """ 

238 name = resolved_theme(name) 

239 accent = str(current_accent()) 

240 cached = _palette_cache.get((name, accent)) 

241 if cached is None: 

242 cached = _build_palette(name, accent) 

243 _palette_cache[(name, accent)] = cached 

244 return dict(cached) 

245 

246 

247def _build_palette(name, accent): 

248 c = dict(PALETTES.get(name, PALETTES[DEFAULT_THEME])) 

249 if IS_MAC: 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true

250 c.update(MAC_OVERRIDES.get(name, {})) 

251 c["accent"] = accent 

252 c["accent_text"] = _contrast_text(accent) 

253 # Hover = accent nudged toward the window colour so it reads as "pressed". 

254 c["accent_hover"] = _mix(accent, c["window"], 0.82) 

255 # Selection = accent softened into the surface for subtle highlights. 

256 c["selection"] = _mix(accent, c["surface"], 0.28) 

257 # The neutral "info" severity follows the accent so the app feels unified. 

258 c["info"] = accent 

259 return c 

260 

261 

262def current_theme(): 

263 """The stored theme setting: ``"system"``, ``"light"`` or ``"dark"``.""" 

264 name = config.get_value(CONFIG_KEY, DEFAULT_THEME_SETTING) 

265 return name if name in available_themes() else DEFAULT_THEME_SETTING 

266 

267 

268def accent_setting(): 

269 """The stored accent setting: ``"system"`` or a hex colour.""" 

270 value = config.get_value(ACCENT_KEY, DEFAULT_ACCENT_SETTING) 

271 if value == SYSTEM or QColor(value).isValid(): 271 ↛ 273line 271 didn't jump to line 273 because the condition on line 271 was always true

272 return value 

273 return DEFAULT_ACCENT_SETTING 

274 

275 

276def current_accent(): 

277 value = accent_setting() 

278 return system_accent() if value == SYSTEM else value 

279 

280 

281def accent(): 

282 """Convenience alias for the active accent colour (hex string).""" 

283 return current_accent() 

284 

285 

286def build_stylesheet(name): 

287 """Build the global Qt style sheet string for the given palette name.""" 

288 c = palette(name) 

289 return f""" 

290 QMainWindow, QDialog {{ 

291 background-color: {c['window']}; 

292 }} 

293 QWidget {{ 

294 color: {c['text']}; 

295 }} 

296 /* Fallback for tooltips the custom popup (tooltip.py) does not handle. 

297 A native tooltip window is rectangular, so no rounded corners here. */ 

298 QToolTip {{ 

299 background-color: {c['surface']}; 

300 color: {c['text']}; 

301 border: 1px solid {c['border']}; 

302 padding: 5px 8px; 

303 }} 

304 

305 /* Menu bar & menus */ 

306 QMenuBar {{ 

307 background-color: {c['surface']}; 

308 border-bottom: 1px solid {c['border']}; 

309 padding: 2px 4px; 

310 }} 

311 QMenuBar::item {{ 

312 background: transparent; 

313 padding: 5px 10px; 

314 border-radius: 6px; 

315 }} 

316 QMenuBar::item:selected {{ background-color: {c['hover']}; }} 

317 QMenuBar::item:pressed {{ background-color: {c['selection']}; }} 

318 QMenu {{ 

319 background-color: {c['surface']}; 

320 border: 1px solid {c['border']}; 

321 border-radius: 8px; 

322 padding: 5px; 

323 }} 

324 QMenu::item {{ 

325 padding: 6px 22px; 

326 border-radius: 5px; 

327 }} 

328 QMenu::item:selected {{ background-color: {c['accent']}; color: {c['accent_text']}; }} 

329 QMenu::separator {{ 

330 height: 1px; 

331 background: {c['border']}; 

332 margin: 4px 8px; 

333 }} 

334 

335 /* Buttons */ 

336 QPushButton {{ 

337 background-color: {c['surface']}; 

338 border: 1px solid {c['border']}; 

339 border-radius: 6px; 

340 padding: 5px 12px; 

341 color: {c['text']}; 

342 }} 

343 QPushButton:hover {{ background-color: {c['hover']}; }} 

344 QPushButton:pressed {{ background-color: {c['selection']}; }} 

345 QPushButton:disabled {{ color: {c['text_muted']}; border-color: {c['border']}; }} 

346 QPushButton:default {{ 

347 background-color: {c['accent']}; 

348 border-color: {c['accent']}; 

349 color: {c['accent_text']}; 

350 }} 

351 QPushButton:default:hover {{ background-color: {c['accent_hover']}; }} 

352 

353 /* Icon-only buttons (setProperty("iconButton", True)): no chrome until 

354 hovered, a soft accent wash when checked. */ 

355 QPushButton[iconButton="true"], QToolButton[iconButton="true"] {{ 

356 background: transparent; 

357 border: 1px solid transparent; 

358 border-radius: 6px; 

359 padding: 0; 

360 }} 

361 QPushButton[iconButton="true"]:hover, QToolButton[iconButton="true"]:hover {{ 

362 background-color: {c['hover']}; 

363 }} 

364 QPushButton[iconButton="true"]:pressed, QToolButton[iconButton="true"]:pressed, 

365 QPushButton[iconButton="true"]:checked, QToolButton[iconButton="true"]:checked {{ 

366 background-color: {c['selection']}; 

367 border-color: {c['accent']}; 

368 }} 

369 /* Primary round action (e.g. timeline play). */ 

370 QPushButton[accentButton="true"] {{ 

371 background-color: {c['accent']}; 

372 border: none; 

373 border-radius: 12px; 

374 padding: 0; 

375 }} 

376 QPushButton[accentButton="true"]:hover {{ background-color: {c['accent_hover']}; }} 

377 QPushButton[accentButton="true"]:checked {{ background-color: {c['accent_hover']}; }} 

378 /* On/off chip (e.g. onion skin). */ 

379 QPushButton[toggleChip="true"] {{ 

380 background: transparent; 

381 border: 1px solid {c['border']}; 

382 border-radius: 12px; 

383 padding: 0 10px 0 8px; 

384 color: {c['text_muted']}; 

385 }} 

386 QPushButton[toggleChip="true"]:hover {{ background-color: {c['hover']}; color: {c['text']}; }} 

387 QPushButton[toggleChip="true"]:checked {{ 

388 background-color: {c['selection']}; 

389 border-color: {c['accent']}; 

390 color: {c['text']}; 

391 font-weight: 600; 

392 }} 

393 

394 /* Text entry */ 

395 QLineEdit, QPlainTextEdit, QSpinBox, QDoubleSpinBox, QComboBox {{ 

396 background-color: {c['surface']}; 

397 border: 1px solid {c['border']}; 

398 border-radius: 6px; 

399 padding: 3px 6px; 

400 selection-background-color: {c['accent']}; 

401 selection-color: {c['accent_text']}; 

402 }} 

403 QComboBox::drop-down {{ border: none; width: 18px; }} 

404 QLineEdit:disabled, QPlainTextEdit:disabled, QSpinBox:disabled, 

405 QDoubleSpinBox:disabled, QComboBox:disabled {{ 

406 background-color: {c['surface_alt']}; 

407 color: {c['text_muted']}; 

408 }} 

409 QComboBox QAbstractItemView {{ 

410 background-color: {c['surface']}; 

411 color: {c['text']}; 

412 border: 1px solid {c['border']}; 

413 selection-background-color: {c['accent']}; 

414 selection-color: {c['accent_text']}; 

415 outline: 0; 

416 }} 

417 

418 /* Generic containers and item views. */ 

419 QFrame[frameShape="4"], QFrame[frameShape="5"] {{ color: {c['border']}; }} 

420 QGroupBox {{ 

421 border: 1px solid {c['border']}; 

422 border-radius: 7px; 

423 margin-top: 8px; 

424 padding-top: 7px; 

425 }} 

426 QGroupBox::title {{ 

427 subcontrol-origin: margin; 

428 left: 8px; 

429 padding: 0 4px; 

430 color: {c['text']}; 

431 }} 

432 QAbstractItemView {{ 

433 background-color: {c['surface']}; 

434 alternate-background-color: {c['surface_alt']}; 

435 color: {c['text']}; 

436 border: 1px solid {c['border']}; 

437 selection-background-color: {c['accent']}; 

438 selection-color: {c['accent_text']}; 

439 outline: 0; 

440 }} 

441 QHeaderView::section {{ 

442 background-color: {c['surface_alt']}; 

443 color: {c['text']}; 

444 border: none; 

445 border-right: 1px solid {c['border']}; 

446 border-bottom: 1px solid {c['border']}; 

447 padding: 4px 6px; 

448 }} 

449 QTableCornerButton::section {{ 

450 background-color: {c['surface_alt']}; 

451 border: none; 

452 border-right: 1px solid {c['border']}; 

453 border-bottom: 1px solid {c['border']}; 

454 }} 

455 QToolButton {{ 

456 background-color: transparent; 

457 color: {c['text']}; 

458 border: 1px solid transparent; 

459 border-radius: 5px; 

460 padding: 3px; 

461 }} 

462 QToolButton:hover {{ background-color: {c['hover']}; border-color: {c['border']}; }} 

463 QToolButton:pressed, QToolButton:checked {{ background-color: {c['selection']}; border-color: {c['accent']}; }} 

464 QToolButton:disabled {{ color: {c['text_muted']}; }} 

465 QProgressBar {{ 

466 background-color: {c['surface_alt']}; 

467 border: 1px solid {c['border']}; 

468 border-radius: 5px; 

469 color: {c['text']}; 

470 text-align: center; 

471 }} 

472 QProgressBar::chunk {{ background-color: {c['accent']}; border-radius: 4px; }} 

473 

474 /* Sliders — thin rounded track, accent fill, clean circular knob. */ 

475 QSlider:horizontal {{ min-height: 20px; }} 

476 QSlider::groove:horizontal {{ 

477 height: 6px; 

478 background: {c['surface_alt']}; 

479 border: 1px solid {c['border']}; 

480 border-radius: 4px; 

481 }} 

482 QSlider::sub-page:horizontal {{ 

483 background: {c['accent']}; 

484 border: 1px solid {c['accent']}; 

485 border-radius: 4px; 

486 }} 

487 QSlider::add-page:horizontal {{ 

488 background: {c['surface_alt']}; 

489 border: 1px solid {c['border']}; 

490 border-radius: 4px; 

491 }} 

492 QSlider::handle:horizontal {{ 

493 background: {c['surface']}; 

494 border: 2px solid {c['accent']}; 

495 width: 14px; 

496 height: 14px; 

497 /* Centre the 18px knob box over the 8px groove box. */ 

498 margin: -6px 0; 

499 border-radius: 9px; 

500 }} 

501 QSlider::handle:horizontal:hover {{ background: {c['selection']}; }} 

502 QSlider::handle:horizontal:pressed {{ background: {c['accent']}; }} 

503 QSlider:vertical {{ min-width: 20px; }} 

504 QSlider::groove:vertical {{ 

505 width: 6px; 

506 background: {c['surface_alt']}; 

507 border: 1px solid {c['border']}; 

508 border-radius: 4px; 

509 }} 

510 QSlider::sub-page:vertical {{ 

511 background: {c['surface_alt']}; 

512 border: 1px solid {c['border']}; 

513 border-radius: 4px; 

514 }} 

515 QSlider::add-page:vertical {{ 

516 background: {c['accent']}; 

517 border: 1px solid {c['accent']}; 

518 border-radius: 4px; 

519 }} 

520 QSlider::handle:vertical {{ 

521 background: {c['surface']}; 

522 border: 2px solid {c['accent']}; 

523 width: 14px; 

524 height: 14px; 

525 margin: 0 -6px; 

526 border-radius: 9px; 

527 }} 

528 QSlider::handle:vertical:hover {{ background: {c['selection']}; }} 

529 

530 /* Scroll bars */ 

531 QScrollBar:vertical {{ 

532 background: transparent; 

533 width: 12px; 

534 margin: 0; 

535 }} 

536 QScrollBar::handle:vertical {{ 

537 background: {c['border']}; 

538 min-height: 24px; 

539 border-radius: 6px; 

540 }} 

541 QScrollBar::handle:vertical:hover {{ background: {c['text_muted']}; }} 

542 QScrollBar:horizontal {{ 

543 background: transparent; 

544 height: 12px; 

545 margin: 0; 

546 }} 

547 QScrollBar::handle:horizontal {{ 

548 background: {c['border']}; 

549 min-width: 24px; 

550 border-radius: 6px; 

551 }} 

552 QScrollBar::handle:horizontal:hover {{ background: {c['text_muted']}; }} 

553 QScrollBar::add-line, QScrollBar::sub-line {{ width: 0; height: 0; }} 

554 QScrollBar::add-page, QScrollBar::sub-page {{ background: none; }} 

555 

556 /* Tabs */ 

557 QTabBar::tab {{ 

558 background: transparent; 

559 color: {c['text_muted']}; 

560 border: none; 

561 border-bottom: 2px solid transparent; 

562 padding: 5px 12px; 

563 margin-right: 2px; 

564 }} 

565 QTabBar::tab:selected {{ 

566 color: {c['text']}; 

567 border-bottom-color: {c['accent']}; 

568 }} 

569 QTabBar::tab:hover:!selected {{ color: {c['text']}; background: {c['hover']}; }} 

570 

571 /* Keep Fusion's native checkbox/radio glyphs. Styling their indicator 

572 background in QSS hides the check mark on Windows. */ 

573 QCheckBox:disabled, QRadioButton:disabled {{ color: {c['text_muted']}; }} 

574 QTabWidget::pane {{ 

575 background: {c['surface']}; 

576 border: 1px solid {c['border']}; 

577 border-radius: 6px; 

578 top: -1px; 

579 }} 

580 QSplitter::handle {{ background-color: {c['border']}; }} 

581 QSplitter::handle:hover {{ background-color: {c['accent']}; }} 

582 QMessageBox, QFileDialog, QColorDialog, QInputDialog {{ background-color: {c['window']}; }} 

583 QLineEdit:focus, QPlainTextEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus {{ 

584 border-color: {c['accent']}; 

585 }} 

586 """ + (_mac_stylesheet(c) if IS_MAC else "") 

587 

588 

589def _mac_stylesheet(c): 

590 """macOS additions: menus, scroll bars and controls closer to AppKit.""" 

591 return f""" 

592 /* Context menus: rounded panel, inset rounded highlight, compact rows. */ 

593 QMenu {{ 

594 border-radius: 10px; 

595 padding: 5px; 

596 }} 

597 QMenu::item {{ 

598 padding: 4px 18px 4px 12px; 

599 border-radius: 5px; 

600 }} 

601 QMenu::separator {{ margin: 5px 10px; }} 

602 /* Thin overlay-style scroll bars with no visible track. */ 

603 QScrollBar:vertical {{ width: 10px; background: transparent; }} 

604 QScrollBar:horizontal {{ height: 10px; background: transparent; }} 

605 QScrollBar::handle:vertical {{ 

606 background: {_mix(c['text_muted'], c['window'], 0.55)}; 

607 border: 2px solid transparent; 

608 border-radius: 5px; 

609 min-height: 28px; 

610 margin: 1px; 

611 }} 

612 QScrollBar::handle:horizontal {{ 

613 background: {_mix(c['text_muted'], c['window'], 0.55)}; 

614 border-radius: 5px; 

615 min-width: 28px; 

616 margin: 1px; 

617 }} 

618 QScrollBar::handle:hover {{ background: {c['text_muted']}; }} 

619 /* Push buttons: AppKit-like height and radius. */ 

620 QPushButton {{ 

621 border-radius: 6px; 

622 padding: 4px 12px; 

623 }} 

624 QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox {{ border-radius: 6px; }} 

625 QGroupBox {{ border-radius: 9px; }} 

626 """ 

627 

628 

629def build_qpalette(name): 

630 """Build a QPalette so unstyled widget backgrounds follow the theme. 

631 

632 The global style sheet only paints the widgets it names; container 

633 backgrounds (scroll areas, list/table viewports, dialogs, input bases) 

634 come from the QPalette. Setting both is what makes dark mode complete. 

635 """ 

636 c = palette(name) 

637 pal = QPalette() 

638 window = QColor(c["window"]) 

639 surface = QColor(c["surface"]) 

640 text = QColor(c["text"]) 

641 muted = QColor(c["text_muted"]) 

642 disabled = QColor(_mix(c["text"], c["window"], 0.45)) 

643 pal.setColor(QPalette.ColorRole.Window, window) 

644 pal.setColor(QPalette.ColorRole.WindowText, text) 

645 pal.setColor(QPalette.ColorRole.Base, surface) 

646 pal.setColor(QPalette.ColorRole.AlternateBase, QColor(c["surface_alt"])) 

647 pal.setColor(QPalette.ColorRole.Text, text) 

648 pal.setColor(QPalette.ColorRole.Button, surface) 

649 pal.setColor(QPalette.ColorRole.ButtonText, text) 

650 pal.setColor(QPalette.ColorRole.ToolTipBase, surface) 

651 pal.setColor(QPalette.ColorRole.ToolTipText, text) 

652 pal.setColor(QPalette.ColorRole.PlaceholderText, muted) 

653 pal.setColor(QPalette.ColorRole.Highlight, QColor(c["accent"])) 

654 pal.setColor(QPalette.ColorRole.HighlightedText, QColor(c["accent_text"])) 

655 pal.setColor(QPalette.ColorRole.Link, QColor(c["accent"])) 

656 pal.setColor(QPalette.ColorRole.LinkVisited, QColor(c["accent_hover"])) 

657 pal.setColor(QPalette.ColorRole.BrightText, QColor(c["error"])) 

658 pal.setColor(QPalette.ColorRole.Light, QColor(_mix(c["surface"], "#ffffff", 0.78))) 

659 pal.setColor(QPalette.ColorRole.Midlight, QColor(c["surface_alt"])) 

660 pal.setColor(QPalette.ColorRole.Mid, QColor(c["border"])) 

661 pal.setColor(QPalette.ColorRole.Dark, QColor(_mix(c["border"], "#000000", 0.72))) 

662 pal.setColor(QPalette.ColorRole.Shadow, QColor("#000000")) 

663 for role in ( 

664 QPalette.ColorRole.WindowText, QPalette.ColorRole.Text, 

665 QPalette.ColorRole.ButtonText, 

666 ): 

667 pal.setColor(QPalette.ColorGroup.Disabled, role, disabled) 

668 return pal 

669 

670 

671def apply_theme(app, name=None, persist=False): 

672 """Apply the theme to the ``QApplication`` and optionally persist it.""" 

673 if name is None: 

674 name = current_theme() 

675 if name not in available_themes(): 

676 name = DEFAULT_THEME 

677 if persist: 

678 config.set_value(CONFIG_KEY, name) 

679 # Read the OS accent while the platform palette is still in place. 

680 system_accent() 

681 # Fusion honours QPalette consistently across platforms, which the native 

682 # Windows style does not — required for a complete dark theme. 

683 try: 

684 app.setStyle("Fusion") 

685 except Exception as exc: # noqa: BLE001 - Fusion is optional; theming degrades but must not crash startup 

686 log.debug("could not apply Fusion style, using platform default: %s", exc) 

687 app.setPalette(build_qpalette(name)) 

688 app.setStyleSheet(build_stylesheet(name)) 

689 app.setProperty("ui_theme", resolved_theme(name)) 

690 return name 

691 

692 

693def set_accent(app, color, persist=True): 

694 """Persist a new accent colour (or ``"system"``) and re-apply the styles.""" 

695 hexval = QColor(color).name() if not isinstance(color, str) else color 

696 if hexval != SYSTEM and not QColor(hexval).isValid(): 

697 return current_accent() 

698 if persist: 

699 config.set_value(ACCENT_KEY, hexval) 

700 app.setPalette(build_qpalette(current_theme())) 

701 app.setStyleSheet(build_stylesheet(current_theme())) 

702 return hexval 

703 

704 

705# --------------------------------------------------------------------------- 

706# Status bar 

707# --------------------------------------------------------------------------- 

708_SEVERITY_ICON = { 

709 "info": "ℹ", 

710 "success": "✓", 

711 "warning": "⚠", 

712 "error": "✕", 

713} 

714 

715 

716class StatusBar(QStatusBar): 

717 """Status bar with severity-coloured messages and an accent stripe. 

718 

719 Use :meth:`show_message` for level-aware messages. Plain 

720 ``showMessage(text, timeout)`` calls (used throughout the app) still work 

721 and are treated as ``info``. 

722 """ 

723 

724 def __init__(self, parent=None): 

725 super().__init__(parent) 

726 self.setSizeGripEnabled(False) 

727 

728 # Accent stripe on the far left signals severity at a glance. 

729 self._stripe = QFrame(self) 

730 self._stripe.setFixedWidth(4) 

731 self._stripe.setFrameShape(QFrame.Shape.NoFrame) 

732 

733 self._icon = QLabel(self) 

734 self._icon.setFixedWidth(16) 

735 self._icon.setAlignment(Qt.AlignmentFlag.AlignCenter) 

736 

737 self._text = QLabel(self) 

738 self._text.setSizePolicy( 

739 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred 

740 ) 

741 

742 container = QWidget(self) 

743 row = QHBoxLayout(container) 

744 row.setContentsMargins(0, 0, 0, 0) 

745 row.setSpacing(8) 

746 row.addWidget(self._stripe) 

747 row.addWidget(self._icon) 

748 row.addWidget(self._text, 1) 

749 # Permanent (not "normal") so Qt never hides it while a plain 

750 # showMessage() is active; the stretch squeezes out Qt's own text area 

751 # so legacy showMessage() calls surface through our label instead. 

752 self.addPermanentWidget(container, 1) 

753 

754 # Qt clears showMessage() text on timeout; mirror that into our label. 

755 self.messageChanged.connect(self._on_message_changed) 

756 

757 self._clear_timer = QTimer(self) 

758 self._clear_timer.setSingleShot(True) 

759 self._clear_timer.timeout.connect(self.clear_message) 

760 

761 self._current_level = "info" 

762 self._apply_level("info") 

763 self._sync_idle() 

764 

765 # -- public API -------------------------------------------------------- 

766 def show_message(self, message, level="info", timeout=4000): 

767 """Show ``message`` styled for ``level`` for ``timeout`` ms (0 = keep).""" 

768 if level not in _SEVERITY_ICON: 

769 level = "info" 

770 self._current_level = level 

771 self._apply_level(level) 

772 self._text.setText(message or "") 

773 self._sync_idle() 

774 self._clear_timer.stop() 

775 if timeout and timeout > 0: 

776 self._clear_timer.start(int(timeout)) 

777 

778 def clear_message(self): 

779 self._clear_timer.stop() 

780 self._text.clear() 

781 self._current_level = "info" 

782 self._apply_level("info") 

783 self._sync_idle() 

784 

785 def refresh_palette(self): 

786 """Re-read colours after a theme switch.""" 

787 self._apply_level(self._current_level) 

788 

789 # -- internals --------------------------------------------------------- 

790 def _on_message_changed(self, text): 

791 # Fires when other code calls showMessage()/clearMessage() directly. 

792 # Route it through our label so those legacy call sites keep working. 

793 if text: 

794 if self._text.text() != text: 794 ↛ 801line 794 didn't jump to line 801 because the condition on line 794 was always true

795 self._current_level = "info" 

796 self._apply_level("info") 

797 self._text.setText(text) 

798 else: 

799 if not self._clear_timer.isActive(): 799 ↛ 801line 799 didn't jump to line 801 because the condition on line 799 was always true

800 self._text.clear() 

801 self._sync_idle() 

802 

803 def _sync_idle(self): 

804 # With no message, hide the stripe and icon so the bar stays quiet. 

805 has_text = bool(self._text.text()) 

806 self._stripe.setVisible(has_text) 

807 self._icon.setVisible(has_text) 

808 

809 def _apply_level(self, level): 

810 from . import icons # icons imports this module for the palette 

811 

812 c = palette() 

813 color = c.get(level, c["info"]) 

814 self._stripe.setStyleSheet(f"background-color: {color}; border-radius: 2px;") 

815 dpr = self.devicePixelRatioF() 

816 self._icon.setPixmap(icons.pixmap(level, 14, color, color, dpr)) 

817 weight = "600" if level in ("warning", "error") else "500" 

818 self._text.setStyleSheet( 

819 f"color: {c['text'] if level == 'info' else color}; " 

820 f"font-weight: {weight};" 

821 ) 

822 self.setStyleSheet( 

823 f"QStatusBar {{ background-color: {c['statusbar']}; " 

824 f"border-top: 1px solid {c['border']}; }}" 

825 "QStatusBar::item { border: none; }" 

826 )