CI / test (pull_request) Canceled after 0s
Người dùng báo: chọn tiếng Nhật mà nhóm Sandbox Security, nút Save/Cancel và
nhiều chỗ khác vẫn tiếng Anh. Bộ test i18n cũ vẫn xanh vì nó chỉ bắt lỗi "có
dịch nhưng không ai áp lại" — hai lỗ thật nằm chỗ khác:
* Chuỗi HARDCODE không đi qua ``tr()`` bao giờ (``QPushButton("Unlock")``), nên
phép đo "đổi ngôn ngữ rồi tìm chỗ không mang mốc" thấy nó đứng yên ở cả hai
lần chụp và coi là bình thường.
* Nhãn nút do CHÍNH Qt vẽ. ``QDialogButtonBox``, ``QMessageBox.question`` và
``QInputDialog.get*`` lấy chữ từ bảng dịch riêng của Qt; ứng dụng không cài
``QTranslator`` nào và bản PySide6 đang dùng cũng không đóng gói file
``qtbase_*.qm`` nào để cài — nên chúng luôn rơi về tiếng Anh.
``ui/dialog_buttons.py`` gán nhãn của dự án đè lên nhãn Qt: ``dialog_buttons``
(10 hộp thoại), ``confirm`` (13 hộp Có/Không), ``ask_text``/``ask_multiline``/
``ask_item`` (16 hộp nhập liệu). Cùng với 17 chuỗi hardcode và 5 câu lỗi mà
``core/tasks.py`` trả thẳng ra hộp thoại — nay trả KHOÁ i18n, nơi hiển thị mới
gọi ``tr()`` — là 61 chỗ.
Ba chỗ nữa cùng lớp lỗi, phát hiện khi rà lại:
* ``_add_section`` nhận chuỗi ĐÃ dịch nên bốn tiêu đề mục của Step config đứng
nguyên ở ngôn ngữ lúc dựng panel. Nay nhận khoá + ``bind_dynamic`` để không
mất trạng thái gập/mở khi đổi ngôn ngữ.
* Thẻ tool ở Giám sát ▸ Công cụ hiện thẳng ``spec.description`` — chuỗi gửi cho
MÔ HÌNH trong schema function-calling, phải giữ tiếng Anh. Thêm bộ mô tả hiển
thị riêng cho 9 tool.
* Tên nhóm catalog ở tab Connector ("Other (any generic MCP server)").
Kèm theo, phần giao diện người dùng yêu cầu:
* ``__version__`` 2.26.0 -> 0.0.1, một nguồn cho tiêu đề cửa sổ, tab Giới thiệu
và dòng mới ở góc phải thanh trạng thái (thay dòng ghi công tác giả).
* Tắt size grip: nó vẽ một vệt ngay bên phải dòng phiên bản. Cửa sổ vẫn kéo
giãn được từ các cạnh.
* ``_NAV_SETTINGS_GAP`` 10 -> 4: hàng Cài đặt bớt xa nhóm Dashboard/Giám sát.
Hai test SẼ TREO nếu không sửa kèm: chúng patch ``QInputDialog.getText/getItem``
để tự trả lời, mà code nay gọi ``ask_text``/``ask_item`` — patch không còn chặn
được và hộp thoại thật sẽ mở ra chờ người bấm.
Ba cổng mới trong ``tests/ui/test_i18n_khong_hardcode_chu.py`` canh ở mức cấu
trúc (không ai được dựng lại kiểu cũ); đã kiểm chúng CẮN trên bản trước khi sửa:
10 + 13 + 17 vi phạm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
201 lines
7.7 KiB
Python
201 lines
7.7 KiB
Python
"""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.
|
|
|
|
``setText(tr("k"))`` on its own is only correct for the instant it runs, and a
|
|
screen with dozens of such one-shot calls is where "I picked English and half
|
|
the screen is still Vietnamese" comes from. The :func:`bind_text` family
|
|
attaches the key to the widget instead, so every future language change
|
|
re-applies it — one line per widget, and nothing to remember in a separate
|
|
``retranslate`` method. Bindings hold the widget WEAKLY, so they are safe for
|
|
widgets that get rebuilt constantly (Kanban rows, calendar cells).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import weakref
|
|
from typing import Any, Callable, Dict, Iterable, List, Tuple
|
|
|
|
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]] = []
|
|
#: (weak ref to the widget, how to re-apply its text) — see :func:`bind_text`.
|
|
_bindings: List[Tuple["weakref.ref", Callable[[Any], 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
|
|
from . import cloud_workspace as _cloud_workspace
|
|
from . import dialog_buttons as _dialog_buttons
|
|
from . import connectors as _connectors
|
|
|
|
# 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,
|
|
**_cloud_workspace.STRINGS,
|
|
**_dialog_buttons.STRINGS,
|
|
**_connectors.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
|
|
_apply_bindings()
|
|
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`. For a single widget whose text is one
|
|
key, prefer :func:`bind_text` and friends — they need no callback of their
|
|
own and cannot keep a destroyed widget alive."""
|
|
_listeners.append(fn)
|
|
fn()
|
|
|
|
|
|
# ---- per-widget bindings -------------------------------------------------
|
|
|
|
def _bind(widget: Any, apply: Callable[[Any], None]) -> Any:
|
|
"""Attach a text re-application to one widget, run it now, return the widget.
|
|
|
|
``apply`` takes the widget as its argument rather than closing over it: a
|
|
closure would keep the widget alive for the life of the process, which is
|
|
exactly what the weak reference here exists to avoid.
|
|
|
|
The widget comes back out so a call site can bind IN PLACE of the one-shot
|
|
call it replaces — ``bind_text(QLabel(), k)`` where ``QLabel(tr(k))`` was —
|
|
without spending a line, which several screens here cannot afford (Gate S).
|
|
"""
|
|
_bindings.append((weakref.ref(widget), apply))
|
|
apply(widget)
|
|
return widget
|
|
|
|
|
|
def _apply_bindings() -> None:
|
|
"""Re-apply every live binding; drop the ones whose widget is gone.
|
|
|
|
Both halves of "gone" are handled: the Python wrapper collected (the weak
|
|
ref answers None) and the C++ object deleted underneath a live wrapper
|
|
(``RuntimeError``). Neither may stop the remaining widgets from updating.
|
|
"""
|
|
alive: List[Tuple["weakref.ref", Callable[[Any], None]]] = []
|
|
for ref, apply in _bindings:
|
|
widget = ref()
|
|
if widget is None:
|
|
continue
|
|
try:
|
|
apply(widget)
|
|
except RuntimeError:
|
|
continue
|
|
alive.append((ref, apply))
|
|
_bindings[:] = alive
|
|
|
|
|
|
def bind_text(widget: Any, key: str, **kwargs) -> Any:
|
|
"""Keep ``widget``'s label on ``key`` through every language change."""
|
|
return _bind(widget, lambda w: w.setText(tr(key, **kwargs)))
|
|
|
|
|
|
def bind_tip(widget: Any, key: str, **kwargs) -> Any:
|
|
"""Keep ``widget``'s tooltip on ``key`` through every language change."""
|
|
return _bind(widget, lambda w: w.setToolTip(tr(key, **kwargs)))
|
|
|
|
|
|
def bind_placeholder(widget: Any, key: str, **kwargs) -> Any:
|
|
"""Keep an input's placeholder on ``key`` through every language change."""
|
|
return _bind(widget, lambda w: w.setPlaceholderText(tr(key, **kwargs)))
|
|
|
|
|
|
def bind_items(widget: Any, keys: Iterable[str]) -> Any:
|
|
"""Keep a combo's item LABELS on ``keys``, by position.
|
|
|
|
``setItemText`` on purpose: clearing and re-adding the items would drop the
|
|
per-item data every caller persists (routing mode, task type) and reset the
|
|
current selection as a side effect of a translation.
|
|
"""
|
|
keys = list(keys)
|
|
|
|
def _apply(w: Any) -> None:
|
|
"""Re-label each item that still exists, leaving its data alone."""
|
|
for i, key in enumerate(keys[:w.count()]):
|
|
w.setItemText(i, tr(key))
|
|
|
|
return _bind(widget, _apply)
|
|
|
|
|
|
def bind_dynamic(widget: Any, apply: Callable[[], None]) -> Any:
|
|
"""Bind text that is not one plain key — a count, a name, a joined list.
|
|
|
|
``apply`` takes no argument and re-reads whatever it needs itself; the
|
|
widget is still what decides how long the binding lives.
|
|
"""
|
|
return _bind(widget, lambda _w: apply())
|