fix(i18n): đổi ngôn ngữ áp lại toàn bộ chữ trên màn hình

Trước đây rất nhiều widget gọi setText(tr(...)) một lần lúc dựng, nên sau khi
đổi ngôn ngữ một nửa màn hình vẫn giữ tiếng cũ. Thêm họ bind_text/bind_tip/
bind_placeholder/bind_items/bind_dynamic trong i18n: khoá dịch được gắn thẳng
vào widget (giữ tham chiếu yếu) và tự áp lại mỗi lần set_language.

- co4e sidebar, node property panel, run control, routing toggle, nav rail,
  top bar, filter scaffold, usage chart: chuyển sang binding hoặc tự đăng ký
  on_language_changed thay vì trông chờ nơi nhúng.
- RoutingToggle/AutoRunToggle tự đăng ký retranslate và dùng
  AdjustToContents để bản dịch dài không bị cắt.
- BusyOverlay mới: che cửa sổ trong lúc set_language chạy đồng bộ trên GUI
  thread, tránh cảm giác treo app.
- co4e sidebar chỉ đọc thư viện skill một lần (_skill_prefix_lookup) thay vì
  quét đĩa cho từng skill — trước đó mỗi lần reload tốn ~3.8s đứng GUI.
- Bổ sung bản dịch ja/vi còn để nguyên tiếng Anh; sửa chiều cao hàng
  Settings ở nav rail.
- Thêm 4 bộ test UI cho các thay đổi trên.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-10 01:35:43 +09:00
committed by thanhnv
co-authored by Claude Opus 5
parent 58a2a4507d
commit 9459dbe197
31 changed files with 1625 additions and 131 deletions
+108
View File
@@ -0,0 +1,108 @@
"""Lớp phủ "đang xử lý" ở cấp cửa sổ, dành cho tác vụ chặn GUI thread.
Vì sao là file riêng chứ không nhét vào ``main_window.py``: file đó chỉ còn 9
dòng vật lý dưới trần 400 của Gate S, và một lớp phủ cấp cửa sổ là một trách
nhiệm riêng (guardrail G6).
Cùng lý do ``repaint()`` với panel bận của GraphRAG — xem
``presentation/graph/structure_graph_view.py:139-151``.
"""
from __future__ import annotations
from time import perf_counter
from typing import Callable
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
# Dưới mức này người dùng chưa kịp nhận ra mình đang đợi, nên một lớp phủ toàn
# cửa sổ chỉ kịp nháy lên rồi tắt — tự nó là một khuyết tật giao diện, không
# phải một lời trấn an.
_NOTICEABLE_MS = 400.0
class BusyOverlay(QWidget):
"""A window-wide "please wait" cover for work that blocks the GUI thread.
Deliberately NOT registered with :func:`i18n.on_language_changed`: the text
is supplied by the caller right before the block and must stay in the
language the rest of the screen is still showing.
"""
def __init__(self, parent: QWidget) -> None:
"""Build the cover hidden; it sizes itself to the parent on every show."""
super().__init__(parent)
self.setObjectName("busyOverlay")
# A QWidget SUBCLASS ignores a stylesheet background without this
# attribute; a plain QWidget instance (the panel below) does not need it.
self.setAttribute(Qt.WA_StyledBackground, True)
self.setFocusPolicy(Qt.NoFocus)
# Chưa đo được lượt nào: xem mục ``run_blocking``.
self._last_ms: float | None = None
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addStretch(1)
row = QHBoxLayout()
row.addStretch(1)
self._panel = QWidget()
self._panel.setObjectName("busyOverlayPanel")
inner = QHBoxLayout(self._panel)
inner.setContentsMargins(24, 18, 24, 18)
self._label = QLabel()
inner.addWidget(self._label)
row.addWidget(self._panel)
row.addStretch(1)
lay.addLayout(row)
lay.addStretch(1)
self.hide()
def text(self) -> str:
"""Chữ đang hiện trên lớp phủ (dùng cho test)."""
return self._label.text()
def run_blocking(self, message: str, work: Callable[[], None]) -> None:
"""Run ``work`` on the GUI thread, covered only when that is worth doing.
Nothing can time the freeze WHILE it happens: the GUI thread stops, so
no timer fires and no watchdog can raise the cover mid-way. The only
honest clock is the PREVIOUS run of this same call, so that is what
decides. No measurement yet (the first switch of a process) errs
towards showing: one flash is a smaller defect than a multi-second
freeze with nothing on screen to explain it.
The result is self-calibrating. A fast machine flashes once per launch
and then stays out of the way; a slow one, or a big skill library, gets
the cover on every switch from the second one on.
``work`` is timed and its exceptions propagate — the cover still comes
down, so a raising callback cannot leave it stuck on screen forever.
"""
if self._last_ms is None or self._last_ms >= _NOTICEABLE_MS:
self.show_busy(message)
started = perf_counter()
try:
work()
finally:
self._last_ms = (perf_counter() - started) * 1000.0
self.hide_busy()
def show_busy(self, message: str) -> None:
"""Show the cover and FORCE it onto the screen right now.
``repaint()``, not ``update()``: the caller is about to block the GUI
thread, so a queued paint would only run once the freeze is over — the
one moment the cover is no longer needed.
No animated progress bar on purpose: with no event loop running,
nothing would move; only static text is guaranteed to be readable.
"""
self._label.setText(message)
self.setGeometry(self.parent().rect())
self.show()
self.raise_()
self.repaint()
def hide_busy(self) -> None:
"""Release the cover. Call from ``finally`` so a raising callback
cannot leave it stuck on screen forever."""
self.hide()
+13
View File
@@ -260,6 +260,12 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip"))
if hasattr(self, "provider_lbl"):
self.provider_lbl.setText(tr("app.provider"))
# The label is hidden — the combo names itself through its tooltip
# (see top_bar._build_account_row), so that is the one users read.
self.provider_combo.setToolTip(tr("app.provider"))
if hasattr(self, "nav_project"):
self.nav_project.setToolTip(tr("app.nav.project_pick"))
self.nav_recents_hdr.setText(tr("app.nav.recents"))
if hasattr(self, "settings_btn"):
self.settings_btn.setText(tr("app.settings"))
if hasattr(self, "theme_btn"):
@@ -271,6 +277,13 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
if getattr(self, "help_agent", None) is not None:
self.help_agent.retranslate()
self._tray.retranslate()
# Thanh trạng thái (góc dưới bên trái) nhận thông báo từ hàng chục nơi
# qua signal ``status_message``, và signal đó mang CHUỖI ĐÃ DỊCH chứ
# không mang khoá — nên không thể dịch lại câu đang hiện. Đưa nó về câu
# nền của ngôn ngữ mới: câu cũ không đọng lại bằng thứ tiếng vừa rời đi,
# mà chỗ đó cũng không trống trơn. Thông báo là ghi chú về một việc vừa
# xong, nên bỏ nó đi khi đổi ngôn ngữ không làm mất thông tin nào.
self.statusBar().showMessage(tr("app.status.ready"))
# ---- system tray (run in background when the window is closed) ---
+16 -2
View File
@@ -253,10 +253,24 @@ class NavRailMixin:
tree.blockSignals(blocked)
# Both destination lists are exactly as tall as their rows; the
# stretch in between belongs to RECENTS.
#
# The frame, and nothing else. A flat ``+ 8`` here used to leave 6px
# of dead space under the last row of each list, and because the
# Settings button sits DIRECTLY under nav_bottom (nvl has no
# spacing), that space landed between Giám sát and Settings only —
# so three rows that read as one list were spaced 18/26px. Padding
# a row is the item delegate's job; this is the frame's.
row_h = 0
for tree in (self.nav, self.nav_bottom):
n = tree.topLevelItemCount()
row_h = tree.sizeHintForRow(0) if n else 0
tree.setFixedHeight(n * row_h + 8)
row_h = tree.sizeHintForRow(0) if n else row_h
tree.setFixedHeight(n * row_h + 2 * tree.frameWidth())
# Settings is one more row of the same list, so it gets the rows'
# own height rather than a second set of paddings guessed to match
# it — the only way the three stay evenly spaced when the font (and
# with it ``sizeHintForRow``) is not the one this was tuned on.
if row_h and hasattr(self, "_nav_settings_btn"):
self._nav_settings_btn.setFixedHeight(row_h)
if keep:
self._select_nav_row(*keep)
finally:
+47 -3
View File
@@ -54,7 +54,12 @@ class TopBarMixin:
self._nav_settings_btn.setCursor(Qt.PointingHandCursor)
self._nav_settings_btn.clicked.connect(self._open_settings)
srow = QHBoxLayout(self._nav_settings_btn)
srow.setContentsMargins(_NAV_ROW_INSET, 6, 8, 6)
# No vertical padding of its own: ``_rebuild_nav`` pins this button to the
# nav rows' OWN height, so the 6px a row pads with is already inside
# that number. Adding it again here made the row taller than the button
# (28 wanted, 20 given), which both clipped the icon and pushed the text
# 8px below an even pitch with Dashboard / Giám sát.
srow.setContentsMargins(_NAV_ROW_INSET, 0, 8, 0)
srow.setSpacing(_NAV_ROW_GAP)
self._nav_settings_icon = QLabel()
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16))
@@ -63,6 +68,9 @@ class TopBarMixin:
srow.addWidget(self._nav_settings_icon)
srow.addWidget(self._nav_settings_text)
srow.addStretch(1)
# The first _rebuild_nav() ran before this button existed (it is what
# fills the list this row belongs under), so take the height here too.
self._nav_settings_btn.setFixedHeight(self.nav_bottom.sizeHintForRow(0))
nvl.addWidget(self._nav_settings_btn)
self._account_row = self._build_account_row()
@@ -194,6 +202,39 @@ class TopBarMixin:
# Khong bao "dang dung <provider>" o thanh trang thai: chinh bo chon
# provider nam ngay tren man hinh va da hien thu vua chon, nen dong thong
# bao chi nhac lai mot thu nguoi dung vua tu tay lam.
def _lang_busy_overlay(self):
"""The window's busy cover, built on first use.
Built lazily so a window that never changes language never gets one —
and so ``_open_settings`` can be checked for "no switch, no flash".
"""
overlay = getattr(self, "_lang_busy", None)
if overlay is None:
from .busy_overlay import BusyOverlay
overlay = BusyOverlay(self)
self._lang_busy = overlay
return overlay
def _switch_language(self, lang: str) -> None:
"""Apply a new UI language behind a busy cover.
``set_language`` runs every registered widget's re-translation on the
GUI thread, which on a large skill library takes long enough to look
like a hang. Nothing can raise a cover once that has started (no event
loop is left running), so it goes up FIRST — see ``busy_overlay.py``.
The message is read before the switch on purpose: mid-switch the only
language the user can still read is the one being left behind.
"""
message = tr("app.lang.switching")
self.language_combo.setEnabled(False)
try:
self._lang_busy_overlay().run_blocking(message, lambda: set_language(lang))
finally:
# In a ``finally`` so a listener that raises cannot leave the
# switcher locked for the rest of the session.
self.language_combo.setEnabled(True)
def _on_language_changed(self, _idx: int) -> None:
"""Đổi ngôn ngữ giao diện; trùng ngôn ngữ hiện tại thì bỏ qua để không dựng lại
toàn bộ chữ vô ích.
@@ -203,7 +244,7 @@ class TopBarMixin:
return
self.ctx.config.language = lang
self.ctx.save()
set_language(lang) # notifies every registered persistent widget
self._switch_language(lang) # notifies every registered persistent widget
def _open_settings(self) -> None:
"""Mở hộp thoại Cài đặt; bấm Lưu thì áp lại theme và làm mới thanh trên."""
dlg = SettingsDialog(self.ctx, self)
@@ -214,7 +255,10 @@ class TopBarMixin:
from ...ui.icons import icon as _theme_icon
self.theme_btn.setIcon(
_theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
set_language(self.ctx.config.language) # apply if changed in Settings
# Guarded, not left to set_language's own no-op check: the cover
# around the switch would otherwise flash on every Save.
if self.ctx.config.language != get_language():
self._switch_language(self.ctx.config.language)
# reflect provider/theme/language changes
i = self.provider_combo.findData(self.ctx.config.active_provider)
if i >= 0: