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:
2026-08-30 11:25:27 +09:00
co-authored by Claude Opus 5
parent e29a0ccdbd
commit be5c5747a9
24 changed files with 66 additions and 56 deletions
+101
View File
@@ -0,0 +1,101 @@
"""Runtime UI translation: English / Japanese / Vietnamese.
``tr(key, **kwargs)`` returns the string for the current language (falling
back to English, then the key itself so a missing entry is still visible
instead of crashing). ``.format(**kwargs)`` is applied when placeholders are
passed, so callers can do e.g. ``tr("composer.attachments", n=3)``.
Persistent, long-lived widgets (the main window chrome, the tabs, the
sidebar, the composer, ...) must reflect a language change immediately, so
they register a zero-arg callback via :func:`on_language_changed` that
re-applies ``tr()`` to their own text; the callback runs once right away and
again every time the language changes. Transient dialogs (Settings, Skills,
Flow, Permission...) are rebuilt from scratch each time they are opened, so
they simply call ``tr()`` while constructing their widgets and need no
registration.
"""
from __future__ import annotations
from typing import Callable, Dict, List
LANGUAGES: Dict[str, str] = {"en": "English", "ja": "日本語", "vi": "Tiếng Việt"}
# Short codes shown in the compact top-bar switcher (Settings keeps the full names above).
LANGUAGE_SHORT: Dict[str, str] = {"en": "EN", "ja": "JP", "vi": "VN"}
DEFAULT_LANGUAGE = "vi"
_current = DEFAULT_LANGUAGE
_listeners: List[Callable[[], None]] = []
# key -> {"en": ..., "ja": ..., "vi": ...}
from . import login_dialog as _login_dialog
from . import sidebar as _sidebar
from . import composer as _composer
from . import hint as _hint
from . import cowork_tab as _cowork_tab
from . import settings_dialog as _settings_dialog
from . import skills_dialog as _skills_dialog
from . import libreoffice_view as _libreoffice_view
from . import agents_admin_tab as _agents_admin_tab
from . import monitoring_overview as _monitoring_overview
# Gộp theo đúng thứ tự cũ: khoá trùng thì cụm sau thắng, y như khi tất cả
# còn nằm chung một dict literal.
STRINGS: Dict[str, Dict[str, str]] = {
**_login_dialog.STRINGS,
**_sidebar.STRINGS,
**_composer.STRINGS,
**_hint.STRINGS,
**_cowork_tab.STRINGS,
**_settings_dialog.STRINGS,
**_skills_dialog.STRINGS,
**_libreoffice_view.STRINGS,
**_agents_admin_tab.STRINGS,
**_monitoring_overview.STRINGS,
}
def set_language(lang: str) -> None:
"""Switch the active language and notify every registered persistent widget."""
global _current
if lang not in LANGUAGES:
lang = DEFAULT_LANGUAGE
if lang == _current:
return
_current = lang
for fn in list(_listeners):
try:
fn()
except RuntimeError:
# The widget behind this callback was already destroyed — drop it.
try:
_listeners.remove(fn)
except ValueError:
pass
def get_language() -> str:
"""Mã ngôn ngữ đang dùng."""
return _current
def tr(key: str, **kwargs) -> str:
"""Chuỗi đã dịch cho một khoá.
Thiếu khoá thì trả về CHÍNH khoá đó — hiện ra một chuỗi lạ trên giao diện
vẫn tốt hơn là làm vỡ màn hình. Thiếu bản dịch của ngôn ngữ hiện tại thì rơi
về tiếng Anh.
"""
entry = STRINGS.get(key)
if not entry:
return key
text = entry.get(_current) or entry.get("en") or next(iter(entry.values()), key)
return text.format(**kwargs) if kwargs else text
def on_language_changed(fn: Callable[[], None]) -> None:
"""Register a callback that re-applies translations to a persistent widget.
Called once immediately (to apply the current language) and again on every
future call to :func:`set_language`."""
_listeners.append(fn)
fn()