CI / test (push) Canceled after 0s
fix các bug theo yêu cầu https://fptsoftware362-my.sharepoint.com/❌/g/personal/nampdt_fpt_com/IQAHBJ4A9xqDTLgvt2bhukJEAdRB5LRz2hbJpTivvIiBSYM?wdExp=TEAMS-TREATMENT&web=1&isSPOFile=1&ovuser=f01e930a-b52e-42b1-b70f-a8882b5d043b%2CAnhTNM1%40fpt.com&clickparams=eyJBcHBOYW1lIjoiVGVhbXMtRGVza3RvcCIsIkFwcFZlcnNpb24iOiI0OS8yNjA4MTMxOTMxNyIsIkhhc0ZlZGVyYXRlZFVzZXIiOmZhbHNlfQ%3D%3D --------- Co-authored-by: Duy Le Huu <duylh19@fpt.com> Reviewed-on: #10 Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.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())
|