3bc29f7b by 柴进

feat(theme): 设计令牌生成器 + 主题管理器

- TOKENS_LIGHT / TOKENS_DARK:24 个语义颜色令牌(bg/text/border/accent/status)
- SIZES:圆角/间距/控件高度/字号
- FONT_STACK:跨平台字体回退
- build_qss(mode):生成完整 QSS(覆盖 GroupBox/Button/Tab/Input/List/Scroll/CheckBox/Tooltip/Menu)
- ThemeManager:QObject,监听 styleHints().colorSchemeChanged 自动切换
- apply_theme(app):入口函数,在 main() 创建 QApplication 后调用

业务层通过 setObjectName / setProperty('variant'|'status'|'role', ...) 命中 QSS。
状态色(success/warning/danger)走 status property,不再 inline setStyleSheet。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d717f7dd
Showing 1 changed file with 680 additions and 0 deletions
1 """设计系统:Apple 高级浅色主题 + 浅深双模式。
2
3 单一真相源 — 所有颜色 / 间距 / 圆角 / 字号在本模块定义,
4 通过 build_qss(mode) 拼成完整 QSS 字符串应用到 QApplication。
5
6 跟随系统切换:监听 QGuiApplication.styleHints().colorSchemeChanged,
7 macOS 按时间自动切深色 / Windows 11 系统级深浅都会自动响应。
8
9 业务代码不要写 inline setStyleSheet — 通过 setObjectName / setProperty
10 让全局 QSS 命中。状态色(success/warning/danger)用 setProperty('status', ...)。
11 """
12 from __future__ import annotations
13
14 from PySide6.QtCore import QObject, Qt, Signal
15 from PySide6.QtGui import QGuiApplication
16 from PySide6.QtWidgets import QApplication
17
18 # =============================================================================
19 # 设计令牌
20 # =============================================================================
21
22 FONT_STACK = (
23 '"-apple-system", "SF Pro Text", "PingFang SC", '
24 '"Microsoft YaHei UI", "Segoe UI Variable", "Segoe UI", sans-serif'
25 )
26
27 TOKENS_LIGHT: dict[str, str] = {
28 # 表面层级
29 "bg_canvas": "#fbfbfd",
30 "bg_surface": "#ffffff",
31 "bg_elevated": "#ffffff",
32 "bg_subtle": "#f4f4f7",
33 "bg_hover": "#f0f0f3",
34
35 # 文字层级
36 "text_primary": "#1d1d1f",
37 "text_secondary": "#6e6e73",
38 "text_tertiary": "#86868b",
39 "text_on_accent": "#ffffff",
40 "text_on_danger": "#ffffff",
41
42 # 边框 / 分隔
43 "border_default": "rgba(0, 0, 0, 0.10)",
44 "border_strong": "rgba(0, 0, 0, 0.18)",
45 "border_focus": "#0071e3",
46 "divider": "rgba(0, 0, 0, 0.06)",
47
48 # 主色(accent)
49 "accent": "#0071e3",
50 "accent_hover": "#0077ed",
51 "accent_pressed": "#0062c4",
52 "accent_subtle": "rgba(0, 113, 227, 0.08)",
53 "accent_subtle_hover": "rgba(0, 113, 227, 0.14)",
54
55 # 状态色
56 "success": "#34c759",
57 "warning": "#ff9500",
58 "danger": "#ff3b30",
59 "danger_hover": "#ff5247",
60 "danger_subtle": "rgba(255, 59, 48, 0.10)",
61 }
62
63 TOKENS_DARK: dict[str, str] = {
64 "bg_canvas": "#1a1a1c",
65 "bg_surface": "#2c2c2e",
66 "bg_elevated": "#3a3a3c",
67 "bg_subtle": "#242426",
68 "bg_hover": "#34343a",
69
70 "text_primary": "#ffffff",
71 "text_secondary": "#98989d",
72 "text_tertiary": "#6e6e73",
73 "text_on_accent": "#ffffff",
74 "text_on_danger": "#ffffff",
75
76 "border_default": "rgba(255, 255, 255, 0.10)",
77 "border_strong": "rgba(255, 255, 255, 0.20)",
78 "border_focus": "#0a84ff",
79 "divider": "rgba(255, 255, 255, 0.06)",
80
81 "accent": "#0a84ff",
82 "accent_hover": "#1d8cff",
83 "accent_pressed": "#006edc",
84 "accent_subtle": "rgba(10, 132, 255, 0.16)",
85 "accent_subtle_hover": "rgba(10, 132, 255, 0.24)",
86
87 "success": "#30d158",
88 "warning": "#ff9f0a",
89 "danger": "#ff453a",
90 "danger_hover": "#ff5b50",
91 "danger_subtle": "rgba(255, 69, 58, 0.16)",
92 }
93
94 # 尺寸 / 圆角 / 间距 ─ 浅深一致
95 SIZES: dict[str, str] = {
96 "radius_sm": "4px",
97 "radius_md": "8px",
98 "radius_lg": "12px",
99 "radius_pill": "980px",
100
101 "space_1": "4px",
102 "space_2": "8px",
103 "space_3": "12px",
104 "space_4": "16px",
105 "space_5": "20px",
106 "space_6": "24px",
107
108 "control_h_sm": "26px",
109 "control_h_md": "32px",
110 "control_h_lg": "40px",
111
112 "font_xs": "10pt",
113 "font_sm": "11pt",
114 "font_base": "12pt",
115 "font_lg": "14pt",
116 "font_xl": "17pt",
117 }
118
119
120 # =============================================================================
121 # QSS 构建
122 # =============================================================================
123
124 def build_qss(mode: str) -> str:
125 """根据 mode('light'/'dark')拼出完整 QSS。"""
126 c = TOKENS_DARK if mode == "dark" else TOKENS_LIGHT
127 s = SIZES
128 f = FONT_STACK
129
130 return f"""
131 /* ========== 全局 ========== */
132 * {{
133 font-family: {f};
134 color: {c['text_primary']};
135 }}
136
137 QWidget {{
138 background-color: {c['bg_canvas']};
139 color: {c['text_primary']};
140 font-size: {s['font_base']};
141 }}
142
143 QMainWindow, QDialog {{
144 background-color: {c['bg_canvas']};
145 }}
146
147 /* 二级弹窗(QMessageBox 等)走 surface 颜色,凸出于主窗口 */
148 QMessageBox {{
149 background-color: {c['bg_surface']};
150 }}
151 QMessageBox QLabel {{
152 background: transparent;
153 color: {c['text_primary']};
154 font-size: {s['font_base']};
155 }}
156
157 /* ========== 文字 ========== */
158 QLabel {{
159 background: transparent;
160 color: {c['text_primary']};
161 }}
162 QLabel[role="secondary"] {{
163 color: {c['text_secondary']};
164 }}
165 QLabel[role="muted"] {{
166 color: {c['text_tertiary']};
167 font-size: {s['font_sm']};
168 }}
169 QLabel[role="caption"] {{
170 color: {c['text_secondary']};
171 font-size: {s['font_xs']};
172 text-transform: uppercase;
173 letter-spacing: 1px;
174 }}
175 QLabel[role="title"] {{
176 color: {c['text_primary']};
177 font-size: {s['font_xl']};
178 font-weight: 600;
179 }}
180
181 /* 状态色 — 业务用 setProperty('status', '...') 命中 */
182 QLabel[status="success"] {{ color: {c['success']}; }}
183 QLabel[status="warning"] {{ color: {c['warning']}; }}
184 QLabel[status="danger"] {{ color: {c['danger']}; }}
185 QLabel[status="muted"] {{ color: {c['text_tertiary']}; }}
186 QLabel[status="info"] {{ color: {c['accent']}; }}
187
188 /* ========== 卡片 / GroupBox ========== */
189 QGroupBox {{
190 background-color: {c['bg_surface']};
191 border: 1px solid {c['border_default']};
192 border-radius: {s['radius_md']};
193 margin-top: 14px;
194 padding: {s['space_4']} {s['space_3']} {s['space_3']} {s['space_3']};
195 font-weight: 600;
196 font-size: {s['font_sm']};
197 color: {c['text_secondary']};
198 }}
199 QGroupBox::title {{
200 subcontrol-origin: margin;
201 subcontrol-position: top left;
202 left: {s['space_3']};
203 padding: 0 {s['space_2']};
204 color: {c['text_secondary']};
205 background: transparent;
206 text-transform: uppercase;
207 letter-spacing: 1px;
208 font-size: {s['font_xs']};
209 }}
210
211 /* ========== 按钮 ========== */
212 QPushButton {{
213 background-color: {c['bg_surface']};
214 color: {c['text_primary']};
215 border: 1px solid {c['border_default']};
216 border-radius: {s['radius_md']};
217 padding: 6px 14px;
218 min-height: {s['control_h_md']};
219 font-size: {s['font_sm']};
220 font-weight: 500;
221 }}
222 QPushButton:hover {{
223 background-color: {c['bg_hover']};
224 border-color: {c['border_strong']};
225 }}
226 QPushButton:pressed {{
227 background-color: {c['bg_subtle']};
228 }}
229 QPushButton:disabled {{
230 color: {c['text_tertiary']};
231 background-color: {c['bg_subtle']};
232 border-color: {c['border_default']};
233 }}
234 QPushButton:focus {{
235 outline: none;
236 border-color: {c['accent']};
237 }}
238
239 /* 主按钮("生成图片"等)— pill 圆角 + 实色背景 */
240 QPushButton[variant="primary"] {{
241 background-color: {c['accent']};
242 color: {c['text_on_accent']};
243 border: 1px solid {c['accent']};
244 border-radius: {s['radius_pill']};
245 padding: 8px 22px;
246 min-height: {s['control_h_lg']};
247 font-weight: 600;
248 }}
249 QPushButton[variant="primary"]:hover {{
250 background-color: {c['accent_hover']};
251 border-color: {c['accent_hover']};
252 }}
253 QPushButton[variant="primary"]:pressed {{
254 background-color: {c['accent_pressed']};
255 border-color: {c['accent_pressed']};
256 }}
257 QPushButton[variant="primary"]:disabled {{
258 background-color: {c['accent_subtle']};
259 color: {c['text_tertiary']};
260 border-color: transparent;
261 }}
262
263 /* 危险按钮 */
264 QPushButton[variant="danger"] {{
265 background-color: {c['bg_surface']};
266 color: {c['danger']};
267 border: 1px solid {c['danger']};
268 }}
269 QPushButton[variant="danger"]:hover {{
270 background-color: {c['danger_subtle']};
271 }}
272 QPushButton[variant="danger"]:pressed {{
273 background-color: {c['danger']};
274 color: {c['text_on_danger']};
275 }}
276
277 /* 幽灵按钮(图标按钮等) */
278 QPushButton[variant="ghost"] {{
279 background-color: transparent;
280 border: 1px solid transparent;
281 color: {c['text_secondary']};
282 }}
283 QPushButton[variant="ghost"]:hover {{
284 background-color: {c['accent_subtle']};
285 color: {c['accent']};
286 }}
287
288 /* 链接按钮 — 像超链接 */
289 QPushButton[variant="link"] {{
290 background-color: transparent;
291 border: none;
292 color: {c['accent']};
293 padding: 2px 6px;
294 min-height: 0;
295 }}
296 QPushButton[variant="link"]:hover {{
297 color: {c['accent_hover']};
298 text-decoration: underline;
299 }}
300
301 /* ========== 输入框 ========== */
302 QLineEdit, QTextEdit, QPlainTextEdit {{
303 background-color: {c['bg_surface']};
304 color: {c['text_primary']};
305 border: 1px solid {c['border_default']};
306 border-radius: {s['radius_md']};
307 padding: 6px 10px;
308 selection-background-color: {c['accent']};
309 selection-color: {c['text_on_accent']};
310 font-size: {s['font_base']};
311 }}
312 QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {{
313 border: 2px solid {c['accent']};
314 padding: 5px 9px;
315 }}
316 QLineEdit:disabled, QTextEdit:disabled, QPlainTextEdit:disabled {{
317 background-color: {c['bg_subtle']};
318 color: {c['text_tertiary']};
319 }}
320 QLineEdit[placeholder="true"], QTextEdit[placeholder="true"] {{
321 color: {c['text_tertiary']};
322 }}
323
324 /* ========== 下拉框 ========== */
325 QComboBox {{
326 background-color: {c['bg_surface']};
327 color: {c['text_primary']};
328 border: 1px solid {c['border_default']};
329 border-radius: {s['radius_md']};
330 padding: 5px 10px;
331 min-height: {s['control_h_md']};
332 min-width: 100px;
333 }}
334 QComboBox:hover {{
335 border-color: {c['border_strong']};
336 }}
337 QComboBox:focus {{
338 border: 2px solid {c['accent']};
339 padding: 4px 9px;
340 }}
341 QComboBox::drop-down {{
342 width: 20px;
343 border: none;
344 background: transparent;
345 }}
346 QComboBox QAbstractItemView {{
347 background-color: {c['bg_elevated']};
348 color: {c['text_primary']};
349 border: 1px solid {c['border_default']};
350 border-radius: {s['radius_md']};
351 selection-background-color: {c['accent_subtle']};
352 selection-color: {c['text_primary']};
353 padding: 4px;
354 outline: none;
355 }}
356 QComboBox QAbstractItemView::item {{
357 padding: 6px 10px;
358 border-radius: {s['radius_sm']};
359 }}
360 QComboBox QAbstractItemView::item:hover {{
361 background-color: {c['accent_subtle']};
362 }}
363
364 /* ========== Tab ========== */
365 QTabWidget::pane {{
366 border: none;
367 background-color: {c['bg_canvas']};
368 top: -1px;
369 }}
370 QTabWidget::tab-bar {{
371 left: 4px;
372 }}
373 QTabBar {{
374 background: transparent;
375 qproperty-drawBase: 0;
376 }}
377 QTabBar::tab {{
378 background: transparent;
379 color: {c['text_secondary']};
380 padding: 8px 16px;
381 margin: 0 2px;
382 border: none;
383 border-bottom: 2px solid transparent;
384 font-size: {s['font_base']};
385 min-width: 80px;
386 }}
387 QTabBar::tab:hover {{
388 color: {c['text_primary']};
389 }}
390 QTabBar::tab:selected {{
391 color: {c['accent']};
392 border-bottom: 2px solid {c['accent']};
393 font-weight: 600;
394 }}
395 QTabBar::tab:disabled {{
396 color: {c['text_tertiary']};
397 }}
398
399 /* ========== 列表 ========== */
400 QListWidget, QListView, QTreeWidget, QTreeView {{
401 background-color: {c['bg_surface']};
402 color: {c['text_primary']};
403 border: 1px solid {c['border_default']};
404 border-radius: {s['radius_md']};
405 outline: none;
406 padding: 4px;
407 }}
408 QListWidget::item, QListView::item {{
409 padding: 8px;
410 border-radius: {s['radius_sm']};
411 color: {c['text_primary']};
412 }}
413 QListWidget::item:hover, QListView::item:hover {{
414 background-color: {c['bg_hover']};
415 }}
416 QListWidget::item:selected, QListView::item:selected {{
417 background-color: {c['accent_subtle']};
418 color: {c['text_primary']};
419 }}
420
421 /* ========== 滚动区 ========== */
422 QScrollArea {{
423 background-color: {c['bg_canvas']};
424 border: none;
425 }}
426 QScrollArea > QWidget > QWidget {{
427 background-color: transparent;
428 }}
429
430 /* ========== 滚动条(Apple 隐式风格)========== */
431 QScrollBar:vertical {{
432 background: transparent;
433 width: 10px;
434 margin: 2px;
435 border: none;
436 }}
437 QScrollBar::handle:vertical {{
438 background: {c['border_strong']};
439 border-radius: 3px;
440 min-height: 30px;
441 }}
442 QScrollBar::handle:vertical:hover {{
443 background: {c['text_tertiary']};
444 }}
445 QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
446 height: 0;
447 background: transparent;
448 border: none;
449 }}
450 QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
451 background: transparent;
452 }}
453
454 QScrollBar:horizontal {{
455 background: transparent;
456 height: 10px;
457 margin: 2px;
458 border: none;
459 }}
460 QScrollBar::handle:horizontal {{
461 background: {c['border_strong']};
462 border-radius: 3px;
463 min-width: 30px;
464 }}
465 QScrollBar::handle:horizontal:hover {{
466 background: {c['text_tertiary']};
467 }}
468 QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
469 width: 0;
470 background: transparent;
471 border: none;
472 }}
473 QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{
474 background: transparent;
475 }}
476
477 /* ========== 进度条 ========== */
478 QProgressBar {{
479 background-color: {c['bg_subtle']};
480 border: none;
481 border-radius: 4px;
482 height: 6px;
483 text-align: center;
484 color: {c['text_secondary']};
485 font-size: {s['font_xs']};
486 }}
487 QProgressBar::chunk {{
488 background-color: {c['accent']};
489 border-radius: 4px;
490 }}
491
492 /* ========== 复选 / 单选 ========== */
493 QCheckBox, QRadioButton {{
494 color: {c['text_primary']};
495 spacing: 6px;
496 background: transparent;
497 }}
498 QCheckBox::indicator, QRadioButton::indicator {{
499 width: 16px;
500 height: 16px;
501 }}
502 QCheckBox::indicator:unchecked {{
503 background: {c['bg_surface']};
504 border: 1px solid {c['border_strong']};
505 border-radius: 3px;
506 }}
507 QCheckBox::indicator:checked {{
508 background: {c['accent']};
509 border: 1px solid {c['accent']};
510 border-radius: 3px;
511 }}
512 QRadioButton::indicator:unchecked {{
513 background: {c['bg_surface']};
514 border: 1px solid {c['border_strong']};
515 border-radius: 8px;
516 }}
517 QRadioButton::indicator:checked {{
518 background: {c['accent']};
519 border: 1px solid {c['accent']};
520 border-radius: 8px;
521 }}
522
523 /* ========== 工具提示 ========== */
524 QToolTip {{
525 background-color: {c['bg_elevated']};
526 color: {c['text_primary']};
527 border: 1px solid {c['border_default']};
528 border-radius: {s['radius_sm']};
529 padding: 4px 8px;
530 font-size: {s['font_sm']};
531 }}
532
533 /* ========== 菜单 ========== */
534 QMenu {{
535 background-color: {c['bg_elevated']};
536 color: {c['text_primary']};
537 border: 1px solid {c['border_default']};
538 border-radius: {s['radius_md']};
539 padding: 4px;
540 }}
541 QMenu::item {{
542 padding: 6px 16px;
543 border-radius: {s['radius_sm']};
544 }}
545 QMenu::item:selected {{
546 background-color: {c['accent_subtle']};
547 color: {c['text_primary']};
548 }}
549 QMenu::separator {{
550 height: 1px;
551 background: {c['divider']};
552 margin: 4px 6px;
553 }}
554
555 /* ========== 分割器 ========== */
556 QSplitter::handle {{
557 background-color: {c['divider']};
558 }}
559 QSplitter::handle:horizontal {{
560 width: 1px;
561 }}
562 QSplitter::handle:vertical {{
563 height: 1px;
564 }}
565
566 /* ========== 任务队列 sidebar ========== */
567 #taskQueueSidebar {{
568 background-color: {c['bg_surface']};
569 border-left: 1px solid {c['divider']};
570 }}
571 #sidebarHeader {{
572 background-color: {c['bg_surface']};
573 border-bottom: 1px solid {c['divider']};
574 min-height: 40px;
575 max-height: 40px;
576 }}
577 #sidebarHeader QLabel {{
578 color: {c['text_primary']};
579 font-size: {s['font_base']};
580 font-weight: 600;
581 padding: 0 12px;
582 }}
583
584 /* ========== 登录对话框 ========== */
585 #loginDialog {{
586 background-color: {c['bg_canvas']};
587 }}
588 #loginDialog QLabel#loginTitle {{
589 font-size: {s['font_xl']};
590 font-weight: 600;
591 color: {c['text_primary']};
592 }}
593 #loginDialog QLabel#loginSubtitle {{
594 font-size: {s['font_sm']};
595 color: {c['text_secondary']};
596 }}
597
598 /* ========== 预览区占位 ========== */
599 #previewPlaceholder {{
600 color: {c['text_tertiary']};
601 background: {c['bg_subtle']};
602 border-radius: {s['radius_md']};
603 }}
604 """
605
606
607 # =============================================================================
608 # 主题管理器
609 # =============================================================================
610
611 def _detect_system_mode() -> str:
612 """检测当前系统色彩偏好。Qt 6.5+ 支持,回退到 light。"""
613 try:
614 scheme = QGuiApplication.styleHints().colorScheme()
615 if scheme == Qt.ColorScheme.Dark:
616 return "dark"
617 return "light"
618 except Exception:
619 return "light"
620
621
622 class ThemeManager(QObject):
623 """全局主题管理器。监听系统色彩偏好变化,自动切换 QSS。
624
625 使用:在 main() 创建 QApplication 后调用 apply_theme(app) 即可。
626 业务代码不需要直接接触 ThemeManager。
627
628 通过 force_mode('light'|'dark'|None) 可以强制锁定,传 None 恢复跟随系统。
629 """
630
631 theme_changed = Signal(str) # 'light' | 'dark'
632
633 def __init__(self, app: QApplication):
634 super().__init__(app)
635 self._app = app
636 self._forced_mode: str | None = None
637 self._current_mode: str = _detect_system_mode()
638 self._apply_qss()
639
640 # 监听系统色彩切换
641 try:
642 QGuiApplication.styleHints().colorSchemeChanged.connect(
643 self._on_system_color_scheme_changed
644 )
645 except Exception:
646 # Qt 版本不支持 / 平台不发射,无所谓
647 pass
648
649 def current_mode(self) -> str:
650 return self._current_mode
651
652 def force_mode(self, mode: str | None) -> None:
653 """锁定主题。None = 跟随系统。"""
654 self._forced_mode = mode
655 new_mode = mode if mode in ("light", "dark") else _detect_system_mode()
656 if new_mode != self._current_mode:
657 self._current_mode = new_mode
658 self._apply_qss()
659 self.theme_changed.emit(new_mode)
660
661 def _on_system_color_scheme_changed(self, scheme) -> None:
662 if self._forced_mode in ("light", "dark"):
663 return # 用户锁定模式时不响应系统切换
664 new_mode = "dark" if scheme == Qt.ColorScheme.Dark else "light"
665 if new_mode != self._current_mode:
666 self._current_mode = new_mode
667 self._apply_qss()
668 self.theme_changed.emit(new_mode)
669
670 def _apply_qss(self) -> None:
671 self._app.setStyleSheet(build_qss(self._current_mode))
672
673
674 def apply_theme(app: QApplication) -> ThemeManager:
675 """主题应用入口。在 main() 创建 QApplication 后立即调用。
676
677 返回 ThemeManager 实例,调用方应持有引用以保持信号连接(否则会被
678 Python GC 当作未引用的 QObject 回收,导致系统切换失效)。
679 """
680 return ThemeManager(app)