The picker was hidden outright at 54px, on the grounds that a combo box there could only show a stub of a name. True, but it left the collapsed rail with no way to change project at all — and the audit page's own inventory of the 11 collapsible controls says the rail's fold "giữ nguyên toàn bộ". A folder QToolButton stands in for it: same items, and choosing one moves the combo, so _on_rail_project_pick stays the single code path. Its tooltip carries the current project name, which the combo could not have shown anyway. It matches the + button's width and drops its menu arrow — at 42px the arrow would take a third of the row to say nothing. check_nav now collapses the rail and requires a reachable picker whose menu matches the combo item for item, then picks a row and asserts the project actually changed. Hiding the button fails it. 15/15 checkers pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
782 lines
34 KiB
Python
782 lines
34 KiB
Python
"""The app's visual system: semantic design tokens + one style sheet template.
|
|
|
|
This replaces the previous mechanism, which was two hand-written Qt style
|
|
sheets (``_DARK`` / ``_LIGHT``) that duplicated each other and hard-coded ~105
|
|
hex literals, with a single source of truth:
|
|
|
|
Palette (tokens) -> _TEMPLATE (one QSS) -> stylesheet(theme)
|
|
|
|
Rules of the system
|
|
-------------------
|
|
* **Nothing outside this module names a colour.** Widgets that paint with
|
|
``QPainter`` (charts, canvases, syntax highlighters) call :func:`palette` and
|
|
read a token. Widgets that style themselves declaratively should instead be
|
|
given an ``objectName`` and styled in ``_TEMPLATE`` below.
|
|
* **Tokens are semantic, not literal.** ``danger``/``text_muted``/``code_string``
|
|
— never ``blue``/``grey2``. Adding a theme means adding a :class:`Palette`,
|
|
not editing a style sheet.
|
|
* **No gradients, no glows.** The palette is Visual Studio Code's — "Dark
|
|
Modern" and "Light Modern", taken from the shipped theme JSON. Flat surfaces,
|
|
square-ish corners, one accent spent only on what the user acts on. Depth
|
|
comes from the surface ramp and hairline borders, not from colour. Note the
|
|
VS Code silhouette: the nav rail is *darker* than the content area, not
|
|
lighter.
|
|
|
|
Contrast is held to WCAG AA (4.5:1) for body text and for text on filled
|
|
buttons. That is why ``accent`` and ``accent_solid`` are separate tokens: on a
|
|
dark background a blue readable *as text* is too light to carry white *as a
|
|
fill*, so each role gets the tint that passes.
|
|
|
|
Four VS Code values fall below AA and are nudged just far enough to clear it —
|
|
dark line numbers (3.59:1), light faint text on the sidebar (4.28:1), light
|
|
green (4.33:1) and light amber (3.12:1). Each carries a comment naming the
|
|
original value, so the deviation is auditable rather than silent.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, asdict
|
|
from string import Template
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Palette:
|
|
"""Every colour and shape value the interface is allowed to use."""
|
|
|
|
name: str
|
|
|
|
# --- surfaces: a 4-step ramp from the window back to the frontmost layer.
|
|
bg: str # window / canvas backdrop
|
|
surface: str # panels, cards, group boxes (NOT the nav rail)
|
|
surface_raised: str # inputs, lists, trees — things you type or pick in
|
|
overlay: str # menus, tooltips, popups (floats above everything)
|
|
sunken: str # logs, code, terminals — things you read into
|
|
hover: str # hover wash on rows, tabs, ghost buttons
|
|
active: str # pressed / held state
|
|
|
|
# The nav rail gets its own step rather than borrowing `surface`. It is a
|
|
# permanent region of the window, not a card floating on the page.
|
|
#
|
|
# Following VS Code, the rail is *darker* than the content area (dark) or a
|
|
# shade off white (light). The step is small on purpose — VS Code separates
|
|
# the rail with a border, not a big tonal jump — so `nav_border` is doing
|
|
# real work here and must stay visible.
|
|
nav_bg: str
|
|
nav_border: str
|
|
nav_hover: str
|
|
nav_selected: str
|
|
|
|
# --- lines
|
|
border: str # default hairline
|
|
border_strong: str # hairline that must survive next to a filled surface
|
|
focus_ring: str # keyboard/typing focus
|
|
|
|
# --- text
|
|
text: str
|
|
text_muted: str # secondary copy, captions, group-box titles
|
|
text_faint: str # metadata, timestamps, placeholder
|
|
text_disabled: str
|
|
on_accent: str # text drawn on top of a filled accent/status surface
|
|
|
|
# --- accent: `accent` tints text & icons, `accent_solid` fills buttons.
|
|
accent: str
|
|
accent_solid: str
|
|
accent_solid_hover: str
|
|
accent_solid_active: str
|
|
accent_soft: str # translucent wash for selected rows (QSS only)
|
|
accent_soft_hover: str
|
|
accent_wash: str # the same tint pre-blended to a solid, for Qt rich
|
|
# text (bgcolor=, <table>) where alpha is ignored
|
|
|
|
# --- status
|
|
success: str
|
|
success_soft: str
|
|
warning: str
|
|
warning_soft: str
|
|
danger: str
|
|
danger_solid: str
|
|
danger_solid_hover: str
|
|
danger_soft: str
|
|
info: str
|
|
info_soft: str
|
|
purple: str
|
|
purple_soft: str
|
|
pink: str
|
|
pink_soft: str
|
|
|
|
# --- selection (text selection inside editors and inputs)
|
|
selection_bg: str
|
|
selection_fg: str
|
|
|
|
# --- scrollbars
|
|
scroll_handle: str
|
|
scroll_handle_hover: str
|
|
|
|
# --- code & terminal
|
|
code_bg: str
|
|
code_fg: str
|
|
code_gutter_bg: str
|
|
code_gutter_fg: str
|
|
code_selection: str
|
|
code_comment: str
|
|
code_keyword: str
|
|
code_type: str
|
|
code_func: str
|
|
code_attr: str
|
|
code_string: str
|
|
code_number: str
|
|
code_error: str
|
|
|
|
# --- diff / inline change badges
|
|
diff_add_bg: str
|
|
diff_add_fg: str
|
|
diff_del_bg: str
|
|
diff_del_fg: str
|
|
|
|
# --- charts
|
|
chart_grid: str
|
|
chart_label: str
|
|
|
|
# --- conversation & graph node roles
|
|
role_user: str
|
|
role_assistant: str
|
|
role_tool: str
|
|
role_result: str
|
|
role_error: str
|
|
|
|
# --- shape & type
|
|
radius_sm: int
|
|
radius: int
|
|
radius_lg: int
|
|
font_family: str
|
|
font_size: int
|
|
font_mono: str
|
|
|
|
|
|
_FONT = '"Segoe UI Variable Text", "Segoe UI", "Inter", "Helvetica Neue", "Yu Gothic UI", "Meiryo", sans-serif'
|
|
_MONO = '"Cascadia Code", "JetBrains Mono", "Consolas", "SF Mono", monospace'
|
|
|
|
|
|
DARK = Palette(
|
|
name="dark",
|
|
# ---- VS Code "Dark Modern" ----------------------------------------------
|
|
# Values taken from the shipped theme JSON. Where VS Code's own choice falls
|
|
# below WCAG AA it is nudged just far enough to pass; each such value carries
|
|
# a note with VS Code's original and the measured ratio.
|
|
bg="#1F1F1F", # editor.background
|
|
surface="#252526", # panel / card
|
|
surface_raised="#313131", # input.background
|
|
overlay="#252526", # menus, tooltips
|
|
sunken="#181818", # logs, terminals — below the ramp
|
|
hover="#2A2D2E", # list.hoverBackground
|
|
active="#37373D", # list.inactiveSelectionBackground
|
|
# The sidebar is DARKER than the editor — that is the VS Code silhouette.
|
|
nav_bg="#181818", # sideBar.background
|
|
nav_border="#2B2B2B", # sideBar.border
|
|
nav_hover="#2A2D2E",
|
|
nav_selected="#04395E", # list.activeSelectionBackground
|
|
border="#2B2B2B", # panel.border
|
|
border_strong="#3C3C3C", # input.border
|
|
focus_ring="#0078D4", # focusBorder
|
|
text="#CCCCCC", # editor.foreground
|
|
text_muted="#9D9D9D", # descriptionForeground
|
|
text_faint="#9A9A9A", # lifted: #8B8B8B was 3.82:1 on input surfaces
|
|
text_disabled="#5A5A5A",
|
|
on_accent="#FFFFFF",
|
|
accent="#4DAAFC", # textLink.foreground — accent as TEXT
|
|
accent_solid="#0078D4", # button.background — accent as FILL
|
|
accent_solid_hover="#026EC1",
|
|
accent_solid_active="#005FB8",
|
|
accent_soft="rgba(0,120,212,0.22)",
|
|
accent_soft_hover="rgba(0,120,212,0.32)",
|
|
accent_wash="#12283C", # pre-blended: Qt rich text ignores alpha
|
|
success="#89D185", # gitDecoration added
|
|
success_soft="rgba(137,209,133,0.16)",
|
|
warning="#CCA700", # editorWarning
|
|
warning_soft="rgba(204,167,0,0.16)",
|
|
danger="#F76464", # editorError #F14C4C lifted (4.29:1 on panels)
|
|
danger_solid="#C4302B",
|
|
danger_solid_hover="#D9433C",
|
|
danger_soft="rgba(241,76,76,0.16)",
|
|
info="#4DAAFC",
|
|
info_soft="rgba(77,170,252,0.16)",
|
|
purple="#C586C0", # Dark+ syntax purple
|
|
purple_soft="rgba(197,134,192,0.16)",
|
|
pink="#D16D9E",
|
|
pink_soft="rgba(209,109,158,0.16)",
|
|
selection_bg="#264F78", # editor.selectionBackground
|
|
selection_fg="#FFFFFF",
|
|
scroll_handle="#4E4E4E", # scrollbarSlider
|
|
scroll_handle_hover="#5A5A5A",
|
|
code_bg="#1F1F1F",
|
|
code_fg="#CCCCCC",
|
|
code_gutter_bg="#1F1F1F",
|
|
# VS Code uses #6E7681 for line numbers — only 3.59:1. Lifted to clear AA.
|
|
code_gutter_fg="#858D97",
|
|
code_selection="#264F78",
|
|
code_comment="#6A9955", # ---- Dark+ syntax, unchanged --------------
|
|
code_keyword="#569CD6",
|
|
code_type="#4EC9B0",
|
|
code_func="#DCDCAA",
|
|
code_attr="#9CDCFE",
|
|
code_string="#CE9178",
|
|
code_number="#B5CEA8",
|
|
code_error="#F44747",
|
|
diff_add_bg="#1B3A1B", # diffEditor inserted, pre-blended
|
|
diff_add_fg="#89D185",
|
|
diff_del_bg="#4B1818", # diffEditor removed, pre-blended
|
|
diff_del_fg="#F76464",
|
|
chart_grid="#2B2B2B",
|
|
chart_label="#9D9D9D",
|
|
role_user="#4DAAFC",
|
|
role_assistant="#4EC9B0",
|
|
role_tool="#C586C0",
|
|
role_result="#89D185",
|
|
role_error="#F14C4C",
|
|
radius_sm=3, # VS Code is squarer than the previous look
|
|
radius=4,
|
|
radius_lg=6,
|
|
font_family=_FONT,
|
|
font_size=13,
|
|
font_mono=_MONO,
|
|
)
|
|
|
|
|
|
LIGHT = Palette(
|
|
name="light",
|
|
# ---- VS Code "Light Modern" ---------------------------------------------
|
|
bg="#FFFFFF", # editor.background
|
|
surface="#F8F8F8", # sideBar / panel
|
|
surface_raised="#FFFFFF", # input.background
|
|
overlay="#FFFFFF",
|
|
sunken="#F3F3F3",
|
|
hover="#F2F2F2", # list.hoverBackground
|
|
active="#E8E8E8", # list.activeSelectionBackground
|
|
nav_bg="#F8F8F8", # sideBar.background
|
|
nav_border="#E5E5E5", # sideBar.border
|
|
nav_hover="#F2F2F2",
|
|
nav_selected="#E4E6F1", # active row, tinted toward the accent
|
|
border="#E5E5E5",
|
|
border_strong="#CECECE", # input.border
|
|
focus_ring="#005FB8", # focusBorder
|
|
text="#3B3B3B", # editor.foreground
|
|
text_muted="#616161",
|
|
# VS Code uses #767676 — 4.28:1 on the sidebar. Darkened to clear AA there.
|
|
text_faint="#6E6E6E",
|
|
text_disabled="#A0A0A0",
|
|
on_accent="#FFFFFF",
|
|
accent="#005FB8", # textLink / button
|
|
accent_solid="#005FB8",
|
|
accent_solid_hover="#0258A8",
|
|
accent_solid_active="#004C97",
|
|
accent_soft="rgba(0,95,184,0.10)",
|
|
accent_soft_hover="rgba(0,95,184,0.16)",
|
|
accent_wash="#E6EEF8",
|
|
# VS Code green #388A34 is 4.33:1 and amber #BF8803 only 3.12:1 — both lifted.
|
|
success="#317A2D",
|
|
success_soft="#DFF3DE",
|
|
warning="#8F6500", # VS Code #BF8803 = 3.12:1
|
|
warning_soft="#FBF0D0",
|
|
danger="#CD3131", # editorError
|
|
danger_solid="#CD3131",
|
|
danger_solid_hover="#B82A2A",
|
|
danger_soft="#FDEFEF", # lightened so #CD3131 clears AA on it
|
|
info="#005FB8",
|
|
info_soft="#DDEBF9",
|
|
purple="#6F42C1",
|
|
purple_soft="#EDE7FA",
|
|
pink="#B3247E",
|
|
pink_soft="#FAE3F0",
|
|
selection_bg="#ADD6FF", # editor.selectionBackground
|
|
selection_fg="#000000",
|
|
scroll_handle="#C1C1C1",
|
|
scroll_handle_hover="#A6A6A6",
|
|
code_bg="#FFFFFF",
|
|
code_fg="#3B3B3B",
|
|
code_gutter_bg="#F8F8F8",
|
|
code_gutter_fg="#656C76", # VS Code #6E7681 = 4.33:1 on the gutter
|
|
code_selection="#ADD6FF",
|
|
code_comment="#008000", # ---- Light+ syntax ------------------------
|
|
code_keyword="#0000FF",
|
|
code_type="#267F99",
|
|
code_func="#795E26",
|
|
code_attr="#E50000",
|
|
code_string="#A31515",
|
|
code_number="#098658",
|
|
code_error="#CD3131",
|
|
diff_add_bg="#DBF4DB",
|
|
diff_add_fg="#1E6F1A",
|
|
diff_del_bg="#FBE3E3",
|
|
diff_del_fg="#B82A2A",
|
|
chart_grid="#E5E5E5",
|
|
chart_label="#616161",
|
|
role_user="#005FB8",
|
|
role_assistant="#267F99",
|
|
role_tool="#6F42C1",
|
|
role_result="#317A2D",
|
|
role_error="#CD3131",
|
|
radius_sm=3,
|
|
radius=4,
|
|
radius_lg=6,
|
|
font_family=_FONT,
|
|
font_size=13,
|
|
font_mono=_MONO,
|
|
)
|
|
|
|
|
|
_PALETTES = {"dark": DARK, "light": LIGHT}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The one style sheet. `$token` placeholders are filled from the Palette above;
|
|
# use `${token}px` where a unit follows the name.
|
|
#
|
|
# Read it as a cascade: reset -> shell -> surfaces -> controls -> chrome.
|
|
# ---------------------------------------------------------------------------
|
|
_TEMPLATE = Template("""
|
|
/* ---- reset ------------------------------------------------------------ */
|
|
* { font-family: $font_family; font-size: ${font_size}px; }
|
|
QWidget { background: $bg; color: $text; }
|
|
QMainWindow::separator { background: $border; width: 1px; height: 1px; }
|
|
QSplitter::handle { background: $border; }
|
|
QSplitter::handle:horizontal { width: 1px; }
|
|
QSplitter::handle:vertical { height: 1px; }
|
|
QSplitter::handle:hover { background: $border_strong; }
|
|
QStatusBar { background: $bg; color: $text_faint; border-top: 1px solid $border; }
|
|
QToolTip {
|
|
background: $overlay; color: $text; border: 1px solid $border_strong;
|
|
border-radius: ${radius}px; padding: 5px 9px;
|
|
}
|
|
|
|
/* Icons are drawn at text scale, not as decoration. */
|
|
QPushButton, QToolButton, QComboBox, QTabBar { qproperty-iconSize: 15px 15px; }
|
|
QTreeWidget#navrail { qproperty-iconSize: 22px 16px; }
|
|
|
|
/* ---- shell ------------------------------------------------------------ */
|
|
QWidget#topbar { background: $bg; border: none; border-bottom: 1px solid $border; }
|
|
QLabel#brand { color: $text; font-size: 15px; font-weight: 700; background: transparent; }
|
|
QWidget#navWrap { background: $nav_bg; border-right: 1px solid $nav_border; }
|
|
QWidget#navWrap QTreeWidget, QWidget#navWrap QListWidget { background: transparent; border: none; }
|
|
QWidget#contentArea { background: $bg; }
|
|
|
|
/* Rail rows sit on nav_bg, so they need their own hover/selected steps — the
|
|
generic ones are tuned against `bg` and wash out here. The active item also
|
|
carries a 2px accent marker, so which section you are in survives even at a
|
|
glance or for anyone who cannot separate the two greys. */
|
|
QWidget#navWrap QTreeWidget::item, QWidget#navWrap QListWidget::item {
|
|
padding: 6px 4px; border-radius: ${radius}px;
|
|
}
|
|
QWidget#navWrap QTreeWidget::item:hover, QWidget#navWrap QListWidget::item:hover {
|
|
background: $nav_hover;
|
|
}
|
|
QWidget#navWrap QTreeWidget::item:selected, QWidget#navWrap QListWidget::item:selected {
|
|
background: $nav_selected; color: $text;
|
|
border-left: 2px solid $accent; font-weight: 600;
|
|
}
|
|
/* Rows the project gate is holding shut: still listed, visibly not open. */
|
|
QWidget#navWrap QTreeWidget::item:disabled { color: $text_faint; }
|
|
/* The pinned bottom group (Dashboard, Monitoring) — one hairline separates it
|
|
from the list above so "occasional" reads apart from "everyday". */
|
|
QTreeWidget#navrailBottom { border-top: 1px solid $nav_border; }
|
|
QScrollArea#navScroll { background: transparent; border: none; }
|
|
QScrollArea#navScroll > QWidget > QWidget { background: transparent; }
|
|
/* Monitoring ▸ Overview reads as titled sections down one column, the way the
|
|
audit page draws it — a quiet caps heading with the content flat underneath,
|
|
not six bordered boxes competing with the cards inside them. */
|
|
QGroupBox#monSection {
|
|
background: transparent; border: none; margin-top: 16px;
|
|
padding: 6px 0 0 0; font-weight: 700;
|
|
}
|
|
QGroupBox#monSection::title {
|
|
subcontrol-origin: margin; subcontrol-position: top left; left: 2px; top: 0;
|
|
padding: 0; color: $text_faint; font-size: 11px; letter-spacing: 0.5px;
|
|
}
|
|
/* Segmented control: two-to-four choices shown side by side (language, theme)
|
|
instead of a drop-list you must open to see what the options even are. */
|
|
QPushButton#segItem {
|
|
background: $surface_raised; color: $text_muted; border: 1px solid $border;
|
|
padding: 4px 12px; margin: 0; border-radius: 0;
|
|
}
|
|
QPushButton#segItem:hover { background: $hover; color: $text; }
|
|
QPushButton#segItem:checked {
|
|
background: $accent_solid; color: #FFFFFF; border-color: $accent_solid; font-weight: 600;
|
|
}
|
|
/* Table of contents down the left of the long dialogs (Settings, Task editor). */
|
|
QListWidget#sectionIndex { background: $surface; border-right: 1px solid $border; }
|
|
QListWidget#sectionIndex::item { padding: 7px 10px; border-radius: ${radius}px; }
|
|
QListWidget#sectionIndex::item:hover { background: $hover; }
|
|
QListWidget#sectionIndex::item:selected {
|
|
background: $nav_selected; color: $text; font-weight: 600;
|
|
}
|
|
/* The strip under the typing box: agent · routing · usage · folder. Reads as
|
|
status, not as a second toolbar, so the eye lands on the input first. */
|
|
QWidget#composerStatus { border-top: 1px solid $border; background: transparent; }
|
|
QWidget#composerStatus QLabel { color: $text_faint; font-size: 11px; }
|
|
QWidget#composerStatus QPushButton, QWidget#composerStatus QComboBox {
|
|
background: transparent; border: none; color: $text_muted; font-size: 11px;
|
|
padding: 2px 6px; border-radius: ${radius_sm}px;
|
|
}
|
|
QWidget#composerStatus QPushButton:hover, QWidget#composerStatus QComboBox:hover {
|
|
background: $hover; color: $text;
|
|
}
|
|
/* Folder: the current path, written as the screen's title. */
|
|
QLabel#folderTitle { color: $text; font-size: 14px; font-weight: 600; background: transparent; }
|
|
/* A pair of view tabs inside a page header (Schedule, GraphRAG) — flatter and
|
|
quieter than the app's main tab bars, since they switch a view, not a page. */
|
|
QTabBar#viewTabs::tab {
|
|
background: transparent; color: $text_muted; border: none;
|
|
padding: 4px 12px; margin: 0 2px; border-radius: ${radius}px;
|
|
}
|
|
QTabBar#viewTabs::tab:hover { background: $hover; color: $text; }
|
|
QTabBar#viewTabs::tab:selected { background: $active; color: $text; font-weight: 600; }
|
|
/* Co4E sidebar section headings — same quiet caps as the rail's RECENTS, so
|
|
"which list am I looking at" is answered on screen, not in a tooltip. */
|
|
/* The action beside a Co4E section heading ("+ Mới", "Quản lý skill…"). The
|
|
wireframe writes these as small accent text; as full buttons they were the
|
|
loudest thing in the sidebar and each cost a row of height. */
|
|
QPushButton#co4eSectionAction {
|
|
background: transparent; border: none; color: $accent;
|
|
font-size: 11px; font-weight: 600; padding: 1px 4px;
|
|
border-radius: ${radius_sm}px; qproperty-iconSize: 11px 11px;
|
|
}
|
|
QPushButton#co4eSectionAction:hover { background: $hover; color: $accent; }
|
|
QPushButton#co4eSectionAction:pressed { background: $active; }
|
|
QPushButton#co4eSectionHdr {
|
|
color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
|
|
background: transparent; border: none; text-align: left; padding: 2px 0;
|
|
}
|
|
QPushButton#co4eSectionHdr:hover { color: $text; }
|
|
/* Account row at the foot of the rail: who you are + the settings that follow
|
|
you (provider, language, theme). Separated by a hairline like the group above. */
|
|
QWidget#navAccount { border-top: 1px solid $nav_border; }
|
|
QWidget#navAccount QComboBox {
|
|
background: $surface_raised; border: 1px solid $nav_border; color: $text;
|
|
padding: 3px 6px; border-radius: ${radius}px;
|
|
}
|
|
/* RECENTS section label — quiet, so the thread titles under it read first. */
|
|
QLabel#navSectionHdr {
|
|
color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
|
|
padding: 8px 8px 2px 8px; background: transparent;
|
|
}
|
|
QTreeWidget#navRecents { border-top: 1px solid $nav_border; }
|
|
/* Rail header — the primary action, so it is the one filled button up there. */
|
|
QPushButton#navNewChatBtn {
|
|
background: $accent_solid; color: #FFFFFF; border: none; font-weight: 600;
|
|
padding: 7px 10px; border-radius: ${radius}px; text-align: left;
|
|
}
|
|
QPushButton#navNewChatBtn:hover { background: $accent_solid_hover; }
|
|
QPushButton#navNewChatBtn:disabled { background: $border_strong; color: $text_faint; }
|
|
QComboBox#navProjectPick {
|
|
background: $surface_raised; border: 1px solid $nav_border; color: $text;
|
|
padding: 4px 8px; border-radius: ${radius}px;
|
|
}
|
|
/* Stand-in for the picker while the rail is 54px wide: icon only, no arrow —
|
|
the arrow would eat a third of the width for no information. */
|
|
QToolButton#navProjectPickMini {
|
|
background: $surface_raised; border: 1px solid $nav_border; border-radius: ${radius}px;
|
|
padding: 4px; qproperty-iconSize: 16px 16px;
|
|
}
|
|
QToolButton#navProjectPickMini:hover { background: $nav_hover; }
|
|
QToolButton#navProjectPickMini:disabled { background: transparent; border-color: $border; }
|
|
QToolButton#navProjectPickMini::menu-indicator { image: none; width: 0; }
|
|
|
|
QPushButton#navSettingsBtn {
|
|
background: transparent; border: none; color: $text_muted;
|
|
padding: 6px 8px 6px 4px; text-align: left; border-radius: ${radius}px;
|
|
/* A button leaves less air between icon and label than a tree row
|
|
does, so the word sat 3px left of Dashboard's. Widening the icon
|
|
box (the glyph stays 16px, left-aligned) closes the gap. */
|
|
qproperty-iconSize: 19px 16px;
|
|
/* No side margin: Settings reads as one more row under Dashboard/Giám sát,
|
|
so its icon has to start on their x. A 6px margin put it at 14 — near
|
|
enough the middle of the collapsed 54px rail to look centred. */
|
|
margin: 2px 0px 6px 0px;
|
|
}
|
|
QPushButton#navSettingsBtn:hover { background: $nav_hover; color: $text; }
|
|
QPushButton#navSettingsBtn:pressed { background: $active; }
|
|
|
|
|
|
/* ---- surfaces --------------------------------------------------------- */
|
|
QGroupBox {
|
|
background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px;
|
|
margin-top: 14px; padding: 16px 12px 12px 12px; font-weight: 600;
|
|
}
|
|
QGroupBox::title {
|
|
subcontrol-origin: margin; subcontrol-position: top left; left: 12px; top: 1px;
|
|
padding: 0 6px; color: $text_muted; font-size: 12px; font-weight: 600;
|
|
}
|
|
QFrame[frameShape="4"], QFrame[frameShape="5"] { background: $border; border: none; }
|
|
QScrollArea { background: transparent; border: none; }
|
|
QAbstractScrollArea::corner { background: transparent; }
|
|
|
|
/* ---- tabs: an underline, not a pill. -------------------------------------
|
|
The old pill tabs read as buttons and fought the real buttons for
|
|
attention. A 2px rule under the active label is quieter and unambiguous. */
|
|
QTabWidget::pane { background: $bg; border: none; border-top: 1px solid $border; top: -1px; }
|
|
QTabBar { background: transparent; qproperty-drawBase: 0; }
|
|
QTabBar::tab {
|
|
background: transparent; color: $text_muted; padding: 9px 14px; margin: 0 2px 0 0;
|
|
border: none; border-bottom: 2px solid transparent; font-weight: 500;
|
|
}
|
|
QTabBar::tab:selected { color: $text; border-bottom: 2px solid $accent; font-weight: 600; }
|
|
QTabBar::tab:hover:!selected { color: $text; background: $hover; }
|
|
|
|
/* Co4E flow strip — browser-style tabs, so these stay enclosed. */
|
|
QTabBar#flowTabs::tab {
|
|
background: $surface; color: $text_muted; border: 1px solid $border;
|
|
border-radius: ${radius}px; padding: 6px 10px; margin: 0 3px 0 0; min-height: 22px;
|
|
}
|
|
QTabBar#flowTabs::tab:selected { background: $surface_raised; color: $text; border-color: $border_strong; }
|
|
QTabBar#flowTabs::tab:hover:!selected { background: $hover; color: $text; }
|
|
QPushButton#flowAddBtn {
|
|
background: transparent; color: $text_muted; border: 1px solid $border;
|
|
border-radius: ${radius}px; padding: 6px 0; font-size: 15px; min-height: 22px;
|
|
}
|
|
QPushButton#flowAddBtn:hover { background: $hover; color: $text; }
|
|
|
|
/* Co4E icon sidebar — no chrome until it is the active one. */
|
|
QTabBar#co4eSideTabs { qproperty-iconSize: 18px 18px; }
|
|
QTabBar#co4eSideTabs::tab {
|
|
background: transparent; color: $text_muted; border: none;
|
|
border-radius: ${radius}px; padding: 6px; margin: 0 4px 0 0;
|
|
}
|
|
QTabBar#co4eSideTabs::tab:selected { background: $accent_soft; color: $text; }
|
|
QTabBar#co4eSideTabs::tab:hover:!selected { background: $hover; color: $text; }
|
|
QGraphicsView#co4eCanvas {
|
|
background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px;
|
|
}
|
|
|
|
/* ---- text entry & item views ------------------------------------------ */
|
|
QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QSpinBox, QDoubleSpinBox,
|
|
QTreeView, QListView, QTableView, QTreeWidget, QListWidget, QTableWidget {
|
|
background: $surface_raised; color: $text; border: 1px solid $border;
|
|
border-radius: ${radius}px; selection-background-color: $selection_bg;
|
|
selection-color: $selection_fg;
|
|
}
|
|
QPlainTextEdit, QTextEdit, QLineEdit, QSpinBox, QDoubleSpinBox { padding: 6px 8px; }
|
|
QPlainTextEdit:hover, QTextEdit:hover, QLineEdit:hover { border-color: $border_strong; }
|
|
QPlainTextEdit:focus, QTextEdit:focus, QLineEdit:focus,
|
|
QSpinBox:focus, QDoubleSpinBox:focus { border: 1px solid $focus_ring; }
|
|
QLineEdit:disabled, QPlainTextEdit:disabled, QTextEdit:disabled {
|
|
background: $surface; color: $text_disabled;
|
|
}
|
|
|
|
QTreeView::item, QListView::item, QTableView::item { padding: 4px 3px; border-radius: ${radius_sm}px; }
|
|
QTreeView::item:hover, QListView::item:hover { background: $hover; }
|
|
QTreeView::item:selected, QListView::item:selected, QTableView::item:selected {
|
|
background: $accent_soft; color: $text;
|
|
}
|
|
QHeaderView::section {
|
|
background: $bg; color: $text_muted; border: none;
|
|
border-bottom: 1px solid $border; padding: 7px 6px; font-weight: 600;
|
|
}
|
|
|
|
/* ---- buttons -----------------------------------------------------------
|
|
Default is a quiet outline. Weight is reserved for #primary / #danger, so
|
|
at most one button per view should carry a fill. */
|
|
QPushButton {
|
|
background: $surface_raised; color: $text; border: 1px solid $border_strong;
|
|
border-radius: ${radius}px; padding: 7px 14px; font-weight: 500;
|
|
}
|
|
QPushButton:hover { background: $hover; border-color: $border_strong; }
|
|
QPushButton:pressed { background: $active; }
|
|
QPushButton:disabled { color: $text_disabled; background: $surface; border-color: $border; }
|
|
QPushButton:focus { border: 1px solid $focus_ring; }
|
|
|
|
QPushButton#primary {
|
|
background: $accent_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600;
|
|
}
|
|
QPushButton#primary:hover { background: $accent_solid_hover; }
|
|
QPushButton#primary:pressed { background: $accent_solid_active; }
|
|
QPushButton#primary:disabled { background: $surface; color: $text_disabled; border-color: $border; }
|
|
|
|
QPushButton#danger {
|
|
background: $danger_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600;
|
|
}
|
|
QPushButton#danger:hover { background: $danger_solid_hover; }
|
|
QPushButton#danger:disabled { background: $surface; color: $text_disabled; border-color: $border; }
|
|
|
|
/* Ghost buttons: nav section headers and icon-only chrome. */
|
|
QPushButton#navMenuBtn {
|
|
background: transparent; border: none; border-radius: ${radius}px; padding: 5px 6px;
|
|
font-weight: 600; font-size: 11px; letter-spacing: 0.6px; color: $text_faint; text-align: left;
|
|
}
|
|
QPushButton#navMenuBtn:hover { background: $hover; color: $text; }
|
|
QPushButton#navMenuBtn:pressed { background: $active; }
|
|
|
|
QToolButton {
|
|
background: transparent; color: $text_muted; border: none;
|
|
border-radius: ${radius}px; padding: 5px;
|
|
}
|
|
QToolButton:hover { background: $hover; color: $text; }
|
|
QToolButton:pressed { background: $active; }
|
|
QToolButton::menu-indicator { image: none; }
|
|
|
|
/* ---- pickers ----------------------------------------------------------- */
|
|
QComboBox {
|
|
background: $surface_raised; color: $text; border: 1px solid $border_strong;
|
|
border-radius: ${radius}px; padding: 6px 10px;
|
|
}
|
|
QComboBox:hover { background: $hover; }
|
|
QComboBox:focus { border-color: $focus_ring; }
|
|
QComboBox:disabled { color: $text_disabled; background: $surface; border-color: $border; }
|
|
QComboBox::drop-down { border: none; width: 20px; }
|
|
QComboBox QAbstractItemView {
|
|
background: $overlay; color: $text; border: 1px solid $border_strong;
|
|
border-radius: ${radius}px; padding: 4px; outline: none;
|
|
selection-background-color: $accent_soft; selection-color: $text;
|
|
}
|
|
|
|
QMenu { background: $overlay; color: $text; border: 1px solid $border_strong;
|
|
border-radius: ${radius}px; padding: 4px; }
|
|
QMenu::item { padding: 6px 14px; border-radius: ${radius_sm}px; }
|
|
QMenu::item:selected { background: $accent_soft; color: $text; }
|
|
QMenu::item:disabled { color: $text_disabled; }
|
|
QMenu::separator { height: 1px; background: $border; margin: 4px 6px; }
|
|
|
|
QMenuBar { background: $bg; color: $text; border-bottom: 1px solid $border; }
|
|
QMenuBar::item { padding: 5px 10px; border-radius: ${radius_sm}px; background: transparent; }
|
|
QMenuBar::item:selected { background: $hover; }
|
|
|
|
/* ---- toggles ----------------------------------------------------------- */
|
|
QCheckBox, QRadioButton { spacing: 8px; background: transparent; }
|
|
QCheckBox::indicator, QRadioButton::indicator {
|
|
width: 16px; height: 16px; background: $surface_raised;
|
|
border: 1px solid $border_strong; border-radius: ${radius_sm}px;
|
|
}
|
|
QRadioButton::indicator { border-radius: 9px; }
|
|
QCheckBox::indicator:hover, QRadioButton::indicator:hover { border-color: $accent; }
|
|
QCheckBox::indicator:checked, QRadioButton::indicator:checked {
|
|
background: $accent_solid; border-color: $accent_solid;
|
|
}
|
|
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
|
|
background: $surface; border-color: $border;
|
|
}
|
|
QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled {
|
|
background: $border_strong; border-color: $border_strong;
|
|
}
|
|
|
|
QSlider::groove:horizontal { height: 4px; background: $border; border-radius: 2px; }
|
|
QSlider::sub-page:horizontal { background: $accent_solid; border-radius: 2px; }
|
|
QSlider::handle:horizontal {
|
|
width: 14px; height: 14px; margin: -6px 0; border-radius: 7px;
|
|
background: $surface_raised; border: 1px solid $border_strong;
|
|
}
|
|
QSlider::handle:horizontal:hover { border-color: $accent; }
|
|
|
|
QProgressBar {
|
|
background: $surface; border: none; border-radius: 3px;
|
|
height: 6px; text-align: center; color: $text_muted;
|
|
}
|
|
QProgressBar::chunk { background: $accent_solid; border-radius: 3px; }
|
|
|
|
/* ---- scrollbars: overlay-thin, no arrows. ------------------------------ */
|
|
QScrollBar:vertical { background: transparent; width: 10px; margin: 2px; }
|
|
QScrollBar::handle:vertical { background: $scroll_handle; border-radius: 4px; min-height: 28px; }
|
|
QScrollBar::handle:vertical:hover { background: $scroll_handle_hover; }
|
|
QScrollBar:horizontal { background: transparent; height: 10px; margin: 2px; }
|
|
QScrollBar::handle:horizontal { background: $scroll_handle; border-radius: 4px; min-width: 28px; }
|
|
QScrollBar::handle:horizontal:hover { background: $scroll_handle_hover; }
|
|
QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; border: none; background: none; }
|
|
QScrollBar::add-page, QScrollBar::sub-page { background: none; }
|
|
|
|
/* ---- badges & inline text tones ---------------------------------------
|
|
One shape, six tones. Pick by meaning: badgeSuccess for a finished run,
|
|
badgeDanger for a failed one — not by which colour looks nice. */
|
|
QLabel#badge, QLabel#badgeSuccess, QLabel#badgeWarn, QLabel#badgeDanger,
|
|
QLabel#badgePurple, QLabel#badgePink {
|
|
border-radius: ${radius_sm}px; padding: 2px 8px; font-size: 12px; font-weight: 600;
|
|
}
|
|
QLabel#badge { background: $info_soft; color: $info; }
|
|
QLabel#badgeSuccess { background: $success_soft; color: $success; }
|
|
QLabel#badgeWarn { background: $warning_soft; color: $warning; }
|
|
QLabel#badgeDanger { background: $danger_soft; color: $danger; }
|
|
QLabel#badgePurple { background: $purple_soft; color: $purple; }
|
|
QLabel#badgePink { background: $pink_soft; color: $pink; }
|
|
|
|
QLabel { background: transparent; }
|
|
QLabel#hint { color: $text_muted; }
|
|
QLabel#faint { color: $text_faint; }
|
|
QLabel#warning { color: $warning; font-weight: 600; }
|
|
QLabel#error { color: $danger; font-weight: 600; }
|
|
QLabel#success { color: $success; font-weight: 600; }
|
|
QLabel#sectionTitle { color: $text; font-size: 15px; font-weight: 600; }
|
|
|
|
/* ---- code, terminals & logs -------------------------------------------
|
|
These read as "sunken" surfaces: the eye goes in, not across. */
|
|
QPlainTextEdit#codeEditor, QPlainTextEdit#termOutput, QPlainTextEdit#logView {
|
|
background: $code_bg; color: $code_fg; border: none;
|
|
font-family: $font_mono; selection-background-color: $code_selection;
|
|
}
|
|
QLineEdit#termInput {
|
|
background: $code_bg; color: $code_fg; border: none; border-top: 1px solid $border;
|
|
font-family: $font_mono; border-radius: 0; padding: 7px 10px;
|
|
}
|
|
QLineEdit#termInput:focus { border-top-color: $accent; }
|
|
|
|
/* The help-agent dock styles itself from these same tokens — it is a floating
|
|
overlay that re-applies on every theme switch. See ui/help_agent_widget.py. */
|
|
""")
|
|
|
|
|
|
def resolve_theme(theme: str) -> str:
|
|
"""Resolve ``'system'`` to ``'dark'``/``'light'`` from the OS colour scheme."""
|
|
if theme in _PALETTES:
|
|
return theme
|
|
try:
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtWidgets import QApplication
|
|
|
|
app = QApplication.instance()
|
|
if app is not None:
|
|
scheme = app.styleHints().colorScheme()
|
|
return "light" if scheme == Qt.ColorScheme.Light else "dark"
|
|
except Exception:
|
|
pass
|
|
return "dark"
|
|
|
|
|
|
def palette(theme: str) -> Palette:
|
|
"""The token set for ``theme``. Painting code reads its colours from here."""
|
|
return _PALETTES[resolve_theme(theme)]
|
|
|
|
|
|
# The theme the running app is currently showing. Painting code (paintEvent,
|
|
# QSyntaxHighlighter, canvas items) reads it via current_palette() instead of
|
|
# re-reading config.json — that used to cost a file open per repaint.
|
|
_active_theme = "dark"
|
|
|
|
|
|
def set_active_theme(theme: str) -> str:
|
|
"""Record the theme the app just applied. Call this next to every
|
|
``QApplication.setStyleSheet(stylesheet(...))``. Returns the resolved name."""
|
|
global _active_theme
|
|
_active_theme = resolve_theme(theme)
|
|
return _active_theme
|
|
|
|
|
|
def current_theme() -> str:
|
|
"""The resolved theme ('dark'/'light') the app is showing right now."""
|
|
return _active_theme
|
|
|
|
|
|
def current_palette() -> Palette:
|
|
"""Tokens for the theme the app is showing right now."""
|
|
return _PALETTES[_active_theme]
|
|
|
|
|
|
def stylesheet(theme: str) -> str:
|
|
"""The application-wide Qt style sheet for ``theme``."""
|
|
return _TEMPLATE.substitute(asdict(palette(theme)))
|
|
|
|
|
|
def role_colors(theme: str) -> dict[str, str]:
|
|
"""Conversation/graph node colours keyed by role."""
|
|
p = palette(theme)
|
|
return {
|
|
"user": p.role_user,
|
|
"assistant": p.role_assistant,
|
|
"tool": p.role_tool,
|
|
"result": p.role_result,
|
|
"error": p.role_error,
|
|
}
|