refactor: gom i18n/ và theme/ thành gói, gộp requirements về một file
Thư mục gốc: 22 file .py -> 7
--------------------------------
13 file "thành phần" nằm rải rác ngay ngoài thư mục gốc, mỗi file chỉ có ĐÚNG
MỘT nơi import — chính cái hub của nó:
i18n.py + 10 file i18n_*.py -> i18n/__init__.py + i18n/*.py
theme.py + 3 file theme_*.py -> theme/__init__.py + theme/*.py
Đổi hub thành `__init__.py` nên 78 chỗ `from ..i18n import tr` và 24 chỗ
`from ..theme import current_palette` KHÔNG phải sửa một dòng nào. Git nhận ra
11/15 file là đổi tên thuần, 0 dòng thay đổi; 4 file còn lại chỉ sửa đúng dòng
import và mấy tham chiếu tên file trong docstring.
Đối chiếu với bản trước khi gom, cùng một phép băm:
số khoá i18n 1431 -> 1431 hash STRINGS a06cc34b... (trùng)
QSS dark hash 7bb230a4... (trùng)
QSS light hash 884f73ce... (trùng)
`check_loc.py` phải khai thêm "i18n", "theme" vào DEFAULT_TARGET_DIRS: chúng
từng được quét theo diện "module nằm ở thư mục gốc", gom vào gói rồi thì không
khai là lặng lẽ tuột khỏi tầm quét.
Bánh cóc `ui/widgets.py` siết 505 -> 466 sau khi tách SegmentedControl — nợ cũ
co lại thì con số phải co theo, không thì bánh cóc đứng yên mãi ở mức cũ.
Một file requirements
---------------------
Xoá `requirements-test.txt`. Nó chỉ có `pytest` + `pydantic`, nhưng 64/108 file
test dựng widget thật và 20 file trong đó import PySide6 thẳng ở đầu file không
có bảo vệ — nên CI cài mỗi file kia thì pytest chết ngay lúc thu thập test chứ
không phải "vài test bị bỏ qua". Hai file cho một danh sách gần trùng nhau chỉ
tạo thêm một chỗ để lệch phiên bản, và `pydantic` đã bị chép ở cả hai.
CI đổi sang cài `requirements.txt`. Người dùng cuối cài thừa pytest vài MB.
Kèm theo: `install.bat` bỏ cờ `--dev` (không còn gì để cài thêm). Khối `if`
rỗng còn sót lại làm cmd.exe báo "( was unexpected at this time" và script chết
ngay sau bước cài thư viện — đã gỡ hẳn.
859 test xanh · 4/4 cổng CASAN · check_design_parity 32/32 ·
check_layout_geometry trùng từng byte với bản trước refactor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
"""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 .palettes import ( # noqa: F401 — giữ đường vào cũ
|
||||
DARK, LIGHT, Palette, _chevron_asset, _FONT, _MONO, _PALETTES,
|
||||
)
|
||||
from .qss import _TEMPLATE
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from string import Template
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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``."""
|
||||
p = palette(theme)
|
||||
values = asdict(p)
|
||||
values["chevron_down"] = _chevron_asset("down", p.text_muted)
|
||||
values["chevron_up"] = _chevron_asset("up", p.text_muted)
|
||||
return _TEMPLATE.substitute(values)
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user