"""Parametrised application stylesheet. Supports 4 theme variants: light/dark x green/blue. build_stylesheet(palette) renders the QSS template with the given palette. APP_STYLESHEET is the default (light-green) for backward compatibility. """ from __future__ import annotations from dataclasses import dataclass, asdict import os import tempfile from PySide6.QtGui import QColor, QPalette @dataclass(frozen=True) class ThemePalette: # backgrounds bg_base: str bg_card: str bg_input: str bg_dialog: str bg_tooltip: str # text text_primary: str text_secondary: str text_muted: str text_on_accent: str # borders border: str border_light: str # accent family accent: str accent_dark: str accent_darker: str accent_border: str accent_bg_subtle: str accent_text: str # generic buttons btn_bg: str btn_border: str btn_hover: str btn_pressed: str # primary buttons btn_primary_bg: str btn_primary_hover: str btn_primary_pressed: str # title-tabs tab_bg: str tab_text: str tab_border: str tab_selected_bg: str tab_selected_text: str tab_hover_bg: str # content tabs ctab_pane_border: str ctab_pane_bg: str ctab_bg: str ctab_text: str ctab_border: str ctab_selected_bg: str ctab_selected_text: str ctab_hover_bg: str ctab_close_bg: str ctab_close_text: str ctab_close_hover_bg: str ctab_close_hover_text: str # status badges status_ready_border: str status_ready_bg: str status_ready_text: str status_warn_border: str status_warn_bg: str status_warn_text: str status_error_border: str status_error_bg: str status_error_text: str # input indicator indicator_bg: str indicator_border: str indicator_text: str indicator_active_bg: str indicator_active_border: str indicator_active_text: str # hero badge hero_badge_color: str hero_badge_bg: str hero_badge_border: str # icon colours (for qtawesome icons on regular buttons) icon_default: str icon_muted: str _LIGHT_GREEN = ThemePalette( bg_base="#e9edf2", bg_card="#ffffff", bg_input="#fdfefe", bg_dialog="#f8fafc", bg_tooltip="#1e293b", text_primary="#1f2937", text_secondary="#334155", text_muted="#64748b", text_on_accent="#ffffff", border="#cad3df", border_light="#d7e0eb", accent="#16a34a", accent_dark="#15803d", accent_darker="#166534", accent_border="#1d7a45", accent_bg_subtle="#dcfce7", accent_text="#166534", btn_bg="#ecf2f9", btn_border="#c7d4e3", btn_hover="#dce8f5", btn_pressed="#c8d9eb", btn_primary_bg="#0f766e", btn_primary_hover="#115e59", btn_primary_pressed="#134e4a", tab_bg="#166534", tab_text="#ecfdf5", tab_border="#1d7a45", tab_selected_bg="#f8fafc", tab_selected_text="#166534", tab_hover_bg="#1e6f3f", ctab_pane_border="#cad3df", ctab_pane_bg="#eef2f6", ctab_bg="#e8eef5", ctab_text="#334155", ctab_border="#c7d2df", ctab_selected_bg="#ffffff", ctab_selected_text="#0f172a", ctab_hover_bg="#f3f7fb", ctab_close_bg="#dcfce7", ctab_close_text="#166534", ctab_close_hover_bg="#fee2e2", ctab_close_hover_text="#b91c1c", status_ready_border="#86efac", status_ready_bg="#dcfce7", status_ready_text="#166534", status_warn_border="#f4d8a8", status_warn_bg="#fff7e6", status_warn_text="#8a5a00", status_error_border="#fca5a5", status_error_bg="#fef2f2", status_error_text="#991b1b", indicator_bg="#e2e8f0", indicator_border="#cbd5e1", indicator_text="#64748b", indicator_active_bg="#22c55e", indicator_active_border="#16a34a", indicator_active_text="#ffffff", hero_badge_color="#0f766e", hero_badge_bg="#d1fae5", hero_badge_border="#99f6e4", icon_default="#1f2937", icon_muted="#64748b", ) _LIGHT_BLUE = ThemePalette( bg_base="#e9edf2", bg_card="#ffffff", bg_input="#fdfefe", bg_dialog="#f8fafc", bg_tooltip="#1e293b", text_primary="#1f2937", text_secondary="#334155", text_muted="#64748b", text_on_accent="#ffffff", border="#cad3df", border_light="#d7e0eb", accent="#2563eb", accent_dark="#1d4ed8", accent_darker="#1e40af", accent_border="#3b82f6", accent_bg_subtle="#dbeafe", accent_text="#1e40af", btn_bg="#ecf2f9", btn_border="#c7d4e3", btn_hover="#dce8f5", btn_pressed="#c8d9eb", btn_primary_bg="#2563eb", btn_primary_hover="#1d4ed8", btn_primary_pressed="#1e40af", tab_bg="#1e40af", tab_text="#dbeafe", tab_border="#3b82f6", tab_selected_bg="#f8fafc", tab_selected_text="#1e40af", tab_hover_bg="#1e3a8a", ctab_pane_border="#cad3df", ctab_pane_bg="#eef2f6", ctab_bg="#e8eef5", ctab_text="#334155", ctab_border="#c7d2df", ctab_selected_bg="#ffffff", ctab_selected_text="#0f172a", ctab_hover_bg="#f3f7fb", ctab_close_bg="#dbeafe", ctab_close_text="#1e40af", ctab_close_hover_bg="#fee2e2", ctab_close_hover_text="#b91c1c", status_ready_border="#86efac", status_ready_bg="#dcfce7", status_ready_text="#166534", status_warn_border="#f4d8a8", status_warn_bg="#fff7e6", status_warn_text="#8a5a00", status_error_border="#fca5a5", status_error_bg="#fef2f2", status_error_text="#991b1b", indicator_bg="#e2e8f0", indicator_border="#cbd5e1", indicator_text="#64748b", indicator_active_bg="#3b82f6", indicator_active_border="#2563eb", indicator_active_text="#ffffff", hero_badge_color="#1d4ed8", hero_badge_bg="#dbeafe", hero_badge_border="#93c5fd", icon_default="#1f2937", icon_muted="#64748b", ) _DARK_GREEN = ThemePalette( bg_base="#1e1e2e", bg_card="#2a2a3c", bg_input="#33334a", bg_dialog="#252538", bg_tooltip="#f8fafc", text_primary="#e2e8f0", text_secondary="#cbd5e1", text_muted="#94a3b8", text_on_accent="#ffffff", border="#3f3f5a", border_light="#4a4a66", accent="#22c55e", accent_dark="#16a34a", accent_darker="#15803d", accent_border="#22c55e", accent_bg_subtle="#16382a", accent_text="#4ade80", btn_bg="#33334a", btn_border="#4a4a66", btn_hover="#3d3d56", btn_pressed="#4a4a66", btn_primary_bg="#16a34a", btn_primary_hover="#15803d", btn_primary_pressed="#166534", tab_bg="#15803d", tab_text="#d1fae5", tab_border="#22c55e", tab_selected_bg="#e2e8f0", tab_selected_text="#15803d", tab_hover_bg="#166534", ctab_pane_border="#3f3f5a", ctab_pane_bg="#252538", ctab_bg="#2a2a3c", ctab_text="#cbd5e1", ctab_border="#3f3f5a", ctab_selected_bg="#33334a", ctab_selected_text="#e2e8f0", ctab_hover_bg="#33334a", ctab_close_bg="#16382a", ctab_close_text="#4ade80", ctab_close_hover_bg="#4c1d1d", ctab_close_hover_text="#fca5a5", status_ready_border="#22c55e", status_ready_bg="#16382a", status_ready_text="#4ade80", status_warn_border="#ca8a04", status_warn_bg="#3b2f09", status_warn_text="#facc15", status_error_border="#ef4444", status_error_bg="#4c1d1d", status_error_text="#fca5a5", indicator_bg="#33334a", indicator_border="#4a4a66", indicator_text="#94a3b8", indicator_active_bg="#22c55e", indicator_active_border="#16a34a", indicator_active_text="#ffffff", hero_badge_color="#4ade80", hero_badge_bg="#16382a", hero_badge_border="#22c55e", icon_default="#e2e8f0", icon_muted="#94a3b8", ) _DARK_BLUE = ThemePalette( bg_base="#1e1e2e", bg_card="#2a2a3c", bg_input="#33334a", bg_dialog="#252538", bg_tooltip="#f8fafc", text_primary="#e2e8f0", text_secondary="#cbd5e1", text_muted="#94a3b8", text_on_accent="#ffffff", border="#3f3f5a", border_light="#4a4a66", accent="#3b82f6", accent_dark="#2563eb", accent_darker="#1d4ed8", accent_border="#3b82f6", accent_bg_subtle="#1e2a4a", accent_text="#60a5fa", btn_bg="#33334a", btn_border="#4a4a66", btn_hover="#3d3d56", btn_pressed="#4a4a66", btn_primary_bg="#2563eb", btn_primary_hover="#1d4ed8", btn_primary_pressed="#1e40af", tab_bg="#1d4ed8", tab_text="#dbeafe", tab_border="#3b82f6", tab_selected_bg="#e2e8f0", tab_selected_text="#1d4ed8", tab_hover_bg="#1e40af", ctab_pane_border="#3f3f5a", ctab_pane_bg="#252538", ctab_bg="#2a2a3c", ctab_text="#cbd5e1", ctab_border="#3f3f5a", ctab_selected_bg="#33334a", ctab_selected_text="#e2e8f0", ctab_hover_bg="#33334a", ctab_close_bg="#1e2a4a", ctab_close_text="#60a5fa", ctab_close_hover_bg="#4c1d1d", ctab_close_hover_text="#fca5a5", status_ready_border="#22c55e", status_ready_bg="#16382a", status_ready_text="#4ade80", status_warn_border="#ca8a04", status_warn_bg="#3b2f09", status_warn_text="#facc15", status_error_border="#ef4444", status_error_bg="#4c1d1d", status_error_text="#fca5a5", indicator_bg="#33334a", indicator_border="#4a4a66", indicator_text="#94a3b8", indicator_active_bg="#3b82f6", indicator_active_border="#2563eb", indicator_active_text="#ffffff", hero_badge_color="#60a5fa", hero_badge_bg="#1e2a4a", hero_badge_border="#3b82f6", icon_default="#e2e8f0", icon_muted="#94a3b8", ) PALETTES: dict[tuple[str, str], ThemePalette] = { ("light", "green"): _LIGHT_GREEN, ("light", "blue"): _LIGHT_BLUE, ("dark", "green"): _DARK_GREEN, ("dark", "blue"): _DARK_BLUE, } def get_palette(mode: str = "light", accent: str = "green") -> ThemePalette: return PALETTES.get((mode, accent), _LIGHT_GREEN) _QSS_TEMPLATE = """ QMainWindow#AppWindow {{ background: {bg_base}; }} QWidget#MainContainer {{ background: {bg_base}; border: none; }} QFrame#ContentArea {{ background: {bg_base}; border: none; }} QStackedWidget {{ background: {bg_base}; }} QScrollArea {{ background: {bg_base}; }} QScrollArea > QWidget > QWidget {{ background: {bg_base}; }} QWidget#TitleBar {{ background: {accent}; border-bottom: 1px solid {accent}; }} QLabel#TitleBarTitle {{ color: #f8fafc; font-size: 13px; font-weight: 600; }} QLabel#TitleBarIcon {{ background: transparent; }} QWidget#TitleTabsContainer {{ background: transparent; }} QTabBar#TitleTabs {{ background: transparent; }} QTabBar#TitleTabs::tab {{ background: {tab_bg}; color: {tab_text}; border: 1px solid {tab_border}; border-radius: 7px; padding: 4px 20px 4px 10px; margin-right: 4px; min-height: 18px; }} QTabBar#TitleTabs::tab:selected {{ background: {tab_selected_bg}; color: {tab_selected_text}; border-color: {accent_bg_subtle}; font-weight: 600; }} QTabBar#TitleTabs::tab:hover:!selected {{ background: {tab_hover_bg}; }} QTabBar#TitleTabs QToolButton[tabClose="true"] {{ background: transparent; border: none; border-radius: 7px; margin-right: 6px; padding: 0px; }} QTabBar#TitleTabs QToolButton[tabClose="true"]:hover {{ background: rgba(255, 255, 255, 0.25); }} QWidget#TabStatusDot {{ border-radius: 4px; min-width: 8px; min-height: 8px; max-width: 8px; max-height: 8px; }} QWidget#TabStatusDot[dotColor="green"] {{ background: #22c55e; }} QWidget#TabStatusDot[dotColor="yellow"] {{ background: #facc15; }} QWidget#TabStatusDot[dotColor="red"] {{ background: #ef4444; }} QWidget#TabStatusDot[dotColor="none"] {{ background: transparent; border: none; }} QWidget#TabRightContainer {{ background: transparent; }} QToolButton[windowControl="minimize"], QToolButton[windowControl="maximize"], QToolButton[windowControl="close"] {{ background: rgba(255, 255, 255, 0.12); color: #d8e4f1; border: none; border-radius: 6px; }} QToolButton[windowControl="minimize"]:hover, QToolButton[windowControl="maximize"]:hover, QToolButton[windowControl="close"]:hover {{ background: rgba(255, 255, 255, 0.25); }} QTabWidget::pane {{ border: 1px solid {ctab_pane_border}; border-radius: 8px; top: -1px; background: {ctab_pane_bg}; }} QTabBar::tab {{ background: {ctab_bg}; color: {ctab_text}; border: 1px solid {ctab_border}; border-bottom: none; padding: 6px 12px; margin-right: 2px; min-height: 25px; border-top-left-radius: 8px; border-top-right-radius: 8px; }} QTabBar::tab:selected {{ background: {ctab_selected_bg}; border-color: {ctab_pane_border}; color: {ctab_selected_text}; font-weight: 600; }} QTabBar::tab:hover:!selected {{ background: {ctab_hover_bg}; }} QToolButton[tabClose="true"] {{ background: {ctab_close_bg}; color: {ctab_close_text}; border: none; border-radius: 7px; padding: 0px; font-size: 11px; font-weight: 600; }} QToolButton[tabClose="true"]:hover {{ background: {ctab_close_hover_bg}; color: {ctab_close_hover_text}; }} QToolButton[tabClose="true"]:pressed {{ background: {ctab_close_hover_bg}; color: {ctab_close_hover_text}; }} QFrame#HeroCard, QFrame#PanelCard {{ background: {bg_card}; border: 1px solid {border_light}; border-radius: 14px; }} QLabel#HeroBadge {{ color: {hero_badge_color}; background: {hero_badge_bg}; border: 1px solid {hero_badge_border}; border-radius: 9px; padding: 4px 10px; font-weight: 600; }} QLabel#HeroTitle {{ color: {text_primary}; font-size: 22px; font-weight: 700; }} QLabel#HeroHint {{ color: {text_muted}; font-size: 13px; }} QLabel#StatusBadge {{ border-radius: 10px; border: 1px solid {status_error_border}; background: {status_error_bg}; color: {status_error_text}; padding: 6px 10px; font-weight: 600; }} QLabel#StatusBadge[state="need-auth"] {{ border-color: {status_warn_border}; background: {status_warn_bg}; color: {status_warn_text}; }} QLabel#StatusBadge[state="ready"] {{ border-color: {status_ready_border}; background: {status_ready_bg}; color: {status_ready_text}; }} QPushButton#ReconnectBtn {{ background: transparent; border: 1px solid {border}; border-radius: 6px; padding: 4px; }} QPushButton#ReconnectBtn:hover {{ background: {btn_bg}; }} QPushButton#ReconnectBtn:pressed {{ background: {border}; }} QLabel#SectionTitle {{ color: {text_secondary}; font-size: 15px; font-weight: 700; }} QGroupBox {{ border: 1px solid {border}; border-radius: 10px; margin-top: 10px; padding-top: 10px; color: {text_secondary}; font-weight: 600; }} QGroupBox::title {{ subcontrol-origin: margin; left: 10px; padding: 0 6px; color: {text_muted}; font-size: 12px; }} QLabel {{ color: {text_primary}; }} QComboBox {{ background: {bg_input}; border: 1px solid {border}; border-radius: 8px; padding: 6px 28px 6px 10px; min-height: 18px; color: {text_primary}; selection-background-color: {btn_primary_bg}; selection-color: {text_on_accent}; }} QComboBox:hover {{ border-color: {text_muted}; }} QComboBox:focus {{ border-color: {btn_primary_bg}; }} QComboBox::drop-down {{ subcontrol-origin: padding; subcontrol-position: center right; width: 24px; border: none; border-left: 1px solid {border}; border-top-right-radius: 8px; border-bottom-right-radius: 8px; background: {btn_bg}; }} QComboBox::drop-down:hover {{ background: {btn_hover}; }} QComboBox::down-arrow {{ image: url("{_arrow_down_svg}"); width: 10px; height: 10px; }} QComboBox::down-arrow:on {{ image: url("{_arrow_up_svg}"); }} QComboBox QAbstractItemView {{ background: {bg_card}; color: {text_primary}; border: 1px solid {border}; selection-background-color: {btn_primary_bg}; selection-color: {text_on_accent}; outline: 0; }} QPushButton {{ background: {btn_bg}; border: 1px solid {btn_border}; border-radius: 9px; color: {text_primary}; padding: 7px 12px; font-weight: 600; }} QPushButton:hover {{ background: {btn_hover}; }} QPushButton:pressed {{ background: {btn_pressed}; }} QPushButton[role="primary"] {{ background: {btn_primary_bg}; border-color: {btn_primary_bg}; color: {text_on_accent}; }} QPushButton[role="primary"]:hover {{ background: {btn_primary_hover}; }} QPushButton[role="primary"]:pressed {{ background: {btn_primary_pressed}; }} QPushButton[role="ghost"] {{ background: {bg_card}; border-color: {border}; color: {text_muted}; }} QMessageBox, QDialog {{ background: {bg_dialog}; color: {text_primary}; }} QMessageBox QLabel {{ color: {text_primary}; font-size: 13px; }} QMessageBox QPushButton {{ background: {btn_bg}; border: 1px solid {btn_border}; border-radius: 9px; color: {text_primary}; padding: 7px 18px; font-weight: 600; min-width: 70px; }} QMessageBox QPushButton:hover {{ background: {btn_hover}; }} QMessageBox QPushButton:pressed {{ background: {btn_pressed}; }} QLabel#InputIndicator {{ background: {indicator_bg}; border: 1px solid {indicator_border}; border-radius: 6px; color: {indicator_text}; font-size: 11px; font-weight: 600; }} QLabel#InputIndicator[active="true"] {{ background: {indicator_active_bg}; border-color: {indicator_active_border}; color: {indicator_active_text}; }} QLabel#LogLabel {{ color: {text_muted}; font-size: 12px; padding: 4px; }} QSpinBox {{ background: {bg_input}; border: 1px solid {border}; border-radius: 6px; padding: 4px 22px 4px 6px; color: {text_primary}; min-height: 18px; }} QSpinBox:focus {{ border-color: {btn_primary_bg}; }} QSpinBox::up-button {{ subcontrol-origin: border; subcontrol-position: top right; width: 20px; border: none; border-left: 1px solid {border}; border-top-right-radius: 6px; background: {btn_bg}; }} QSpinBox::up-button:hover {{ background: {btn_hover}; }} QSpinBox::up-button:pressed {{ background: {btn_pressed}; }} QSpinBox::up-arrow {{ image: url("{_arrow_up_svg}"); width: 8px; height: 8px; }} QSpinBox::down-button {{ subcontrol-origin: border; subcontrol-position: bottom right; width: 20px; border: none; border-left: 1px solid {border}; border-bottom-right-radius: 6px; background: {btn_bg}; }} QSpinBox::down-button:hover {{ background: {btn_hover}; }} QSpinBox::down-button:pressed {{ background: {btn_pressed}; }} QSpinBox::down-arrow {{ image: url("{_arrow_down_svg}"); width: 8px; height: 8px; }} QToolButton#AccentSwatch {{ border: 2px solid {border}; border-radius: 16px; min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px; }} QToolButton#AccentSwatch:checked {{ border-color: {text_primary}; border-width: 3px; }} QPushButton:checked {{ background: {accent_bg_subtle}; border-color: {accent}; color: {accent_text}; }} QTextEdit, QPlainTextEdit {{ background: {bg_input}; color: {text_primary}; border: 1px solid {border}; border-radius: 6px; padding: 4px; }} QLineEdit {{ background: {bg_input}; color: {text_primary}; border: 1px solid {border}; border-radius: 8px; padding: 6px 10px; min-height: 18px; }} QLineEdit:focus {{ border-color: {btn_primary_bg}; }} QProgressBar {{ background: {bg_input}; border: 1px solid {border}; border-radius: 6px; text-align: center; color: {text_primary}; min-height: 16px; }} QProgressBar::chunk {{ background: {accent}; border-radius: 5px; }} QScrollBar:vertical {{ background: transparent; width: 8px; margin: 0; }} QScrollBar::handle:vertical {{ background: {border}; border-radius: 4px; min-height: 24px; }} QScrollBar::handle:vertical:hover {{ background: {text_muted}; }} QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} QScrollBar:horizontal {{ background: transparent; height: 8px; margin: 0; }} QScrollBar::handle:horizontal {{ background: {border}; border-radius: 4px; min-width: 24px; }} QScrollBar::handle:horizontal:hover {{ background: {text_muted}; }} QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }} /* ── Checkbox ─────────────────────────────────── */ QCheckBox {{ color: {text_primary}; spacing: 6px; }} /* ── PRO-mode toggle ──────────────────────────── */ QCheckBox#ProToggle {{ color: {text_muted}; font-size: 12px; spacing: 6px; }} QCheckBox#ProToggle::indicator {{ width: 36px; height: 20px; border-radius: 10px; background: {border}; border: 1px solid {border_light}; }} QCheckBox#ProToggle::indicator:checked {{ background: {accent}; border-color: {accent_dark}; }} /* ── Collapsible section header ───────────────── */ QPushButton#CollapsibleHeader {{ background: {btn_bg}; border: 1px solid {border}; border-radius: 8px; color: {text_primary}; padding: 8px 12px; font-weight: 700; font-size: 13px; text-align: left; }} QPushButton#CollapsibleHeader:hover {{ background: {btn_hover}; border-color: {text_muted}; }} QPushButton#CollapsibleHeader:pressed {{ background: {btn_pressed}; }} """ _ARROW_SVG_DIR: str | None = None def _arrow_svg_path(name: str, color: str, points: str) -> str: """Создать SVG-файл стрелки и вернуть путь (forward-slash для QSS).""" global _ARROW_SVG_DIR # noqa: PLW0603 if _ARROW_SVG_DIR is None: _ARROW_SVG_DIR = tempfile.mkdtemp(prefix="plate_arrows_") # Уникальное имя: name + hex-цвет без # fname = f"{name}_{color.lstrip('#')}.svg" fpath = os.path.join(_ARROW_SVG_DIR, fname) if not os.path.exists(fpath): svg = ( '' f'' '' ) with open(fpath, "w", encoding="utf-8") as f: f.write(svg) return fpath.replace("\\", "/") def create_qpalette(palette: ThemePalette) -> QPalette: qpal = QPalette() qpal.setColor(QPalette.Window, QColor(palette.bg_base)) qpal.setColor(QPalette.WindowText, QColor(palette.text_primary)) qpal.setColor(QPalette.Base, QColor(palette.bg_card)) qpal.setColor(QPalette.AlternateBase, QColor(palette.bg_input)) qpal.setColor(QPalette.ToolTipBase, QColor(palette.bg_tooltip)) qpal.setColor(QPalette.ToolTipText, QColor( palette.text_on_accent if "#1e" in palette.bg_tooltip or "#0" in palette.bg_tooltip else palette.text_primary )) qpal.setColor(QPalette.Text, QColor(palette.text_primary)) qpal.setColor(QPalette.Button, QColor(palette.btn_bg)) qpal.setColor(QPalette.ButtonText, QColor(palette.text_primary)) qpal.setColor(QPalette.Highlight, QColor(palette.accent)) qpal.setColor(QPalette.HighlightedText, QColor(palette.text_on_accent)) return qpal def build_stylesheet(palette: ThemePalette) -> str: d = asdict(palette) # Генерируем SVG-стрелки muted = d["text_muted"] d["_arrow_down_svg"] = _arrow_svg_path("arrow_down", muted, "2,4 6,8 10,4") d["_arrow_up_svg"] = _arrow_svg_path("arrow_up", muted, "2,8 6,4 10,8") return _QSS_TEMPLATE.format(**d) APP_STYLESHEET = build_stylesheet(_LIGHT_GREEN)