Coverage for paintmaskanimator/theme.py: 76%
221 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« 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.
3This module owns two things:
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.
14The chosen theme is persisted through :mod:`.config` so it survives restarts.
15"""
16import sys
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)
24from . import config
25from .i18n import tr
26from .logging_setup import get_logger
28log = get_logger(__name__)
30IS_MAC = sys.platform == "darwin"
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
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
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 )
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}
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}
164def available_themes():
165 """Theme settings offered in the menu: follow the OS, or a fixed palette."""
166 return (SYSTEM,) + tuple(PALETTES.keys())
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"
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
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
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()
209def _contrast_text(background):
210 """Return black or white, whichever is more legible on ``background``."""
211 color = QColor(background)
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
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"
227# テーマ×アクセントごとの配色。タイムラインはセル1つ描くたびに palette() を
228# 引くので、混色の計算を毎回やり直さない。
229_palette_cache = {}
232def palette(name=None):
233 """Return the colour dict for ``name`` (or the active theme).
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)
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
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
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
276def current_accent():
277 value = accent_setting()
278 return system_accent() if value == SYSTEM else value
281def accent():
282 """Convenience alias for the active accent colour (hex string)."""
283 return current_accent()
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 }}
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 }}
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']}; }}
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 }}
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 }}
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; }}
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']}; }}
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; }}
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']}; }}
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 "")
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 """
629def build_qpalette(name):
630 """Build a QPalette so unstyled widget backgrounds follow the theme.
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
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
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
705# ---------------------------------------------------------------------------
706# Status bar
707# ---------------------------------------------------------------------------
708_SEVERITY_ICON = {
709 "info": "ℹ",
710 "success": "✓",
711 "warning": "⚠",
712 "error": "✕",
713}
716class StatusBar(QStatusBar):
717 """Status bar with severity-coloured messages and an accent stripe.
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 """
724 def __init__(self, parent=None):
725 super().__init__(parent)
726 self.setSizeGripEnabled(False)
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)
733 self._icon = QLabel(self)
734 self._icon.setFixedWidth(16)
735 self._icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
737 self._text = QLabel(self)
738 self._text.setSizePolicy(
739 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
740 )
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)
754 # Qt clears showMessage() text on timeout; mirror that into our label.
755 self.messageChanged.connect(self._on_message_changed)
757 self._clear_timer = QTimer(self)
758 self._clear_timer.setSingleShot(True)
759 self._clear_timer.timeout.connect(self.clear_message)
761 self._current_level = "info"
762 self._apply_level("info")
763 self._sync_idle()
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))
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()
785 def refresh_palette(self):
786 """Re-read colours after a theme switch."""
787 self._apply_level(self._current_level)
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()
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)
809 def _apply_level(self, level):
810 from . import icons # icons imports this module for the palette
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 )