diff --git a/i18n/__init__.py b/i18n/__init__.py index 20f004a..ae6e09a 100644 --- a/i18n/__init__.py +++ b/i18n/__init__.py @@ -13,10 +13,19 @@ 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 -from typing import Callable, Dict, List +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). @@ -25,6 +34,8 @@ 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 @@ -62,6 +73,7 @@ def set_language(lang: str) -> None: if lang == _current: return _current = lang + _apply_bindings() for fn in list(_listeners): try: fn() @@ -96,6 +108,87 @@ 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`.""" + 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()) diff --git a/i18n/agents_admin_tab.py b/i18n/agents_admin_tab.py index 7f92e5c..2a3414d 100644 --- a/i18n/agents_admin_tab.py +++ b/i18n/agents_admin_tab.py @@ -158,7 +158,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "vi": "Không tìm thấy agent '{name}'."}, # ---- agents_admin_tab.py — Admin-only agent catalog ------------------- - "agents_admin.page_title": {"en": "Agents Admin", "ja": "Agents Admin", "vi": "Agents Admin"}, + "agents_admin.page_title": {"en": "Agents Admin", "ja": "エージェント管理", "vi": "Agents Admin"}, "agents_admin.edit_row_tooltip": {"en": "Edit", "ja": "編集", "vi": "Sửa"}, "agents_admin.delete_row_tooltip": {"en": "Delete", "ja": "削除", "vi": "Xóa"}, "agents_admin.hint": { @@ -179,7 +179,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "en": "Extra instructions this agent always follows (optional)…", "ja": "このエージェントが常に従う追加指示(任意)…", "vi": "Chỉ dẫn bổ sung agent này luôn tuân theo (tùy chọn)…"}, - "agents_admin.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Provider"}, + "agents_admin.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Nhà cung cấp"}, "agents_admin.provider_default": { "en": "(machine's active provider)", "ja": "(各マシンの現在のプロバイダー)", "vi": "(provider hiện tại của máy)"}, diff --git a/i18n/composer.py b/i18n/composer.py index bc6a267..d49b72b 100644 --- a/i18n/composer.py +++ b/i18n/composer.py @@ -168,8 +168,8 @@ STRINGS: Dict[str, Dict[str, str]] = { "composer.manage_skills": {"en": "Manage skills…", "ja": "スキルを管理…", "vi": "Quản lý skill…"}, # ---- schedule_task_tab.py / task_editor_dialog.py ------------------- - "schedtask.title": {"en": "Schedule Task", "ja": "Schedule Task", "vi": "Schedule Task"}, - "schedtask.view.kanban": {"en": "Kanban", "ja": "Kanban", "vi": "Kanban"}, + "schedtask.title": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"}, + "schedtask.view.kanban": {"en": "Kanban", "ja": "カンバン", "vi": "Kanban"}, "schedtask.view.calendar": {"en": "Calendar", "ja": "カレンダー", "vi": "Lịch"}, "schedtask.no_title": {"en": "(untitled)", "ja": "(無題)", "vi": "(chưa có tên)"}, "schedtask.cal_today": {"en": "Today", "ja": "今日", "vi": "Hôm nay"}, @@ -195,23 +195,23 @@ STRINGS: Dict[str, Dict[str, str]] = { "en": "Describe what you want in natural language — AI proposes tasks/schedule/chain, you confirm before anything is created.", "ja": "自然文で説明すると、AIがタスク・スケジュール・チェーンを提案します。確認後に作成されます。", "vi": "Mô tả bằng ngôn ngữ tự nhiên — AI đề xuất task/lịch/chuỗi, bạn xác nhận rồi mới tạo."}, - "schedtask.no_tasks": {"en": "No tasks", "ja": "タスクなし", "vi": "No tasks"}, + "schedtask.no_tasks": {"en": "No tasks", "ja": "タスクなし", "vi": "Chưa có task"}, "schedtask.no_schedule": {"en": "No schedule", "ja": "スケジュールなし", "vi": "Chưa đặt lịch"}, "schedtask.last_success": {"en": "Last: Success", "ja": "前回: 成功", "vi": "Lần cuối: Thành công"}, "schedtask.last_failed": {"en": "Last: Failed", "ja": "前回: 失敗", "vi": "Lần cuối: Lỗi"}, "schedtask.last_never": {"en": "Last: not run", "ja": "前回: 未実行", "vi": "Lần cuối: chưa chạy"}, - "schedtask.status.backlog": {"en": "Backlog", "ja": "Backlog", "vi": "Backlog"}, - "schedtask.status.scheduled": {"en": "Scheduled", "ja": "Scheduled", "vi": "Scheduled"}, - "schedtask.status.running": {"en": "Running", "ja": "Running", "vi": "Running"}, - "schedtask.status.waiting_input": {"en": "Waiting Input", "ja": "Waiting Input", "vi": "Waiting Input"}, - "schedtask.status.done": {"en": "Done", "ja": "Done", "vi": "Done"}, - "schedtask.status.failed": {"en": "Failed", "ja": "Failed", "vi": "Failed"}, - "schedtask.status.paused": {"en": "Paused", "ja": "Paused", "vi": "Paused"}, + "schedtask.status.backlog": {"en": "Backlog", "ja": "バックログ", "vi": "Chờ xử lý"}, + "schedtask.status.scheduled": {"en": "Scheduled", "ja": "予約済み", "vi": "Đã lên lịch"}, + "schedtask.status.running": {"en": "Running", "ja": "実行中", "vi": "Đang chạy"}, + "schedtask.status.waiting_input": {"en": "Waiting Input", "ja": "入力待ち", "vi": "Chờ nhập"}, + "schedtask.status.done": {"en": "Done", "ja": "完了", "vi": "Hoàn thành"}, + "schedtask.status.failed": {"en": "Failed", "ja": "失敗", "vi": "Thất bại"}, + "schedtask.status.paused": {"en": "Paused", "ja": "一時停止", "vi": "Tạm dừng"}, "schedtask.type.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"}, "schedtask.type.co4e_code": {"en": "Code", "ja": "Code", "vi": "Code"}, - "schedtask.type.flow": {"en": "Flow", "ja": "Flow", "vi": "Flow"}, - "schedtask.type.script": {"en": "Script", "ja": "Script", "vi": "Script"}, - "schedtask.type.manual": {"en": "Manual", "ja": "Manual", "vi": "Manual"}, + "schedtask.type.flow": {"en": "Flow", "ja": "フロー", "vi": "Flow"}, + "schedtask.type.script": {"en": "Script", "ja": "スクリプト", "vi": "Script"}, + "schedtask.type.manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"}, "schedtask.priority.low": {"en": "Low", "ja": "低", "vi": "Thấp"}, "schedtask.priority.medium": {"en": "Medium", "ja": "中", "vi": "Trung bình"}, "schedtask.priority.high": {"en": "High", "ja": "高", "vi": "Cao"}, @@ -229,7 +229,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "vi": "Double-click một dòng để mở thư mục artifact của lần chạy đó."}, "schedtask.hist_col_time": {"en": "Finished at", "ja": "完了時刻", "vi": "Hoàn thành lúc"}, "schedtask.hist_col_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"}, - "schedtask.hist_col_run": {"en": "Run ID", "ja": "実行ID", "vi": "Run ID"}, + "schedtask.hist_col_run": {"en": "Run ID", "ja": "実行ID", "vi": "Mã lần chạy"}, "schedtask.hist_col_error": {"en": "Error", "ja": "エラー", "vi": "Lỗi"}, "schedtask.menu_create_next": { "en": "Create next task from output", "ja": "出力から次タスクを作成", @@ -261,7 +261,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "schedtask.no_workspace": {"en": "— No workspace —", "ja": "— ワークスペースなし —", "vi": "— Không có workspace —"}, "schedtask.f_agent": {"en": "Agent", "ja": "エージェント", "vi": "Agent"}, "schedtask.no_agent": {"en": "— No agent preset —", "ja": "— エージェントなし —", "vi": "— Không dùng agent —"}, - "schedtask.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Provider"}, + "schedtask.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Nhà cung cấp"}, "schedtask.provider_default": { "en": "— Default (Settings) —", "ja": "— 既定(設定)—", "vi": "— Mặc định (Settings) —"}, "schedtask.f_model": {"en": "Model", "ja": "モデル", "vi": "Model"}, @@ -317,7 +317,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "schedtask.repeat.daily": {"en": "Daily", "ja": "毎日", "vi": "Hằng ngày"}, "schedtask.repeat.weekly": {"en": "Weekly", "ja": "毎週", "vi": "Hằng tuần"}, "schedtask.repeat.monthly": {"en": "Monthly", "ja": "毎月", "vi": "Hằng tháng"}, - "schedtask.repeat.cron": {"en": "Cron expression", "ja": "Cron式", "vi": "Cron expression"}, + "schedtask.repeat.cron": {"en": "Cron expression", "ja": "Cron式", "vi": "Biểu thức cron"}, "schedtask.f_task_mode": {"en": "Task type", "ja": "タスク種別", "vi": "Loại task"}, # Run kind: an AI agent vs a saved Co4E flow + multi-format import "schedtask.f_run_kind": {"en": "Run", "ja": "実行対象", "vi": "Chạy"}, diff --git a/i18n/cowork_tab.py b/i18n/cowork_tab.py index de5c2b5..c01f387 100644 --- a/i18n/cowork_tab.py +++ b/i18n/cowork_tab.py @@ -93,7 +93,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "en": "Enable the predefined Req→Demo flow feature (off by default).", "ja": "定義済みの Req→Demo フロー機能を有効化(初期値はオフ)。", "vi": "Bật tính năng Flow Req→Demo dựng sẵn (mặc định tắt)."}, - "code.flow_btn": {"en": "Flow Management", "ja": "Flow Management", "vi": "Flow Management"}, + "code.flow_btn": {"en": "Flow Management", "ja": "フロー管理", "vi": "Flow Management"}, "code.flow_btn_tooltip": { "en": "Build and run a multi-stage flow from requirement to demo.", "ja": "要件からデモまでの多段フローを作成・実行します。", @@ -152,7 +152,7 @@ STRINGS: Dict[str, Dict[str, str]] = { # because until the index existed nothing had to refer to it. "settings.group.general": {"en": "General", "ja": "一般", "vi": "Chung"}, "settings.group.provider": {"en": "AI Provider", "ja": "AI プロバイダー", "vi": "Nhà cung cấp AI"}, - "settings.group.parameter": {"en": "Parameter", "ja": "Parameter", "vi": "Parameter"}, + "settings.group.parameter": {"en": "Parameter", "ja": "パラメータ", "vi": "Tham số"}, "settings.group.about": {"en": "About", "ja": "このアプリについて", "vi": "Giới thiệu"}, "settings.param_section_pricing": { "en": "Model pricing", "ja": "モデル価格", "vi": "Bảng giá model"}, diff --git a/i18n/hint.py b/i18n/hint.py index 0d0f613..1825798 100644 --- a/i18n/hint.py +++ b/i18n/hint.py @@ -45,8 +45,8 @@ STRINGS: Dict[str, Dict[str, str]] = { "schedtask.step_prompt_ph": {"en": "Prompt / command", "ja": "プロンプト/コマンド", "vi": "Prompt / lệnh"}, "schedtask.stepexec.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"}, "schedtask.stepexec.co4e": {"en": "Code", "ja": "Code", "vi": "Code"}, - "schedtask.stepexec.script": {"en": "Script", "ja": "Script", "vi": "Script"}, - "schedtask.stepexec.manual": {"en": "Manual", "ja": "Manual", "vi": "Manual"}, + "schedtask.stepexec.script": {"en": "Script", "ja": "スクリプト", "vi": "Script"}, + "schedtask.stepexec.manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"}, "schedtask.del_step_tooltip": {"en": "Delete the selected step", "ja": "選択したステップを削除", "vi": "Xóa bước đang chọn"}, "schedtask.guide_tooltip": { @@ -168,7 +168,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "en": "Safety: the scheduler will NEVER auto-run this — it parks in Waiting Input until you right-click → Run now.", "ja": "安全: 自動実行されず、Run nowまで待機します。", "vi": "An toàn: scheduler KHÔNG BAO GIỜ tự chạy task này — nó nằm ở Waiting Input tới khi bạn chuột phải → Chạy ngay."}, - "schedtask.g_input": {"en": "Input", "ja": "入力", "vi": "Input"}, + "schedtask.g_input": {"en": "Input", "ja": "入力", "vi": "Đầu vào"}, "schedtask.f_input_mode": {"en": "Input mode", "ja": "入力モード", "vi": "Chế độ input"}, "schedtask.inmode.empty": {"en": "Empty (default)", "ja": "空(既定)", "vi": "Trống (mặc định)"}, "schedtask.inmode.manual": {"en": "Manual text", "ja": "手入力テキスト", "vi": "Văn bản nhập tay"}, @@ -199,7 +199,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "ja": "各URLを取得し(ベストエフォート)、テキストをコンテキストとして渡します。", "vi": "Mỗi link được tải nội dung (khi có thể) và đưa vào ngữ cảnh cho agent."}, "schedtask.f_prev_task": {"en": "Previous task", "ja": "前タスク", "vi": "Task trước"}, - "schedtask.g_output": {"en": "Output", "ja": "出力", "vi": "Output"}, + "schedtask.g_output": {"en": "Output", "ja": "出力", "vi": "Đầu ra"}, "schedtask.f_output_mode": {"en": "Output mode", "ja": "出力モード", "vi": "Chế độ output"}, "schedtask.g_dependency": {"en": "Dependency / Next task", "ja": "依存 / 次タスク", "vi": "Phụ thuộc / Task tiếp theo"}, "schedtask.f_next_task": {"en": "Next task", "ja": "次タスク", "vi": "Task tiếp theo"}, @@ -244,8 +244,8 @@ STRINGS: Dict[str, Dict[str, str]] = { "ja": "プレビュー(確認するまで作成されません):", "vi": "Xem trước (chưa tạo gì cho tới khi bạn xác nhận):"}, "schedtask.ai_confirm": {"en": "Create tasks", "ja": "タスクを作成", "vi": "Tạo các task"}, - "schedtask.tab_ai": {"en": "AI gen task", "ja": "AIタスク生成", "vi": "AI gen task"}, - "schedtask.tab_import": {"en": "Import", "ja": "インポート", "vi": "Import"}, + "schedtask.tab_ai": {"en": "AI gen task", "ja": "AIタスク生成", "vi": "Tạo task bằng AI"}, + "schedtask.tab_import": {"en": "Import", "ja": "インポート", "vi": "Nhập"}, "schedtask.export_template_btn": { "en": "Create Excel template…", "ja": "Excelテンプレートを作成…", "vi": "Tạo template Excel…"}, diff --git a/i18n/login_dialog.py b/i18n/login_dialog.py index f8ae31e..9351670 100644 --- a/i18n/login_dialog.py +++ b/i18n/login_dialog.py @@ -149,8 +149,14 @@ STRINGS: Dict[str, Dict[str, str]] = { "app.provider": {"en": "Provider:", "ja": "プロバイダー:", "vi": "Nhà cung cấp:"}, "app.language": {"en": "Language:", "ja": "言語:", "vi": "Ngôn ngữ:"}, "app.settings": {"en": "Settings", "ja": "設定", "vi": "Cài đặt"}, - "app.tab.dashboard": {"en": "Dashboard", "ja": "Dashboard", "vi": "Dashboard"}, - "app.tab.schedule": {"en": "Schedule Task", "ja": "Schedule Task", "vi": "Schedule Task"}, + # Shown on the cover while a language switch blocks the GUI thread. It is + # deliberately read BEFORE the switch, so it appears in the language the + # user is leaving — the only one they can still read at that moment. + "app.lang.switching": { + "en": "Switching language…", "ja": "言語を切り替えています…", + "vi": "Đang đổi ngôn ngữ…"}, + "app.tab.dashboard": {"en": "Dashboard", "ja": "ダッシュボード", "vi": "Dashboard"}, + "app.tab.schedule": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"}, "app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"}, "app.tab.code": {"en": "Code", "ja": "Code", "vi": "Code"}, "app.tab.structure": {"en": "GraphRAG", "ja": "GraphRAG", "vi": "GraphRAG"}, @@ -158,7 +164,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "app.tab.monitoring": {"en": "Monitoring", "ja": "モニタリング", "vi": "Giám sát"}, "app.nav.collapse_tooltip": {"en": "Collapse menu to icons only", "ja": "メニューをアイコンのみに折りたたむ", "vi": "Thu gọn menu về icon"}, "app.nav.expand_tooltip": {"en": "Expand menu", "ja": "メニューを展開", "vi": "Mở rộng menu"}, - "app.nav.menu_label": {"en": "MENU", "ja": "MENU", "vi": "MENU"}, + "app.nav.menu_label": {"en": "MENU", "ja": "メニュー", "vi": "MENU"}, # Shown on the rail rows the project gate disables (Cowork, GraphRAG) — # they stay listed and greyed instead of disappearing from the menu. "app.nav.needs_project": { diff --git a/i18n/skills_dialog.py b/i18n/skills_dialog.py index 7690c63..0a48c07 100644 --- a/i18n/skills_dialog.py +++ b/i18n/skills_dialog.py @@ -123,7 +123,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "vi": "Không phân tích được template này. Kiểm tra file .pptx/.xlsx hợp lệ và provider AI trong Settings hoạt động tốt, hoặc tự thêm skill thủ công."}, # ---- flow_dialog.py ----------------------------------------------- - "flow.title": {"en": "Flow Management", "ja": "Flow Management", "vi": "Flow Management"}, + "flow.title": {"en": "Flow Management", "ja": "フロー管理", "vi": "Flow Management"}, "flow.tab_flow": {"en": "Flow", "ja": "フロー", "vi": "Flow"}, "flow.tab_agents": {"en": "Agents", "ja": "エージェント", "vi": "Agents"}, "flow.tab_skills": {"en": "Skills", "ja": "スキル", "vi": "Skills"}, @@ -140,7 +140,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "flow.task_prompt": {"en": "Task (prompt)", "ja": "タスク(プロンプト)", "vi": "Nhiệm vụ (prompt)"}, "flow.skill": {"en": "Skill", "ja": "スキル", "vi": "Skill"}, "flow.agent": {"en": "AI provider", "ja": "AI プロバイダー", "vi": "AI provider"}, - "flow.model_label": {"en": "Agent:", "ja": "Agent:", "vi": "Agent:"}, + "flow.model_label": {"en": "Agent:", "ja": "エージェント:", "vi": "Agent:"}, "flow.default_model": {"en": "(provider default)", "ja": "(プロバイダー既定)", "vi": "(mặc định của provider)"}, "flow.gen_task_from_hint": {"en": "Generate task from hint", "ja": "ヒントからタスクを生成", "vi": "Tạo task từ gợi ý"}, "flow.gen_task_tooltip": { diff --git a/presentation/co4e/agent_list_panel.py b/presentation/co4e/agent_list_panel.py index 508b565..ba368b8 100644 --- a/presentation/co4e/agent_list_panel.py +++ b/presentation/co4e/agent_list_panel.py @@ -35,7 +35,7 @@ from __future__ import annotations from PySide6.QtCore import Qt from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget -from ...i18n import tr +from ...i18n import bind_text, bind_tip from ...ui.icons import icon from .palette_list import _PaletteList @@ -55,9 +55,11 @@ class AgentListPanel(QWidget): def __init__(self, parent: QWidget | None = None) -> None: """Danh sách agent ở cột trái Co4E Studio, kèm nút tạo mới.""" super().__init__(parent) - self.new_btn = QPushButton(tr("co4e.new")) + # Bound, not set once: this panel has no retranslate hook of its own, and + # Co4ETab (which owns the language callback) cannot reach these tooltips. + self.new_btn = bind_text(QPushButton(), "co4e.new") self.new_btn.setIcon(icon("plus")) - self.new_btn.setToolTip(tr("co4e.tt_new_agent")) + bind_tip(self.new_btn, "co4e.tt_new_agent") self.new_btn.setObjectName("co4eSectionAction") self.new_btn.setFlat(True) self.new_btn.setCursor(Qt.PointingHandCursor) @@ -75,11 +77,11 @@ class AgentListPanel(QWidget): # Edit/delete act on the selected row, so they stay with the list. self.edit_btn = QPushButton() self.edit_btn.setIcon(icon("edit")) - self.edit_btn.setToolTip(tr("co4e.tt_edit_agent")) + bind_tip(self.edit_btn, "co4e.tt_edit_agent") self.edit_btn.setFixedWidth(34) self.del_btn = QPushButton() self.del_btn.setIcon(icon("trash")) - self.del_btn.setToolTip(tr("co4e.tt_del_agent")) + bind_tip(self.del_btn, "co4e.tt_del_agent") self.del_btn.setFixedWidth(34) # KHONG noi .clicked o day: cung ly do nhu new_btn o tren. btns.addWidget(self.edit_btn) diff --git a/presentation/co4e/co4e_chat.py b/presentation/co4e/co4e_chat.py index 2005bae..e8c62cc 100644 --- a/presentation/co4e/co4e_chat.py +++ b/presentation/co4e/co4e_chat.py @@ -13,7 +13,7 @@ from PySide6.QtWidgets import QSplitter, QWidget from ...core import co4e, skills as skills_mod from ...core.co4e_builtins import BUILTIN_AGENTS from ...core.worker import AgentWorker -from ...i18n import tr +from ...i18n import bind_dynamic, tr from ...ui.chat_view import ChatView from ...ui.icons import icon from ...presentation.co4e.co4e_chat_view import ChatPanel @@ -55,6 +55,12 @@ class Co4EChatMixin: self._co4e_routed_provider = None # routing provider override for the next turn self._vsplit_sizes = [540, 220] # sizes to restore when expanded self._msgs_collapsed = True + # The tooltip names the action the button would perform, so it depends on + # which way the box is folded — and the fold state lives here, not in the + # panel. Bound so a language change re-reads it instead of freezing the + # wording set when the tab was built. + bind_dynamic(self.chat_toggle_btn, lambda: self.chat_toggle_btn.setToolTip( + tr("co4e.tt_expand_msgs" if self._msgs_collapsed else "co4e.tt_collapse_msgs"))) return panel def _toggle_messages(self) -> None: """Show/hide the WHOLE chat box (message list + composer) below the diff --git a/presentation/co4e/co4e_chat_view.py b/presentation/co4e/co4e_chat_view.py index d844734..dc4e9ef 100644 --- a/presentation/co4e/co4e_chat_view.py +++ b/presentation/co4e/co4e_chat_view.py @@ -48,7 +48,7 @@ from PySide6.QtWidgets import ( from ...core import co4e, skills as skills_mod from ...core.co4e_builtins import BUILTIN_AGENTS -from ...i18n import tr +from ...i18n import bind_placeholder, bind_text, tr from ...theme import current_palette from ...ui.icons import icon from ...ui.routing_toggle import RoutingToggle @@ -223,7 +223,8 @@ class ChatPanel(QWidget): self.header = QWidget(); self.header.setObjectName("msgHeader") mh = QHBoxLayout(self.header); mh.setContentsMargins(6, 3, 6, 3); mh.setSpacing(6) self.msgs_icon = QLabel(); self.msgs_icon.setPixmap(icon("message").pixmap(14, 14)) - self.msgs_title = QLabel(tr("co4e.messages")); self.msgs_title.setObjectName("hint") + self.msgs_title = bind_text(QLabel(), "co4e.messages") + self.msgs_title.setObjectName("hint") self.chat_toggle_btn = QPushButton() self.chat_toggle_btn.setObjectName("msgToggle") self.chat_toggle_btn.setFlat(True) @@ -253,10 +254,11 @@ class ChatPanel(QWidget): crow.addWidget(self.usage_total_lbl) _inp = QWidget(); row = QHBoxLayout(_inp); row.setContentsMargins(0, 0, 0, 0) self.chat_input = _ChatInput() - self.chat_input.setPlaceholderText(tr("co4e.chat_placeholder")) + bind_placeholder(self.chat_input, "co4e.chat_placeholder") # KHONG noi .submit o day: cung ly do nhu chat_toggle_btn o tren # (ben goi noi toi _chat_send cua chinh no). - self.chat_send_btn = QPushButton(tr("co4e.send")); self.chat_send_btn.setIcon(icon("send")) + self.chat_send_btn = bind_text(QPushButton(), "co4e.send") + self.chat_send_btn.setIcon(icon("send")) # KHONG noi .clicked o day: cung ly do nhu tren. row.addWidget(self.chat_input, 1) # Off/Auto/Manual routing toggle for Co4E (surface key "co4e"). diff --git a/presentation/co4e/co4e_layout.py b/presentation/co4e/co4e_layout.py index 5e1cf28..7ab1867 100644 --- a/presentation/co4e/co4e_layout.py +++ b/presentation/co4e/co4e_layout.py @@ -14,7 +14,7 @@ from typing import List from PySide6.QtCore import QSize, Qt from PySide6.QtWidgets import QComboBox, QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QSizePolicy, QSpacerItem, QSplitter, QTabBar, QTabWidget, QVBoxLayout, QWidget from ...core import co4e -from ...i18n import tr +from ...i18n import bind_text, tr from ...theme import current_palette from ...ui.co4e_canvas import Co4ECanvas from ...ui.icons import icon @@ -141,7 +141,9 @@ class Co4ELayoutMixin: self.runs_btn.setToolTip(tr("co4e.tt_runs_tab")) self.runs_btn.toggled.connect(self._show_runs) - bar.addWidget(QLabel(tr("co4e.flow_name"))) + # Bound: nothing else holds this label, so a one-shot tr() here would + # leave "Flow" stuck in the language the toolbar was built in. + bar.addWidget(bind_text(QLabel(), "co4e.flow_name")) bar.addWidget(self.name_edit, 1) bar.addWidget(self.add_step_btn) bar.addWidget(self.save_btn) diff --git a/presentation/co4e/co4e_run_control_widget.py b/presentation/co4e/co4e_run_control_widget.py index 7a7f2a0..af63bb9 100644 --- a/presentation/co4e/co4e_run_control_widget.py +++ b/presentation/co4e/co4e_run_control_widget.py @@ -39,7 +39,7 @@ from PySide6.QtWidgets import ( QWidget, ) -from ...i18n import tr +from ...i18n import bind_text, bind_tip from ...ui.icons import icon @@ -66,13 +66,15 @@ class RunsPagePanel(QWidget): hdr = QHBoxLayout() # The Runs page covers the flow toolbar, so it carries its own way back — # otherwise the toggle that opened it is off screen. - self.back_btn = QPushButton(tr("co4e.back_to_flow")) + # Bound, not set once: this panel has no retranslate hook of its own, and + # Co4ETab (which owns the language callback) cannot reach these strings. + self.back_btn = bind_text(QPushButton(), "co4e.back_to_flow") self.back_btn.setIcon(icon("chevron-left")) - self.back_btn.setToolTip(tr("co4e.tt_back_to_flow")) + bind_tip(self.back_btn, "co4e.tt_back_to_flow") # KHONG noi .clicked o day: ben goi (Co4ETab) tu quyet dinh slot nao # xu ly - panel chi dung widget, khong biet _show_runs la gi. hdr.addWidget(self.back_btn) - self.title_label = QLabel(tr("co4e.running_flows")) + self.title_label = bind_text(QLabel(), "co4e.running_flows") self.title_label.setObjectName("hint") hdr.addWidget(self.title_label) # Show + open the workspace folder where flow outputs land (below the tab, @@ -85,21 +87,21 @@ class RunsPagePanel(QWidget): # ca hai deu thuoc Co4ETab (can ctx/manager de biet duong dan that). hdr.addWidget(self.ws_folder_btn) hdr.addStretch(1) - self.stop_btn = QPushButton(tr("co4e.stop")) + self.stop_btn = bind_text(QPushButton(), "co4e.stop") self.stop_btn.setIcon(icon("stop")) self.stop_btn.setObjectName("danger") - self.stop_btn.setToolTip(tr("co4e.tt_stop_run")) + bind_tip(self.stop_btn, "co4e.tt_stop_run") # KHONG noi .clicked o day: cung ly do nhu back_btn o tren. - self.rename_btn = QPushButton(tr("co4e.rename_run")) + self.rename_btn = bind_text(QPushButton(), "co4e.rename_run") self.rename_btn.setIcon(icon("edit")) - self.rename_btn.setToolTip(tr("co4e.tt_rename_run")) + bind_tip(self.rename_btn, "co4e.tt_rename_run") # KHONG noi .clicked o day: cung ly do nhu back_btn o tren. - self.del_btn = QPushButton(tr("co4e.delete_run")) + self.del_btn = bind_text(QPushButton(), "co4e.delete_run") self.del_btn.setIcon(icon("trash")) - self.del_btn.setToolTip(tr("co4e.tt_delete_run")) + bind_tip(self.del_btn, "co4e.tt_delete_run") # KHONG noi .clicked o day: cung ly do nhu back_btn o tren. - self.clear_btn = QPushButton(tr("co4e.clear_done")) - self.clear_btn.setToolTip(tr("co4e.tt_clear_runs")) + self.clear_btn = bind_text(QPushButton(), "co4e.clear_done") + bind_tip(self.clear_btn, "co4e.tt_clear_runs") # KHONG noi .clicked o day: cung ly do nhu back_btn o tren. (Ban goc # noi thang toi lambda: self.manager.clear_finished(), khong qua mot # method rieng - Co4ETab van giu dung quirk do khi noi lai signal nay.) @@ -113,7 +115,7 @@ class RunsPagePanel(QWidget): self.table.verticalHeader().setVisible(False) self.table.setEditTriggers(QTableWidget.NoEditTriggers) self.table.setSelectionBehavior(QTableWidget.SelectRows) - self.table.setToolTip(tr("co4e.tt_runs_list")) + bind_tip(self.table, "co4e.tt_runs_list") # KHONG noi .itemDoubleClicked o day: cung ly do nhu back_btn o tren. # Right-click a run → Open / Delete (delete a single old run from history). self.table.setContextMenuPolicy(Qt.CustomContextMenu) diff --git a/presentation/co4e/co4e_sidebar.py b/presentation/co4e/co4e_sidebar.py index f79d882..988816d 100644 --- a/presentation/co4e/co4e_sidebar.py +++ b/presentation/co4e/co4e_sidebar.py @@ -11,14 +11,42 @@ from typing import List from PySide6.QtCore import QSize, Qt from PySide6.QtWidgets import QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QSplitter, QVBoxLayout, QWidget from ...core import co4e, skills as skills_mod -from ...i18n import tr +from ...i18n import bind_dynamic, bind_text, bind_tip, tr from ...ui.icons import icon from ...presentation.co4e.agent_list_panel import AgentListPanel -from ...presentation.co4e.co4e_chat_view import _skill_names from ...presentation.co4e.palette_list import _PaletteList from ...presentation.co4e.skills_list_panel import SkillsListPanel +def _skill_prefix_lookup(all_skills): + """Answer ``skills.skill_prefix_for`` from an ALREADY-LOADED skill list. + + ``skill_prefix_for`` re-reads the whole skill folder on every call, so + asking it once per skill made a sidebar reload cost one full disk scan per + skill — measured at ~3.8s of frozen GUI thread on a 121-skill library, and + that reload runs on every language switch. + + The scan order and the blank-instructions rule are copied from + ``skill_prefix_for`` deliberately: a namesake with no instructions must NOT + end the search, or a skill's text silently becomes empty in an agent prompt. + """ + cache: dict = {} + + def lookup(name: str) -> str: + """The ``## Skill: \\n`` block for one name, or ''.""" + if not name: + return "" + low = name.strip().lower() + if low not in cache: + cache[low] = next( + (f"## Skill: {s.name}\n{s.instructions.strip()}" for s in all_skills + if (s.slug == low or s.name.lower() == low) and s.instructions.strip()), + "") + return cache[low] + + return lookup + + class Co4ESidebarMixin: """Cột trái của Co4E Studio: Workflows, Agents, Skills và Flow Status.""" def _build_sidebar(self) -> QWidget: @@ -66,9 +94,12 @@ class Co4ESidebarMixin: col = _Col(self.side_split) # --- WORKFLOWS --------------------------------------------------- - self.wf_new_btn = QPushButton(tr("co4e.new")) + # Bound, not set once: Co4ETab._retranslate reloads the sidebar's LIST + # CONTENTS, but these headings, buttons and tooltips are built here and + # nothing re-applied them — they stayed in the language of app start-up. + self.wf_new_btn = bind_text(QPushButton(), "co4e.new") self.wf_new_btn.setIcon(icon("plus")) - self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf")) + bind_tip(self.wf_new_btn, "co4e.tt_new_wf") self.wf_new_btn.setObjectName("co4eSectionAction") self.wf_new_btn.setFlat(True) self.wf_new_btn.setCursor(Qt.PointingHandCursor) @@ -78,7 +109,7 @@ class Co4ESidebarMixin: # Draggable: drag a flow onto the canvas to merge it in (Nova-style); # double-click loads it onto the canvas. self.wf_list = _PaletteList(payload_role=Qt.UserRole + 2) - self.wf_list.setToolTip(tr("co4e.drag_hint")) + bind_tip(self.wf_list, "co4e.drag_hint") self.wf_list.itemDoubleClicked.connect(self._load_selected_workflow) self.wf_list.setContextMenuPolicy(Qt.CustomContextMenu) self.wf_list.customContextMenuRequested.connect(self._wf_context_menu) @@ -93,8 +124,9 @@ class Co4ESidebarMixin: wl.addLayout(wf_btns) # Its own row: sharing one line with the three icon buttons cut "Chạy # nền" down to "Chạ" as soon as the sidebar hit its narrow width. - self.wf_runbg_btn = QPushButton(tr("co4e.run_bg")); self.wf_runbg_btn.setIcon(icon("play")) - self.wf_runbg_btn.setToolTip(tr("co4e.tt_run_bg")) + self.wf_runbg_btn = bind_text(QPushButton(), "co4e.run_bg") + self.wf_runbg_btn.setIcon(icon("play")) + bind_tip(self.wf_runbg_btn, "co4e.tt_run_bg") self.wf_runbg_btn.clicked.connect(self._run_selected_in_background) wl.addWidget(self.wf_runbg_btn) col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3) @@ -135,12 +167,12 @@ class Co4ESidebarMixin: self.runs_more_btn.setIcon(icon("chevron-right")) self.runs_more_btn.setFixedWidth(30) self.runs_more_btn.setFlat(True) - self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab")) + bind_tip(self.runs_more_btn, "co4e.tt_runs_tab") self.runs_more_btn.clicked.connect(lambda: self._show_runs(True)) runs_body = QWidget(); rl = QVBoxLayout(runs_body) rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(4) self.runs_side_list = QListWidget() - self.runs_side_list.setToolTip(tr("co4e.tt_runs_tab")) + bind_tip(self.runs_side_list, "co4e.tt_runs_tab") self.runs_side_list.itemClicked.connect(self._on_side_run_clicked) rl.addWidget(self.runs_side_list, 1) col.addWidget(self._section("co4e.runs_tab", runs_body, self.runs_more_btn), 2) @@ -202,7 +234,10 @@ class Co4ESidebarMixin: v.addWidget(body, 1) self._sections[key] = (head, body, stretch) - self._sync_section_arrow(key) + # bind_dynamic, not bind_text: the heading is the fold arrow plus the + # translated name in caps, so re-applying it means re-running the whole + # line rather than pushing one key into setText. + bind_dynamic(head, lambda k=key: self._sync_section_arrow(k)) return box def _fold_section(self, key: str, body: QWidget, box: QWidget, on: bool) -> None: """Fold/unfold a section AND give its height back to the others. @@ -223,7 +258,7 @@ class Co4ESidebarMixin: head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper()) def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton: """Dựng một nút icon nhỏ (rộng 34px) kèm tooltip cho hàng công cụ của mục.""" - b = QPushButton(); b.setIcon(icon(icon_name)); b.setToolTip(tr(tip_key)) + b = QPushButton(); b.setIcon(icon(icon_name)); bind_tip(b, tip_key) b.setFixedWidth(34) b.clicked.connect(slot) return b @@ -254,13 +289,16 @@ class Co4ESidebarMixin: co4e._step_dict(step)) it.setData(Qt.UserRole + 1, ca.id) self.agent_list.addItem(it) - # Skills + # Skills — the library is read ONCE here and both the names and the + # instructions come out of that one read (see _skill_prefix_lookup). self.skill_list.clear() - for name in _skill_names(): - content = skills_mod.skill_prefix_for(name) + all_skills = skills_mod.list_skills() + skills_mod.builtin_skills() + skill_prefix = _skill_prefix_lookup(all_skills) + for skill in all_skills: + name = skill.name payload = co4e._step_dict(co4e.Step( label=name, agent_slug=co4e.slugify(name), role="SKILL", icon="sparkle", - instructions=content, skills=[name])) + instructions=skill_prefix(name), skills=[name])) self.skill_list.addItem(self._palette_item(name, "sparkle", payload)) @staticmethod def _palette_item(text: str, icon_name: str, payload: dict) -> QListWidgetItem: diff --git a/presentation/co4e/node_property_panel.py b/presentation/co4e/node_property_panel.py index 7e256a4..ec885ba 100644 --- a/presentation/co4e/node_property_panel.py +++ b/presentation/co4e/node_property_panel.py @@ -39,7 +39,7 @@ from PySide6.QtWidgets import ( from ...config import PROVIDER_LABELS from ...core.co4e import PERMISSION_PRESETS, Step -from ...i18n import tr +from ...i18n import bind_items, bind_placeholder, bind_text, bind_tip, tr from ...ui.icons import icon, icon_picker_combo from .node_property_actions_mixin import _StepConfigActionsMixin from .step_config_section import _add_section @@ -81,26 +81,30 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea): self.label_edit = QLineEdit() self.label_edit.textChanged.connect(self._on_edit) - form.addRow(tr("co4e.f_label"), self.label_edit) + form.addRow(bind_text(QLabel(), "co4e.f_label"), self.label_edit) self.role_edit = QLineEdit() self.role_edit.textChanged.connect(self._on_edit) - form.addRow(tr("co4e.f_role"), self.role_edit) + form.addRow(bind_text(QLabel(), "co4e.f_role"), self.role_edit) # Dropdown of every icon in the registry (Monitoring's Icon Management # set + built-ins), each row previewing its actual glyph — still # editable so a not-yet-added custom name can be typed directly. self.icon_edit = icon_picker_combo() - self.icon_edit.lineEdit().setPlaceholderText(tr("co4e.f_icon_placeholder")) + # Kept on self because the combo's line edit belongs to C++: a binding + # holds its widget weakly, so with no owner on this side the Python + # wrapper could be collected and the binding silently dropped. + self._icon_line = self.icon_edit.lineEdit() + bind_placeholder(self._icon_line, "co4e.f_icon_placeholder") self.icon_edit.currentTextChanged.connect(self._on_edit) - form.addRow(tr("co4e.f_icon"), self.icon_edit) + form.addRow(bind_text(QLabel(), "co4e.f_icon"), self.icon_edit) self.instructions_edit = QPlainTextEdit() self.instructions_edit.setMaximumHeight(120) self.instructions_edit.textChanged.connect(self._on_edit) - self.gen_btn = QPushButton(tr("co4e.ai_draft")) + self.gen_btn = bind_text(QPushButton(), "co4e.ai_draft") self.gen_btn.setIcon(icon("sparkle")) - self.gen_btn.setToolTip(tr("co4e.ai_draft_tooltip")) + bind_tip(self.gen_btn, "co4e.ai_draft_tooltip") self.gen_btn.setEnabled(ctx is not None) self.gen_btn.clicked.connect(self._ai_draft) instr_box = QWidget() @@ -108,15 +112,15 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea): ib.setContentsMargins(0, 0, 0, 0) ib.addWidget(self.instructions_edit) ib.addWidget(self.gen_btn, alignment=Qt.AlignRight) - form.addRow(tr("co4e.f_instructions"), instr_box) + form.addRow(bind_text(QLabel(), "co4e.f_instructions"), instr_box) # Extra context — free-text background/info fed to the step at run time # (in addition to instructions, attachments and upstream outputs). self.context_edit = QPlainTextEdit() self.context_edit.setMaximumHeight(90) - self.context_edit.setPlaceholderText(tr("co4e.f_context_placeholder")) + bind_placeholder(self.context_edit, "co4e.f_context_placeholder") self.context_edit.textChanged.connect(self._on_edit) - form.addRow(tr("co4e.f_context"), self.context_edit) + form.addRow(bind_text(QLabel(), "co4e.f_context"), self.context_edit) form2, _model_card = _add_section(outer, tr("co4e.tab_model_perm")) @@ -126,28 +130,32 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea): self.model_combo.editTextChanged.connect(self._on_edit) self.load_models_btn = QPushButton() self.load_models_btn.setIcon(icon("download")) - self.load_models_btn.setToolTip(tr("co4e.load_models_tooltip")) + bind_tip(self.load_models_btn, "co4e.load_models_tooltip") self.load_models_btn.clicked.connect(self._load_models) self.load_models_btn.setEnabled(ctx is not None) model_row.addWidget(self.model_combo, 1) model_row.addWidget(self.load_models_btn) mrow = QWidget(); mrow.setLayout(model_row) - form2.addRow(tr("co4e.f_model"), mrow) + form2.addRow(bind_text(QLabel(), "co4e.f_model"), mrow) self.perm_combo = QComboBox() - for preset in PERMISSION_PRESETS: - self.perm_combo.addItem(tr(f"co4e.perm.{preset}"), preset) + perm_keys = [f"co4e.perm.{preset}" for preset in PERMISSION_PRESETS] + for preset, key in zip(PERMISSION_PRESETS, perm_keys): + self.perm_combo.addItem(tr(key), preset) + # Only the visible labels follow the language — the data column stays + # the preset id that ``_on_edit`` persists onto the Step. + bind_items(self.perm_combo, perm_keys) self.perm_combo.currentIndexChanged.connect(self._on_edit) - form2.addRow(tr("co4e.f_permission"), self.perm_combo) + form2.addRow(bind_text(QLabel(), "co4e.f_permission"), self.perm_combo) verify_row = QHBoxLayout() - self.verify_chk = QCheckBox(tr("co4e.f_self_verify")) + self.verify_chk = bind_text(QCheckBox(), "co4e.f_self_verify") self.verify_chk.toggled.connect(self._on_edit) self.rounds_spin = QSpinBox() self.rounds_spin.setRange(1, 5) self.rounds_spin.valueChanged.connect(self._on_edit) verify_row.addWidget(self.verify_chk) - verify_row.addWidget(QLabel(tr("co4e.f_verify_rounds"))) + verify_row.addWidget(bind_text(QLabel(), "co4e.f_verify_rounds")) verify_row.addWidget(self.rounds_spin) verify_row.addStretch(1) vrow = QWidget(); vrow.setLayout(verify_row) @@ -159,15 +167,15 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea): self.skills_list = QListWidget() self.skills_list.setMaximumHeight(110) self.skills_list.itemChanged.connect(self._on_edit) - form3.addRow(tr("co4e.f_skills"), self.skills_list) + form3.addRow(bind_text(QLabel(), "co4e.f_skills"), self.skills_list) # Attachments — files whose extracted text is fed to this step at run time. self.attach_list = QListWidget() self.attach_list.setMaximumHeight(80) - self.attach_add_btn = QPushButton(tr("co4e.attach_add")) + self.attach_add_btn = bind_text(QPushButton(), "co4e.attach_add") self.attach_add_btn.setIcon(icon("plus")) self.attach_add_btn.clicked.connect(self._add_attachment) - self.attach_del_btn = QPushButton(tr("co4e.attach_remove")) + self.attach_del_btn = bind_text(QPushButton(), "co4e.attach_remove") self.attach_del_btn.setIcon(icon("trash")) self.attach_del_btn.clicked.connect(self._del_attachment) att_btns = QHBoxLayout() @@ -175,7 +183,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea): att_btns.addWidget(self.attach_del_btn) att_btns.addStretch(1) abtn = QWidget(); abtn.setLayout(att_btns) - form3.addRow(tr("co4e.f_attachments"), self.attach_list) + form3.addRow(bind_text(QLabel(), "co4e.f_attachments"), self.attach_list) form3.addRow("", abtn) # Parallel sub-agents get their OWN section — same header style as @@ -187,10 +195,10 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea): self.sub_list = QListWidget() self.sub_list.setMaximumHeight(90) self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent - self.sub_add_btn = QPushButton(tr("co4e.add_subagent")) + self.sub_add_btn = bind_text(QPushButton(), "co4e.add_subagent") self.sub_add_btn.setIcon(icon("plus")) self.sub_add_btn.clicked.connect(self._add_subagent) - self.sub_del_btn = QPushButton(tr("co4e.del_subagent")) + self.sub_del_btn = bind_text(QPushButton(), "co4e.del_subagent") self.sub_del_btn.setIcon(icon("trash")) self.sub_del_btn.clicked.connect(self._del_subagent) sub_btns = QHBoxLayout() @@ -203,17 +211,17 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea): # Footer actions — one compact row (Run · Run from here · Delete), # kept below every section, not inside one of the cards. - self.run_btn = QPushButton(tr("co4e.run")) + self.run_btn = bind_text(QPushButton(), "co4e.run") self.run_btn.setIcon(icon("play")) - self.run_btn.setToolTip(tr("co4e.run_this_step")) + bind_tip(self.run_btn, "co4e.run_this_step") self.run_btn.clicked.connect(lambda: self.run_node.emit(self._node_id)) - self.run_from_btn = QPushButton(tr("co4e.run_from_here")) - self.run_from_btn.setToolTip(tr("co4e.run_from_here")) + self.run_from_btn = bind_text(QPushButton(), "co4e.run_from_here") + bind_tip(self.run_from_btn, "co4e.run_from_here") self.run_from_btn.clicked.connect(lambda: self.run_from.emit(self._node_id)) self.del_btn = QPushButton() self.del_btn.setIcon(icon("trash")) self.del_btn.setObjectName("danger") - self.del_btn.setToolTip(tr("co4e.delete_step")) + bind_tip(self.del_btn, "co4e.delete_step") self.del_btn.setFixedWidth(38) self.del_btn.clicked.connect(lambda: self.delete_node.emit(self._node_id)) foot = QHBoxLayout() diff --git a/presentation/co4e/skills_list_panel.py b/presentation/co4e/skills_list_panel.py index a469b4a..fe7d9f8 100644 --- a/presentation/co4e/skills_list_panel.py +++ b/presentation/co4e/skills_list_panel.py @@ -29,7 +29,7 @@ from __future__ import annotations from PySide6.QtCore import Qt from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget -from ...i18n import tr +from ...i18n import bind_text, bind_tip from .palette_list import _PaletteList @@ -50,8 +50,10 @@ class SkillsListPanel(QWidget): cái gì. """ super().__init__(parent) - self.manage_btn = QPushButton(tr("co4e.manage_skills")) - self.manage_btn.setToolTip(tr("co4e.tt_manage_skills")) + # Bound, like AgentListPanel's: the panel owns how its own button reads, + # so no embedder has to remember it in a retranslate method. + self.manage_btn = bind_text(QPushButton(), "co4e.manage_skills") + bind_tip(self.manage_btn, "co4e.tt_manage_skills") self.manage_btn.setObjectName("co4eSectionAction") self.manage_btn.setFlat(True) self.manage_btn.setCursor(Qt.PointingHandCursor) diff --git a/presentation/dashboard/usage_chart_widget.py b/presentation/dashboard/usage_chart_widget.py index efa7397..bdcac15 100644 --- a/presentation/dashboard/usage_chart_widget.py +++ b/presentation/dashboard/usage_chart_widget.py @@ -121,6 +121,13 @@ class UsageChartWidget(QWidget): def retranslate(self) -> None: """Áp lại chữ theo ngôn ngữ đang chọn cho nhãn và tooltip.""" + # setItemText, chứ không clear()+addItem(): cột data của hai combo này + # là thứ quyết định kỳ và chỉ số đang xem, dựng lại danh sách sẽ reset cả + # hai. Khoá dịch suy ra từ chính cột data nên không phải chép lại danh + # sách giá trị ở hai nơi. + for combo, prefix in ((self.gran_combo, "gran"), (self.metric_combo, "metric")): + for i in range(combo.count()): + combo.setItemText(i, tr(f"dashboard.{prefix}_{combo.itemData(i)}")) self.currency_lbl.setText(tr("monitoring.overview_currency")) self.currency_combo.setToolTip(tr("dashboard.currency_tooltip")) self._chart_title.setText(tr("dashboard.chart_title")) diff --git a/presentation/monitoring/shared/filter_scaffold.py b/presentation/monitoring/shared/filter_scaffold.py index 8c02e06..ea26611 100644 --- a/presentation/monitoring/shared/filter_scaffold.py +++ b/presentation/monitoring/shared/filter_scaffold.py @@ -20,7 +20,7 @@ from PySide6.QtWidgets import ( QTableWidget, QVBoxLayout, QWidget, ) -from ....i18n import tr +from ....i18n import bind_tip, tr from ....ui.icons import icon from .event_table import ClickOutsideCloser, EventTable from .event_detail_panel import EventDetailPanel @@ -82,7 +82,10 @@ def build_filter_scaffold( search.textChanged.connect(table.apply_filter) ai_btn = QPushButton(tr("monitoring.ai_filter_btn")) ai_btn.setIcon(icon("sparkle")) - ai_btn.setToolTip(tr("monitoring.ai_filter_tooltip")) + # Bound rather than set once: this scaffold builds the button for all + # three event tabs, and none of their retranslate() methods can reach a + # tooltip that was applied here. + bind_tip(ai_btn, "monitoring.ai_filter_tooltip") ai_btn.setCursor(Qt.PointingHandCursor) if on_ai_filter is not None: ai_btn.clicked.connect(lambda: on_ai_filter(search, ai_btn)) diff --git a/presentation/shell/busy_overlay.py b/presentation/shell/busy_overlay.py new file mode 100644 index 0000000..cbcbd41 --- /dev/null +++ b/presentation/shell/busy_overlay.py @@ -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() diff --git a/presentation/shell/main_window.py b/presentation/shell/main_window.py index d0059df..3f1046f 100644 --- a/presentation/shell/main_window.py +++ b/presentation/shell/main_window.py @@ -255,6 +255,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"): @@ -266,6 +272,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) --- diff --git a/presentation/shell/nav_rail.py b/presentation/shell/nav_rail.py index c6bd9ea..4d91b7e 100644 --- a/presentation/shell/nav_rail.py +++ b/presentation/shell/nav_rail.py @@ -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: diff --git a/presentation/shell/top_bar.py b/presentation/shell/top_bar.py index ebc5ab8..472bad3 100644 --- a/presentation/shell/top_bar.py +++ b/presentation/shell/top_bar.py @@ -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 " 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: diff --git a/tests/ui/test_co4e_sidebar_skill_index.py b/tests/ui/test_co4e_sidebar_skill_index.py new file mode 100644 index 0000000..4e21142 --- /dev/null +++ b/tests/ui/test_co4e_sidebar_skill_index.py @@ -0,0 +1,174 @@ +"""Co4E left column: the skill library must be read ONCE per sidebar reload. + +Why this test exists +-------------------- +``Co4ESidebarMixin._reload_sidebar`` used to call +``core.skills.skill_prefix_for(name)`` once per skill. Every one of those calls +re-reads the *whole* skill folder from disk (``list_skills()`` + +``builtin_skills()``), so the reload was O(N**2) in the number of skills. + +That reload runs on every language switch (``Co4ETab._retranslate`` -> +``_reload_sidebar``). Measured end-to-end on a real library of 121 skills, the +per-name calls cost ~3.8 s of blocked GUI thread — the "app freezes for a few +seconds when I switch Vietnamese -> English" the user reported. + +Two different things are guarded here, and they fail for different reasons: + +* ``test_reload_sidebar_reads_the_skill_library_once`` — the performance + contract. Red before the fix (the library was read 1 + N times), green after. +* the two equivalence tests — the *correctness* contract. A lookup table is + easy to build subtly wrong, and a wrong one silently changes which skill text + is pushed into an agent prompt. They pin the answer to ``skill_prefix_for`` + itself in exactly the cases where a naive ``{name: skill}`` dict diverges: + a name shared by a user skill and a built-in (first match wins, user first), + a skill whose instructions are blank (it does NOT end ``skill_prefix_for``'s + scan, so a later namesake with real instructions must still win), and + lookups by slug / different case / padding / unknown name / empty name. + Those two are characterization tests: green before AND after by design. +""" +from __future__ import annotations + +from dataclasses import replace + +import pytest +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QListWidget + +from cowork_local.core import co4e, skills as skills_mod +from cowork_local.presentation.co4e.co4e_sidebar import Co4ESidebarMixin + + +class _SidebarHarness(Co4ESidebarMixin): + """Just enough state to run the real ``_reload_sidebar``. + + Building a whole ``Co4ETab`` would drag in config/HOME-dependent module + constants (see ``tests/test_build_co4e_tab.py``); the mixin only touches + the three list widgets, so the real production method runs unchanged here. + """ + + def __init__(self) -> None: + """Create the three palette lists ``_reload_sidebar`` fills in.""" + self.wf_list = QListWidget() + self.agent_list = QListWidget() + self.skill_list = QListWidget() + + +@pytest.fixture +def harness(qapp, monkeypatch): + """A sidebar harness with the workflow/agent sources stubbed out empty.""" + monkeypatch.setattr(co4e, "list_workflows", lambda: []) + monkeypatch.setattr(co4e, "list_custom_agents", lambda: []) + return _SidebarHarness() + + +def _install_skills(monkeypatch, user, builtin): + """Replace the two disk-reading skill sources and count how often they run. + + Fresh copies are handed out on every call so a caller mutating a returned + ``Skill`` cannot make a later call look different. + """ + calls = {"list_skills": 0, "builtin_skills": 0} + + def _list_skills(directory=None): + """Stand-in for ``skills.list_skills`` that records each read.""" + calls["list_skills"] += 1 + return [replace(s) for s in user] + + def _builtin_skills(): + """Stand-in for ``skills.builtin_skills`` that records each read.""" + calls["builtin_skills"] += 1 + return [replace(s) for s in builtin] + + monkeypatch.setattr(skills_mod, "list_skills", _list_skills) + monkeypatch.setattr(skills_mod, "builtin_skills", _builtin_skills) + return calls + + +def _skill_rows(sidebar): + """Return ``(item text, payload instructions)`` for every skill palette row.""" + return [(sidebar.skill_list.item(i).text(), + sidebar.skill_list.item(i).data(Qt.UserRole)["instructions"]) + for i in range(sidebar.skill_list.count())] + + +def test_reload_sidebar_reads_the_skill_library_once(harness, monkeypatch): + """One sidebar reload must hit the skill library exactly once, not once per skill.""" + user = [skills_mod.Skill(name=f"Skill {i}", instructions=f"Body {i}") + for i in range(8)] + calls = _install_skills(monkeypatch, user, []) + + harness._reload_sidebar() + + assert harness.skill_list.count() == 8, "every skill must still reach the palette" + assert calls["list_skills"] == 1, ( + f"the skill library was read {calls['list_skills']}x for 8 skills — " + "reading it once per skill is the O(N^2) freeze on language switch" + ) + assert calls["builtin_skills"] == 1, ( + f"built-in skills were read {calls['builtin_skills']}x for 8 skills" + ) + + +def test_skill_payloads_match_skill_prefix_for(harness, monkeypatch): + """The palette payload must be byte-identical to ``skill_prefix_for``'s answer. + + The fixture is deliberately hostile: a name shared by a user skill and a + built-in, a blank-instructions skill followed by a namesake with real + content, and names with padding/odd casing. + """ + user = [ + skills_mod.Skill(name="Shared Name", instructions="USER VERSION"), + skills_mod.Skill(name="Blank First", instructions=" "), + skills_mod.Skill(name="Blank First", instructions="LATER, WITH CONTENT"), + skills_mod.Skill(name=" Padded Name ", instructions="PADDED BODY"), + skills_mod.Skill(name="MiXeD CaSe", instructions="MIXED BODY"), + ] + builtin = [ + skills_mod.Skill(name="Shared Name", instructions="BUILTIN VERSION"), + skills_mod.Skill(name="Builtin Only", instructions="BUILTIN BODY"), + ] + _install_skills(monkeypatch, user, builtin) + + harness._reload_sidebar() + + expected = [(s.name, skills_mod.skill_prefix_for(s.name)) for s in user + builtin] + assert _skill_rows(harness) == expected + + rows = dict(_skill_rows(harness)) + # A user skill wins over a built-in of the same name: list_skills() comes + # first and skill_prefix_for() returns the FIRST match, not the last. + assert rows["Shared Name"] == "## Skill: Shared Name\nUSER VERSION" + # A blank-instructions skill does not end the scan, so its later namesake + # supplies the text. A last-write-wins dict would answer '' here. + assert rows["Blank First"] == "## Skill: Blank First\nLATER, WITH CONTENT" + assert rows["Builtin Only"] == "## Skill: Builtin Only\nBUILTIN BODY" + + +def test_lookup_matches_skill_prefix_for_on_odd_names(monkeypatch): + """Slug / case / padding / unknown / empty lookups must answer like ``skill_prefix_for``.""" + # Imported inside the test on purpose: this symbol is what the fix + # introduces, and a module-level import would turn the pre-fix run into a + # collection error instead of a real assertion failure in the test above. + from cowork_local.presentation.co4e.co4e_sidebar import _skill_prefix_lookup + + user = [ + skills_mod.Skill(name="Shared Name", instructions="USER VERSION"), + skills_mod.Skill(name="Blank First", instructions=""), + skills_mod.Skill(name="Blank First", instructions="LATER, WITH CONTENT"), + skills_mod.Skill(name="Tiếng Việt / 日本語", instructions="UNICODE BODY"), + skills_mod.Skill(name="", instructions="NAMELESS BODY"), + ] + builtin = [skills_mod.Skill(name="Shared Name", instructions="BUILTIN VERSION")] + _install_skills(monkeypatch, user, builtin) + + lookup = _skill_prefix_lookup(skills_mod.list_skills() + skills_mod.builtin_skills()) + + probes = [ + "Shared Name", "shared name", "SHARED NAME", " Shared Name ", + "shared-name", # by slug + "Blank First", "blank-first", + "Tiếng Việt / 日本語", "tiếng-việt-日本語", + "", " ", "no-such-skill-anywhere", "skill", + ] + for probe in probes: + assert lookup(probe) == skills_mod.skill_prefix_for(probe), probe diff --git a/tests/ui/test_i18n_khong_con_chu_cu.py b/tests/ui/test_i18n_khong_con_chu_cu.py new file mode 100644 index 0000000..826c1ef --- /dev/null +++ b/tests/ui/test_i18n_khong_con_chu_cu.py @@ -0,0 +1,325 @@ +"""Đổi ngôn ngữ thì MỌI chữ trên màn hình phải đổi theo. + +Lỗi mà bộ test này khoá lại: `w.setToolTip(tr("k"))` chỉ đúng ở đúng thời điểm +chạy dòng đó. Không có gì áp lại, nên sau khi người dùng đổi ngôn ngữ thì chữ +đứng nguyên ở ngôn ngữ cũ. Vì `DEFAULT_LANGUAGE = "vi"`, triệu chứng người dùng +báo là "chọn English mà nhiều chỗ vẫn tiếng Việt". + +Cách kiểm: KHÔNG so chữ với bảng dịch — làm thế thì một nhãn tiếng Việt trùng +chữ với nhãn khác sẽ báo oan (và ngược lại, "OneDrive" giống nhau ở cả ba ngôn +ngữ sẽ lọt). Thay vào đó thay `tr()` bằng hàm trả về một chuỗi MỐC, rồi gọi +`set_language()`. Chỗ nào được áp lại sẽ mang mốc; chỗ nào không mang mốc là chỗ +không có ai áp lại — đó chính là lỗi, và biết chắc chứ không phải suy đoán. + +Hai điểm mà một bản kiểm ngây thơ sẽ sai: + +* `from ...i18n import tr` COPY tham chiếu vào namespace của từng module, nên + sửa `i18n.tr` một mình là không đủ — phải thay ở mọi module đã import nó. +* Lưới vẽ lại bằng `deleteLater()` để lại widget cũ còn sống tới khi vòng lặp sự + kiện tiêu hoá xong. Không `sendPostedEvents(DeferredDelete)` thì hàng chục + widget bóng ma mang chữ cũ sẽ bị đếm là lỗi trong khi người dùng không hề thấy. +""" +from __future__ import annotations + +import sys + +import pytest + +pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật") + +MOC = "\u2063" # invisible separator: không chuỗi hiển thị thật nào chứa nó + +#: Khoá cố ý giống nhau ở cả ba ngôn ngữ: tên thương hiệu, tên sản phẩm, ký hiệu, +#: placeholder thuần định dạng, và mã mức độ hiển thị nguyên dạng. Thêm khoá mới +#: vào đây phải kèm lý do — đây là chỗ dễ dùng để lặng lẽ bỏ qua việc dịch. +KHOA_KHONG_CAN_DICH = { + # thương hiệu / tên sản phẩm + "app.logo", "app.credit", "login.header", + "app.tab.code", "app.tab.cowork", "app.tab.structure", + "code.title", "code.onedrive_badge", "code.onedrive_btn", + "cowork.title", "sidebar.filter.code", "sidebar.filter.cowork", + "workspace.tab_co4e", "workspace.tab_co4e_tooltip", "workspace.tab_cowork", + "workspace.tab_graphrag", "structure.cmem_ui_open", + "settings.group.anthropic", "settings.group.cowork", "settings.group.structure", + "settings.group.teams", "settings.history_onedrive", + "settings.ms365_connector.onedrive", "settings.ms365_connector.outlook", + "settings.ms365_connector.sharepoint", "settings.ms365_connector.teams", + "schedtask.stepexec.co4e", "schedtask.stepexec.cowork", + "schedtask.type.co4e_code", "schedtask.type.cowork", + "monitoring.tab_mcp", "ext.mode_rest", "schedtask.f_cron", + "help_agent.badge", "help_agent.title", # dùng nguyên dạng trong cả câu tiếng Nhật + "help_agent.default_user", # đứng thay cho TÊN người dùng + # viết tắt / ký hiệu / thuần định dạng + "accounts.ai_search_btn", "monitoring.ai_filter_btn", "monitoring.overview_res_cpu", + "monitoring.na", "schedtask.add_link_label", "settings.teams_webhook", + "cowork.project_label", "connectors.jira_fail", "tools_admin.jira_fail", + # mã mức độ: giữ nguyên dạng ở CẢ BA ngôn ngữ, có chủ ý + "monitoring.severity_critical", "monitoring.severity_info", "monitoring.severity_medium", +} + + +# ---- dữ liệu: bảng dịch không được thiếu tiếng Nhật ---------------------- + +def test_moi_khoa_co_du_ba_ngon_ngu(): + from cowork_local.i18n import STRINGS + + thieu = {k: sorted({"en", "ja", "vi"} - {x for x in v if v.get(x)}) + for k, v in STRINGS.items() + if not all(v.get(x) for x in ("en", "ja", "vi"))} + + assert thieu == {}, f"khoá thiếu bản dịch: {thieu}" + + +def test_ban_dich_nhat_khong_phai_chuoi_tieng_anh(): + """`ja` bằng đúng `en` nghĩa là khoá đó chưa được dịch — trừ danh sách miễn. + + Đây là nửa còn lại của lỗi người dùng báo: "chọn tiếng Nhật thì vài chỗ hiển + thị tiếng Anh". Nó KHÔNG phải lỗi dây nối (widget vẫn áp lại `tr()` đúng), + mà là lỗ trong dữ liệu dịch — nên phải có cổng riêng canh. + """ + from cowork_local.i18n import STRINGS + + chua_dich = sorted(k for k, v in STRINGS.items() + if v.get("en") == v.get("ja") and k not in KHOA_KHONG_CAN_DICH) + + assert chua_dich == [], ( + "khoá chưa có bản dịch tiếng Nhật (thêm bản dịch, hoặc khai vào " + f"KHOA_KHONG_CAN_DICH kèm lý do): {chua_dich}") + + +def test_danh_sach_mien_khong_phinh_len_am_tham(): + """Danh sách miễn chỉ được chứa khoá THẬT SỰ giống nhau ở en/ja. + + Không có test này thì cách dễ nhất để làm cổng trên xanh lại là nhét khoá + vào danh sách miễn rồi để nguyên đó sau khi đã dịch. + """ + from cowork_local.i18n import STRINGS + + thua = sorted(k for k in KHOA_KHONG_CAN_DICH + if k in STRINGS and STRINGS[k].get("en") != STRINGS[k].get("ja")) + + assert thua == [], f"khoá đã có bản dịch nhưng vẫn nằm trong danh sách miễn: {thua}" + + +# ---- lúc chạy: đổi ngôn ngữ thì chữ trên màn hình phải đổi -------------- + +def _chu_cua(w): + """Mọi chỗ chữ hiện ra từ một widget, kèm tên thuộc tính.""" + from PySide6.QtWidgets import ( + QComboBox, QLineEdit, QListWidget, QPlainTextEdit, QTabWidget, + QTableWidget, QTextEdit, QTreeWidget, + ) + ra = [] + + def them(ten, s): + if isinstance(s, str) and s.strip(): + ra.append((ten, s)) + + # QLineEdit.text() là NỘI DUNG người dùng gõ, không phải nhãn giao diện. + if hasattr(w, "text") and not isinstance(w, (QLineEdit, QTextEdit, QPlainTextEdit)): + try: + them("text", w.text()) + except RuntimeError: + return ra + for ten in ("windowTitle", "placeholderText", "toolTip", "title", "accessibleName"): + f = getattr(w, ten, None) + if callable(f): + try: + them(ten, f()) + except (RuntimeError, TypeError): + pass + if isinstance(w, QTabWidget): + for i in range(w.count()): + them(f"tabText[{i}]", w.tabText(i)) + them(f"tabToolTip[{i}]", w.tabToolTip(i)) + if isinstance(w, QComboBox): + for i in range(w.count()): + them(f"itemText[{i}]", w.itemText(i)) + if isinstance(w, QListWidget): + for i in range(w.count()): + it = w.item(i) + if it is not None: + them(f"item[{i}]", it.text()) + them(f"itemTip[{i}]", it.toolTip()) + if isinstance(w, QTableWidget): + for c in range(w.columnCount()): + h = w.horizontalHeaderItem(c) + if h is not None: + them(f"hheader[{c}]", h.text()) + if isinstance(w, QTreeWidget): + hi = w.headerItem() + if hi is not None: + for c in range(w.columnCount()): + them(f"hheader[{c}]", hi.text(c)) + return ra + + +def _chu_so_huu(w): + """Lớp widget của chính dự án gần nhất trên đường đi lên. + + Một `QPushButton` trần không cho biết nó thuộc màn nào; tổ tiên gần nhất do + dự án định nghĩa thì cho biết, và đó là file cần sửa. + """ + cur = w + while cur is not None: + mod = type(cur).__module__ or "" + if mod.startswith("cowork_local"): + return f"{mod}.{type(cur).__name__}" + cur = cur.parent() + return "?" + + +def _chup(root, giu): + """Ảnh chụp mọi chỗ chữ trong cây widget. + + ``giu`` nhận thêm tham chiếu tới từng widget đã đi qua: wrapper PySide6 bị + thu hồi thì `id()` được cấp lại cho wrapper sau, và vòng quét sẽ tưởng đã + thăm rồi mà dừng sớm — mất phần lớn cây. + """ + from PySide6.QtWidgets import QMenu, QWidget + + anh, ngan, da_tham = {}, [root], set() + while ngan: + w = ngan.pop() + if id(w) in da_tham: + continue + da_tham.add(id(w)) + giu.append(w) + so_huu = _chu_so_huu(w) + for ten, s in _chu_cua(w): + anh[(id(w), type(w).__name__, so_huu, ten)] = s + try: + ngan.extend([c for c in w.children() if isinstance(c, (QWidget, QMenu))]) + except RuntimeError: + pass + return anh + + +def _thay_tr(moi, cu): + """Thay `tr` ở MỌI module của dự án đang trỏ tới ``cu``. Trả về số module.""" + from cowork_local import i18n as i18n_mod + + n = 0 + for ten, m in list(sys.modules.items()): + if ten.startswith("cowork_local") and getattr(m, "tr", None) is cu: + setattr(m, "tr", moi) + n += 1 + i18n_mod.tr = moi + return n + + +def _tieu_hoa(qapp): + """Cho Qt xử lý hết `deleteLater()` để không đếm widget bóng ma.""" + from PySide6.QtCore import QEvent + + for _ in range(3): + qapp.processEvents() + qapp.sendPostedEvents(None, QEvent.DeferredDelete) + + +@pytest.fixture +def cua_so(qapp, tmp_path): + """`MainWindow` thật, đã dựng cả bốn trang dựng-lười. + + Trang chưa dựng thì không có chữ nào để kiểm, mà đó lại đúng là nơi lỗi hay + nằm — nên phải gọi `_ensure_page` cho hết. + """ + from cowork_local.presentation.shell.bootstrap import build_config, build_context + from cowork_local.presentation.shell.main_window import MainWindow + + duong_dan = tmp_path / "config.json" + build_config(duong_dan) + win = MainWindow(build_context(duong_dan)) + win.resize(1500, 950) + for row in range(len(win._page_widgets)): + win._ensure_page(row) + yield win + win.close() + + +def test_doi_ngon_ngu_thi_khong_con_chu_ngon_ngu_cu(qapp, cua_so): + """Không chỗ nào giữ lại chữ của ngôn ngữ cũ sau khi đổi ngôn ngữ.""" + from cowork_local import i18n as i18n_mod + + giu = [] # chống cấp lại id() cho wrapper mới + goc = i18n_mod.tr + ngon_ngu_cu = i18n_mod.get_language() + i18n_mod.set_language("vi") + _tieu_hoa(qapp) + truoc = _chup(cua_so, giu) + + def moc(key, **kw): + return MOC + key + + try: + assert _thay_tr(moc, goc) > 0, "không thay được tr() ở module nào" + i18n_mod.set_language("en") + _tieu_hoa(qapp) + sau = _chup(cua_so, giu) + finally: + _thay_tr(goc, moc) + i18n_mod.set_language(ngon_ngu_cu) + + # Chỉ tính chỗ mà chữ CÓ phụ thuộc ngôn ngữ: "OneDrive" giống nhau ở cả ba + # ngôn ngữ nên không áp lại cũng không ai thấy khác. + phu_thuoc_ngon_ngu = set() + for v in i18n_mod.STRINGS.values(): + gia_tri = {v.get("en"), v.get("ja"), v.get("vi")} + if len(gia_tri) > 1: + phu_thuoc_ngon_ngu.update(x for x in gia_tri if x) + + # Nhãn nhà cung cấp là DANH TÍNH lấy từ ``config.PROVIDER_LABELS``, không đi + # qua ``tr()`` ở bất kỳ đâu (Agents Admin, model resolver, preview_ai đều + # hiện nguyên dạng như nhau). Một trong số chúng tình cờ trùng chữ với bản + # tiếng Anh của ``settings.group.openai`` — là tiêu đề mục trong Cài đặt, + # một khoá khác hẳn. Không trừ ra thì phép đo báo oan chỗ này mãi mãi. + # Muốn dịch tên nhà cung cấp thì phải sửa ``config.py`` và sửa đồng loạt cả + # năm nơi đang đọc nó — đó là một quyết định sản phẩm, không phải lỗi dây nối. + from cowork_local.config import PROVIDER_LABELS + phu_thuoc_ngon_ngu -= set(PROVIDER_LABELS.values()) + + con_chu_cu = sorted( + f"{cho[2]} :: {cho[1]}.{cho[3]} = {chu!r}" + for cho, chu in sau.items() + if cho in truoc and not chu.startswith(MOC) and chu in phu_thuoc_ngon_ngu + ) + + assert con_chu_cu == [], ( + "những chỗ này không được áp lại khi đổi ngôn ngữ — dùng bind_text / " + "bind_tip / bind_placeholder / bind_items / bind_dynamic của i18n thay " + "cho setText(tr(...)) một lần:\n " + "\n ".join(con_chu_cu)) + + +def test_phep_do_thuc_su_quet_duoc_man_hinh(qapp, cua_so): + """Chốt độ phủ của chính phép đo trên. + + Một hôm nào đó `_chup` đi sai (đúng lỗi `id()` bị cấp lại đã gặp) thì nó vẫn + trả về một dict rỗng và test trên vẫn xanh — xanh vì không kiểm gì cả. + """ + giu = [] + anh = _chup(cua_so, giu) + + assert len(anh) > 500, f"chỉ quét được {len(anh)} chỗ chữ — phép đo đang bị cắt" + + +def test_binding_tu_don_khi_widget_bi_xoa(qapp): + """Ràng buộc không được giữ widget sống, và không được ném khi widget chết. + + Đây là điều kiện để `bind_*` dùng được cho widget dựng lại liên tục (hàng + Kanban, ô lịch) mà không tích thành rác. + """ + from PySide6.QtWidgets import QLabel + + from cowork_local import i18n as i18n_mod + + lbl = QLabel() + i18n_mod.bind_text(lbl, "app.settings") + truoc = len(i18n_mod._bindings) + assert truoc > 0 + + lbl.deleteLater() + del lbl + _tieu_hoa(qapp) + i18n_mod._apply_bindings() # không được ném + + assert len(i18n_mod._bindings) < truoc, "ràng buộc của widget đã xoá vẫn còn" diff --git a/tests/ui/test_language_switch_busy_overlay.py b/tests/ui/test_language_switch_busy_overlay.py new file mode 100644 index 0000000..97967b4 --- /dev/null +++ b/tests/ui/test_language_switch_busy_overlay.py @@ -0,0 +1,389 @@ +"""Đổi ngôn ngữ phải có lớp phủ "đang xử lý" — và chỉ khi nó đáng có. + +``set_language()`` chạy ĐỒNG BỘ trên GUI thread: nó gọi lần lượt mọi callback +đã đăng ký qua ``on_language_changed``. Trong lúc đó không có vòng lặp sự kiện, +nên không ``QTimer`` nào bắn được và không thanh tiến trình nào quay được — lớp +phủ phải được ``repaint()`` NGAY, không phải ``update()``. + +Cùng lý do đó, lớp phủ không thể "đợi xem có chậm không rồi mới hiện": khi việc +chặn đã bắt đầu thì không còn ai chạy để bật nó lên. Thứ duy nhất đo được là +LẦN ĐỔI TRƯỚC. Vì vậy chính sách là: lần đầu trong phiên thì luôn hiện (thận +trọng), các lần sau chỉ hiện khi lần trước đã đủ chậm để người ta kịp nhận ra. +Máy nhanh vì thế nháy đúng một lần rồi thôi, thay vì nháy mãi mãi. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật") + +from cowork_local.presentation.shell import top_bar as top_bar_mod + + +class _Ctx: + """Ngữ cảnh tối thiểu mà ``_switch_language``/``_open_settings`` cần.""" + + def __init__(self, language: str = "vi") -> None: + self.config = type("C", (), {})() + self.config.language = language + self.config.active_provider = "p" + self.config.theme = "dark" + self.config.data = {} + self.so_lan_luu = 0 + + def save(self) -> None: + self.so_lan_luu += 1 + + +class _Any: + """Nuốt mọi lời gọi. + + ``_open_settings`` còn làm mới sidebar/cowork/workspace sau khi đổi ngôn + ngữ; không thứ nào trong số đó là thứ đang được kiểm ở đây. + """ + + def __getattr__(self, name): + return _Any() + + def __call__(self, *a, **k): + return _Any() + + +def _dung(qapp): + """Một ``QMainWindow`` thật có mang ``TopBarMixin`` — đúng ``self`` mà mixin + nhận trong sản phẩm, nên lớp phủ làm con của cửa sổ chính y như ``Toast``.""" + from PySide6.QtWidgets import QComboBox, QMainWindow + + class W(top_bar_mod.TopBarMixin, QMainWindow): + _THEME_ICONS = {"system": "monitor", "dark": "moon", "light": "sun"} + + w = W() + w.resize(900, 700) + w.ctx = _Ctx() + combo = QComboBox() + for code in ("en", "ja", "vi"): + combo.addItem(code.upper(), code) + combo.setCurrentIndex(combo.findData("vi")) + w.language_combo = combo + pcombo = QComboBox() + pcombo.addItem("P", "p") + w.provider_combo = pcombo + return w + + +def _dong_ho(*moc): + """Đồng hồ giả trả lần lượt các mốc thời gian (giây) cho trước.""" + it = iter(moc) + return lambda: next(it) + + +@pytest.fixture +def dat_ngon_ngu(): + """Đặt ngôn ngữ hiện tại mà KHÔNG phát tán cho listener. + + ``set_language()`` gọi mọi callback đã đăng ký — trong một phiên pytest đầy + đủ, danh sách đó chứa widget của các test khác. Ở đây chỉ cần biết ``tr()`` + đang trả về ngôn ngữ nào. + """ + from cowork_local import i18n as i18n_mod + + cu = i18n_mod._current + + def dat(code: str) -> None: + i18n_mod._current = code + + yield dat + i18n_mod._current = cu + + +# ---- lớp phủ bật TRƯỚC khi việc chặn bắt đầu ----------------------------- + +def test_overlay_hien_truoc_khi_set_language_chan(qapp, monkeypatch, dat_ngon_ngu): + """Đi qua đúng đường người dùng bấm: combo ngôn ngữ ở chân nav rail. + + ``isHidden()`` chứ không ``isVisible()``: cửa sổ cha chưa ``show()`` thì + ``isVisible()`` của widget con luôn False, đúng idiom mà + ``test_graphrag_busy_panel.py:41`` đã dùng. + """ + dat_ngon_ngu("vi") + w = _dung(qapp) + ghi = {} + + def probe(lang): + ghi["hien"] = not w._lang_busy.isHidden() + + monkeypatch.setattr(top_bar_mod, "set_language", probe) + try: + w.language_combo.setCurrentIndex(w.language_combo.findData("en")) + w._on_language_changed(0) + + assert ghi["hien"] is True, ( + "lớp phủ bật sau khi GUI thread đã bị chặn thì người dùng không thấy gì") + finally: + w.deleteLater() + + +def test_nguoi_dung_that_su_nhin_thay_lop_phu(qapp, monkeypatch, dat_ngon_ngu): + """Cửa sổ đã hiện thì ``isVisible()`` mới có nghĩa — chốt luôn mức đó.""" + dat_ngon_ngu("vi") + w = _dung(qapp) + ghi = {} + + def probe(lang): + ghi["hien"] = w._lang_busy.isVisible() + + monkeypatch.setattr(top_bar_mod, "set_language", probe) + w.show() + try: + w._switch_language("en") + + assert ghi["hien"] is True + finally: + w.hide() + w.deleteLater() + + +def test_overlay_tat_sau_khi_doi_xong(qapp, monkeypatch, dat_ngon_ngu): + dat_ngon_ngu("vi") + w = _dung(qapp) + monkeypatch.setattr(top_bar_mod, "set_language", lambda lang: None) + try: + w._switch_language("en") + + assert w._lang_busy.isHidden() is True + finally: + w.deleteLater() + + +def test_callback_nem_loi_van_tat_overlay(qapp, monkeypatch, dat_ngon_ngu): + """``i18n/__init__.py:68`` chỉ nuốt ``RuntimeError``. Một listener ném loại + khác sẽ xuyên qua ``set_language()`` — không có ``try/finally`` thì lớp phủ + treo lại trên màn hình vĩnh viễn.""" + dat_ngon_ngu("vi") + w = _dung(qapp) + + def probe(lang): + raise ValueError("một listener nào đó hỏng") + + monkeypatch.setattr(top_bar_mod, "set_language", probe) + try: + with pytest.raises(ValueError): + w._switch_language("en") + + assert w._lang_busy.isHidden() is True, "lớp phủ treo lại mãi mãi" + assert w.language_combo.isEnabled() is True, "combo khoá lại mãi mãi" + finally: + w.deleteLater() + + +# ---- chữ hiện bằng NGÔN NGỮ CŨ ------------------------------------------- + +def _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu: str, sang: str) -> str: + """Chữ trên lớp phủ tại thời điểm việc chặn bắt đầu. + + Probe đổi ``_current`` thật, y như ``set_language()`` làm, nên nếu ``tr()`` + bị gọi SAU lượt đổi thì chữ sẽ ra ngôn ngữ mới và test đỏ. + """ + from cowork_local import i18n as i18n_mod + + dat_ngon_ngu(tu) + w = _dung(qapp) + ghi = {} + + def probe(lang): + i18n_mod._current = lang + ghi["chu"] = w._lang_busy.text() + + monkeypatch.setattr(top_bar_mod, "set_language", probe) + try: + w._switch_language(sang) + return ghi["chu"] + finally: + w.deleteLater() + + +def test_chu_hien_bang_ngon_ngu_cu(qapp, monkeypatch, dat_ngon_ngu): + from cowork_local.i18n import STRINGS + + chu = _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu="vi", sang="en") + + assert chu == STRINGS["app.lang.switching"]["vi"] + + +def test_chu_hien_bang_ngon_ngu_cu_chieu_nguoc(qapp, monkeypatch, dat_ngon_ngu): + from cowork_local.i18n import STRINGS + + chu = _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu="en", sang="vi") + + assert chu == STRINGS["app.lang.switching"]["en"] + + +def test_i18n_du_ba_ngon_ngu(): + from cowork_local.i18n import STRINGS + + assert set(STRINGS["app.lang.switching"]) >= {"en", "ja", "vi"} + + +# ---- hình dạng: phủ kín cửa sổ, khoá combo ------------------------------- + +def test_overlay_phu_kin_cua_so(qapp, monkeypatch, dat_ngon_ngu): + """Phủ nửa vời thì người dùng vẫn bấm được vào phần còn lại.""" + dat_ngon_ngu("vi") + w = _dung(qapp) + monkeypatch.setattr(top_bar_mod, "set_language", lambda lang: None) + try: + w._switch_language("en") + + assert w._lang_busy.geometry() == w.rect() + finally: + w.deleteLater() + + +def test_combo_bi_khoa_trong_luc_chan(qapp, monkeypatch, dat_ngon_ngu): + dat_ngon_ngu("vi") + w = _dung(qapp) + ghi = {} + + def probe(lang): + ghi["khoa"] = w.language_combo.isEnabled() + + monkeypatch.setattr(top_bar_mod, "set_language", probe) + try: + w._switch_language("en") + + assert ghi["khoa"] is False + assert w.language_combo.isEnabled() is True, "phải mở khoá lại sau khi xong" + finally: + w.deleteLater() + + +# ---- chính sách: chỉ nháy một lần trên máy nhanh ------------------------- + +def test_lan_dau_trong_phien_luon_hien(qapp, monkeypatch, dat_ngon_ngu): + """Chưa đo được gì thì nghiêng về phía hiện: một cái nháy còn hơn một lần + đơ vài giây không lời giải thích.""" + from cowork_local.presentation.shell import busy_overlay as busy_mod + + dat_ngon_ngu("vi") + w = _dung(qapp) + ghi = {} + monkeypatch.setattr(top_bar_mod, "set_language", + lambda lang: ghi.setdefault("hien", not w._lang_busy.isHidden())) + try: + assert w._lang_busy_overlay()._last_ms is None + w._switch_language("en") + + assert ghi["hien"] is True + assert busy_mod._NOTICEABLE_MS > 0 + finally: + w.deleteLater() + + +def test_lan_truoc_nhanh_thi_khong_hien_nua(qapp, monkeypatch, dat_ngon_ngu): + """20 ms thì không ai kịp thấy mình đang đợi — hiện lớp phủ ở đó là tự tạo + ra một cái nháy toàn màn hình.""" + from cowork_local.presentation.shell import busy_overlay as busy_mod + + dat_ngon_ngu("vi") + w = _dung(qapp) + monkeypatch.setattr(busy_mod, "perf_counter", _dong_ho(0.0, 0.02, 5.0, 5.02)) + ghi = [] + monkeypatch.setattr(top_bar_mod, "set_language", + lambda lang: ghi.append(not w._lang_busy.isHidden())) + try: + w._switch_language("en") + w._switch_language("ja") + + assert ghi == [True, False] + finally: + w.deleteLater() + + +def test_lan_truoc_cham_thi_van_hien(qapp, monkeypatch, dat_ngon_ngu): + """Máy chậm / thư viện skill lớn: lần trước mất 1 giây thì lần này phải có + lớp phủ, không đợi thêm lần nào nữa.""" + from cowork_local.presentation.shell import busy_overlay as busy_mod + + dat_ngon_ngu("vi") + w = _dung(qapp) + monkeypatch.setattr(busy_mod, "perf_counter", _dong_ho(0.0, 1.0, 5.0, 6.0)) + ghi = [] + monkeypatch.setattr(top_bar_mod, "set_language", + lambda lang: ghi.append(not w._lang_busy.isHidden())) + try: + w._switch_language("en") + w._switch_language("ja") + + assert ghi == [True, True] + finally: + w.deleteLater() + + +# ---- đường Cài đặt ▸ Chung ----------------------------------------------- + +def _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu: str): + """Chạy ``_open_settings`` với hộp thoại bị thay, trả về (window, ghi).""" + w = _dung(qapp) + w._apply_theme = lambda: None + w.theme_btn = _Any() + w.cowork = _Any() + w.workspace = _Any() + w.sidebar = _Any() + ghi = {"goi": 0} + + class _Dlg: + def __init__(self, ctx, parent): + self._ctx = ctx + + def exec(self): + self._ctx.config.language = ngon_ngu_sau_khi_luu + return True + + def probe(lang): + ghi["goi"] += 1 + ghi["hien"] = not w._lang_busy.isHidden() + + monkeypatch.setattr(top_bar_mod, "SettingsDialog", _Dlg) + monkeypatch.setattr(top_bar_mod, "set_language", probe) + return w, ghi + + +def test_duong_cai_dat_cung_co_overlay(qapp, monkeypatch, dat_ngon_ngu): + """Cài đặt ▸ Chung đổi ngôn ngữ thì cũng chặn GUI thread y hệt combo.""" + dat_ngon_ngu("vi") + w, ghi = _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu="en") + try: + w._open_settings() + + assert ghi["goi"] == 1 + assert ghi["hien"] is True + finally: + w.deleteLater() + + +def test_duong_cai_dat_khong_nhay_khi_khong_doi_ngon_ngu(qapp, monkeypatch, dat_ngon_ngu): + """Bấm Lưu mà không đụng tới ngôn ngữ: ``set_language()`` là no-op + (``i18n/__init__.py:62``), nên bật lớp phủ ở đây chỉ là một cái nháy.""" + dat_ngon_ngu("vi") + w, ghi = _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu="vi") + try: + w._open_settings() + + assert ghi["goi"] == 0, "gọi set_language() cho một lượt đổi không tồn tại" + assert getattr(w, "_lang_busy", None) is None + finally: + w.deleteLater() + + +# ---- màu lấy từ theme, không hardcode ------------------------------------ + +def test_mau_lay_tu_theme(): + """Guardrail G4: ngoài ``theme/`` không file nào được đặt tên một màu.""" + from cowork_local.theme.qss import _TEMPLATE + + assert "QWidget#busyOverlayPanel" in _TEMPLATE.template + src = (Path(__file__).resolve().parents[2] + / "presentation" / "shell" / "busy_overlay.py").read_text(encoding="utf-8") + assert "setStyleSheet" not in src diff --git a/tests/ui/test_routing_toggle_i18n.py b/tests/ui/test_routing_toggle_i18n.py new file mode 100644 index 0000000..8589fe7 --- /dev/null +++ b/tests/ui/test_routing_toggle_i18n.py @@ -0,0 +1,212 @@ +"""Regression: Định tuyến / Tự chạy phải đổi chữ theo ngôn ngữ runtime (UI-20260907-01). + +Người dùng dựng app ở một ngôn ngữ rồi đổi sang ngôn ngữ khác: chữ trên công tắc +"Định tuyến" và ô "Tự chạy" ở tab Cowork vẫn đóng băng ở ngôn ngữ lúc dựng widget. +Vì vậy MỌI test ở đây phải theo khuôn **dựng ở X → đổi sang Y → kiểm**: kiểm ngay +lúc vừa dựng là vô nghĩa (widget luôn đúng lúc dựng, kể cả khi bản vá sai). +""" +from __future__ import annotations + +import pytest + +from cowork_local import i18n +from cowork_local.ui.routing_toggle import AutoRunToggle, RoutingToggle + + +class FakeCtx: + """AppContext tối thiểu; ghi lại MỌI lần ghi để test chứng minh dịch lại không ghi đĩa.""" + + def __init__(self) -> None: + """Khởi tạo với sổ ghi rỗng.""" + self.writes: list = [] + + def project_routing_mode(self, surface: str) -> str: + """Chế độ định tuyến của bề mặt đang mở — cố định "manual" cho test.""" + return "manual" + + def set_project_routing_mode(self, surface: str, mode: str) -> None: + """Ghi lại lần ghi chế độ định tuyến thay vì chạm đĩa.""" + self.writes.append((surface, mode)) + + def project_auto_run(self) -> bool: + """Trạng thái tự chạy của project — cố định True cho test.""" + return True + + def set_project_auto_run(self, value: bool) -> None: + """Ghi lại lần ghi cờ tự chạy thay vì chạm đĩa.""" + self.writes.append(("auto_run", value)) + + +@pytest.fixture +def i18n_sach(): + """Trả ngôn ngữ VÀ danh sách listener về nguyên trạng. + + ``i18n._listeners`` chỉ có đường vào (i18n/__init__.py:100); widget do test + dựng sẽ nằm lại đó và bị gọi ở mọi test sau. Khôi phục để test hermetic. + """ + lang, listeners = i18n.get_language(), list(i18n._listeners) + yield + i18n._listeners[:] = listeners + i18n.set_language(lang) + + +def _chu_dinh_tuyen_phai_la(toggle: RoutingToggle, lang: str) -> None: + """Mọi chữ trên công tắc định tuyến phải khớp bản dịch của ``lang``. + + So với ``i18n.STRINGS`` chứ không hard-code chuỗi: đổi từ ngữ sau này không + được làm test đỏ oan. + """ + assert toggle._label.text() == i18n.STRINGS["routing.toggle_label"][lang] + assert toggle._combo.toolTip() == i18n.STRINGS["routing.toggle_tooltip"][lang] + for i, (_value, key) in enumerate(toggle._modes): + assert toggle._combo.itemText(i) == i18n.STRINGS[key][lang], ( + f"item {i} sai ngôn ngữ" + ) + + +def _on_dinh_layout(app, lap: int = 3) -> None: + """Ép Qt xử lý hết layout request đang xếp hàng để bề rộng widget ổn định.""" + from PySide6.QtCore import QEvent + + for _ in range(lap): + app.sendPostedEvents(None, QEvent.LayoutRequest) + app.processEvents() + + +def _be_rong_o_chu(combo) -> int: + """Bề rộng vùng hiển thị chữ của combo (đã trừ khung và mũi tên).""" + from PySide6.QtWidgets import QStyle, QStyleOptionComboBox + + opt = QStyleOptionComboBox() + opt.initFrom(combo) + opt.currentText = combo.currentText() + opt.editable = combo.isEditable() + opt.frame = combo.hasFrame() + opt.subControls = QStyle.SC_All + return combo.style().subControlRect( + QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxEditField, combo + ).width() + + +def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_ja_roi_doi_sang_vi(qapp, i18n_sach): + """Dựng ở tiếng Nhật, đổi sang tiếng Việt → nhãn/tooltip/4 chế độ phải là tiếng Việt.""" + i18n.set_language("ja") + toggle = RoutingToggle(FakeCtx(), "cowork") + + i18n.set_language("vi") + + _chu_dinh_tuyen_phai_la(toggle, "vi") + + +def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_ja_roi_doi_sang_en(qapp, i18n_sach): + """Dựng ở tiếng Nhật, đổi sang tiếng Anh → toàn bộ chữ phải là tiếng Anh.""" + i18n.set_language("ja") + toggle = RoutingToggle(FakeCtx(), "cowork") + + i18n.set_language("en") + + _chu_dinh_tuyen_phai_la(toggle, "en") + + +def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_vi_roi_doi_sang_ja(qapp, i18n_sach): + """Chiều ngược lại: dựng ở tiếng Việt, đổi sang tiếng Nhật.""" + i18n.set_language("vi") + toggle = RoutingToggle(FakeCtx(), "cowork") + + i18n.set_language("ja") + + _chu_dinh_tuyen_phai_la(toggle, "ja") + + +def test_doi_ngon_ngu_khong_lam_doi_che_do_dinh_tuyen(qapp, i18n_sach): + """Test KHOÁ: dịch lại chỉ được đổi CHỮ, không đổi chế độ và không ghi đĩa. + + Cột ``data`` ("off"/"auto"/"manual"/"fallback") được persist xuống đĩa; nếu + ai đó dịch luôn cột đó, hoặc đổi ``retranslate()`` sang ``clear()+addItem()``, + thì mode routing của mọi workspace bị âm thầm reset về "off". + """ + i18n.set_language("ja") + ctx = FakeCtx() + toggle = RoutingToggle(ctx, "cowork") + + for lang in ("vi", "en", "ja"): + i18n.set_language(lang) + + assert toggle.current_mode() == "manual" + assert toggle._combo.currentData() == "manual" + assert ctx.writes == [], "đổi ngôn ngữ không được ghi lại chế độ định tuyến" + + +def test_tu_chay_dung_ngon_ngu_sau_khi_doi_ngon_ngu(qapp, i18n_sach): + """Ô "Tự chạy" cũng phải đổi chữ, và không được tự đổi trạng thái tick.""" + i18n.set_language("ja") + ctx = FakeCtx() + toggle = AutoRunToggle(ctx) + + i18n.set_language("vi") + + assert toggle._chk.text() == i18n.STRINGS["routing.autorun_label"]["vi"] + assert toggle._chk.toolTip() == i18n.STRINGS["routing.autorun_tooltip"]["vi"] + + i18n.set_language("en") + + assert toggle._chk.text() == i18n.STRINGS["routing.autorun_label"]["en"] + assert toggle._chk.toolTip() == i18n.STRINGS["routing.autorun_tooltip"]["en"] + assert toggle._chk.isChecked() is True + assert ctx.writes == [], "đổi ngôn ngữ không được ghi lại cờ tự chạy" + + +def test_bon_che_do_khong_bi_cat_chu_sau_khi_doi_ngon_ngu(qapp, i18n_sach): + """Dịch lại xong thì combo phải rộng ra theo chữ mới, không nuốt đuôi. + + Combo dựng ở tiếng Nhật rồi đổi sang tiếng Việt: tên chế độ tiếng Việt dài + hơn tên tiếng Nhật, nên nếu bề rộng còn bị khoá theo lần hiện đầu tiên thì + "Thủ công"/"Dự phòng" (96px) không lọt ô chữ 89px và bị cắt đuôi. + + Hai lớp assert, mỗi lớp bắt một nửa bản vá: + + * chữ phải là tiếng Việt — bắt việc quên đăng ký ``on_language_changed``; + * chữ phải lọt ô — bắt việc quên ``setSizeAdjustPolicy``, vì combo chỉ đo + lại bề rộng khi chính sách là ``AdjustToContents``. + + ``host`` được nới rộng có chủ ý: ``addStretch`` phải còn chỗ trống để nhả ra + cho combo, nếu không thì cả combo đúng lẫn combo sai đều kẹt ở bề rộng cũ và + test không phân biệt được hai trạng thái. + """ + from PySide6.QtGui import QFontMetrics + from PySide6.QtWidgets import QHBoxLayout, QWidget + + i18n.set_language("ja") + host = QWidget() + lay = QHBoxLayout(host) + toggle = RoutingToggle(FakeCtx(), "cowork") + lay.addWidget(toggle) + lay.addStretch(1) + host.resize(600, 60) + host.show() + _on_dinh_layout(qapp) + + i18n.set_language("vi") + _on_dinh_layout(qapp) + + combo = toggle._combo + fm = QFontMetrics(combo.font()) + o_chu = _be_rong_o_chu(combo) + chu_hien = [combo.itemText(i) for i in range(combo.count())] + bi_cat = [t for t in chu_hien if fm.horizontalAdvance(t) > o_chu] + host.close() + + assert chu_hien == [i18n.STRINGS[key]["vi"] for _value, key in toggle._modes] + assert not bi_cat, f"bị cắt chữ: {bi_cat} (ô chữ rộng {o_chu}px)" + + +@pytest.mark.parametrize("key", [ + "routing.toggle_label", "routing.toggle_tooltip", + "routing.autorun_label", "routing.autorun_tooltip", + "routing.mode_off", "routing.mode_auto", + "routing.mode_manual", "routing.mode_fallback", +]) +def test_key_routing_co_du_ba_ngon_ngu(key): + """Test KHOÁ: thiếu một bản dịch thì ``tr()`` rơi về tiếng Anh, lỗi lại tái diễn.""" + for lang in ("en", "ja", "vi"): + assert i18n.STRINGS[key].get(lang), f"{key} thiếu {lang}" diff --git a/theme/qss.py b/theme/qss.py index ee76110..533dcff 100644 --- a/theme/qss.py +++ b/theme/qss.py @@ -183,10 +183,14 @@ QPushButton#navSettingsBtn { /* Padding stays at 0: the row lays its own icon and label out, so that the spacing does not change with the platform's button style. */ padding: 0; text-align: left; border-radius: ${radius}px; - /* No side margin: Settings reads as one more row under Dashboard/Giám sát, - so its icon has to start on their x. A 6px margin put it at 14 — near - enough the middle of the collapsed 54px rail to look centred. */ - margin: 2px 0px 6px 0px; + /* No margin at all. A vertical one here was doing the opposite of what it + read as: QVBoxLayout gave this button its geometry with the margin + ignored (nav_bottom's bottom edge and this button's top edge measured + the same y), while the PAINTER honoured it — so the only thing 10px/14px + actually did was inset the hover fill, leaving Settings with a visibly + shorter hover pill than the rows above. Spacing above/below the row is + nav_rail's, which pins this button to the rows' own height. */ + margin: 0; } QPushButton#navSettingsBtn:hover { background: $nav_hover; color: $text; } QPushButton#navSettingsBtn:pressed { background: $active; } diff --git a/theme/qss_controls.py b/theme/qss_controls.py index ce822ad..0f68628 100644 --- a/theme/qss_controls.py +++ b/theme/qss_controls.py @@ -324,6 +324,17 @@ QLabel#welcomeCardTitle { color: $text; font-weight: 600; } nen dac cua rieng minh thay vi chi la mot dong chu. */ QWidget#graphBusy { background: $overlay; border: 1px solid $border_strong; border-radius: ${radius}px; } QWidget#graphBusy QLabel { color: $text; font-weight: 600; } + +/* Lop phu ca cua so trong luc doi ngon ngu — cung ly do voi #graphBusy o tren: + viec chan chay DONG BO tren GUI thread. Nen mo chu khong dac: nguoi dung con + thay man hinh cu mo di, nen hieu la app dang lam viec chu khong phai da nhay + sang mot man hinh khac. */ +QWidget#busyOverlay { background: rgba(0, 0, 0, 0.45); } +QWidget#busyOverlayPanel { + background: $overlay; border: 1px solid $border_strong; + border-radius: ${radius}px; +} +QWidget#busyOverlayPanel QLabel { color: $text; font-weight: 600; } QLabel#faint { color: $text_faint; } QLabel#warning { color: $warning; font-weight: 600; } QLabel#error { color: $danger; font-weight: 600; } diff --git a/ui/co4e_tab.py b/ui/co4e_tab.py index dad7a05..20d9101 100644 --- a/ui/co4e_tab.py +++ b/ui/co4e_tab.py @@ -390,21 +390,35 @@ class Co4ETab( # ---- i18n ------------------------------------------------------------- def _retranslate(self) -> None: - """Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề các mục và tooltip.""" - for key in self._sections: - self._sync_section_arrow(key) - self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab")) - self.wf_new_btn.setText(tr("co4e.new")) - self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf")) + """Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề các mục và tooltip. + + Cột trái và trang Flow Status không có mặt ở đây: mỗi widget bên đó tự + ràng buộc khoá dịch của mình tại chỗ dựng (``i18n.bind_*``), nên panel + dùng ở đâu cũng đúng ngôn ngữ mà không cần ai nhớ hộ. + """ self.runs_btn.setText(tr("co4e.runs_tab")) self.runs_btn.setToolTip(tr("co4e.tt_runs_tab")) - self.runs_back_btn.setText(tr("co4e.back_to_flow")) - self.runs_back_btn.setToolTip(tr("co4e.tt_back_to_flow")) - self.runs_title.setText(tr("co4e.running_flows")) - self.run_stop_btn.setText(tr("co4e.stop")) - self.run_rename_btn.setText(tr("co4e.rename_run")) - self.run_del_btn.setText(tr("co4e.delete_run")) - self.run_clear_btn.setText(tr("co4e.clear_done")) + # Flow toolbar. + self.name_edit.setToolTip(tr("co4e.tt_flow_name")) + self.add_step_btn.setText(tr("co4e.add")) + self.add_step_btn.setToolTip(tr("co4e.tt_add_step")) + self.save_btn.setText(tr("co4e.save")) + self.save_btn.setToolTip(tr("co4e.tt_save")) + self.save_tpl_btn.setToolTip(tr("co4e.tt_save_template")) + self.mode_combo.setToolTip(tr("co4e.tt_mode")) + # By position, from the same source the items were built from: the mode + # string in each item's data is persisted, so it must survive a + # translation untouched. + for i, mode in enumerate(co4e.RUN_MODES): + self.mode_combo.setItemText(i, tr(f"co4e.mode.{mode}")) + self.run_btn.setToolTip(tr("co4e.tt_run")) + # Not a plain tr(): this button reads "Dừng" while THIS flow is running. + self._update_run_btn() + # Step-config panel header. The toggle's tooltip names the action it + # would perform, which depends on which way the panel is folded. + self.config_title.setText(tr("co4e.config_title")) + self.config_toggle_btn.setToolTip(tr( + "co4e.tt_expand_config" if self._config_collapsed else "co4e.tt_collapse_config")) self._refresh_ws_folder_btn() self.runs_table.setHorizontalHeaderLabels([ tr("co4e.runs_col_flow"), tr("co4e.runs_col_status"), tr("co4e.runs_col_steps"), diff --git a/ui/connectors_panel.py b/ui/connectors_panel.py index 457e712..7315b44 100644 --- a/ui/connectors_panel.py +++ b/ui/connectors_panel.py @@ -19,7 +19,7 @@ from PySide6.QtWidgets import ( from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES from ..core.worker import AgentWorker -from ..i18n import on_language_changed, tr +from ..i18n import bind_text, on_language_changed, tr from ..state import AppContext from .ext_connector_dialog import ExtConnectorEditDialog from .icons import icon @@ -160,7 +160,7 @@ class ConnectorsPanel(QWidget): self.connect_external_sw.toggled.connect(self._on_connect_external_toggled) lay.addWidget(self.connect_external_sw) - hint = QLabel(tr("settings.ext_hint")) + hint = bind_text(QLabel(), "settings.ext_hint") hint.setObjectName("hint") hint.setWordWrap(True) lay.addWidget(hint) diff --git a/ui/cowork_tab.py b/ui/cowork_tab.py index b383854..7efe955 100644 --- a/ui/cowork_tab.py +++ b/ui/cowork_tab.py @@ -99,6 +99,10 @@ class CoworkTab(ChatPanel): def _retranslate(self) -> None: """Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và các nút trên thanh công cụ.""" + # ChatPanel dựng phần chrome dùng chung (nhãn Agent, nút Nén, tiêu đề ba + # bảng tệp) nhưng KHÔNG tự đăng ký dịch lại — lớp con là chỗ duy nhất có + # đăng ký, nên bỏ dòng này là toàn bộ phần đó đứng ở ngôn ngữ lúc dựng. + self._retranslate_base() self.refresh_title() self.skills_btn.setText(tr("cowork.skills_btn")) self.skills_btn.setToolTip(tr("cowork.skills_tooltip")) diff --git a/ui/routing_toggle.py b/ui/routing_toggle.py index d235a55..45048e9 100644 --- a/ui/routing_toggle.py +++ b/ui/routing_toggle.py @@ -23,7 +23,7 @@ from PySide6.QtWidgets import ( QWidget, ) -from ..i18n import tr +from ..i18n import on_language_changed, tr class RoutingToggle(QWidget): @@ -71,6 +71,11 @@ class RoutingToggle(QWidget): self._label.setObjectName("hint") self._combo = QComboBox() self._combo.setToolTip(tr("routing.toggle_tooltip")) + # Mode names differ in length per language ("Thủ công" is wider than + # "手動"), and a combo only re-measures itself under this policy — without + # it the width stays frozen at the language the widget was built in and + # the longer translation is cut off. + self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents) # (data value, i18n key) — data is the persisted mode string. Order is # least-to-most autonomous, with Fallback (R03-T03) last because it is # the "only when something breaks" mode rather than a stronger Auto. @@ -88,6 +93,11 @@ class RoutingToggle(QWidget): self._combo.currentIndexChanged.connect(self._on_changed) lay.addWidget(self._label) lay.addWidget(self._combo) + # Registered HERE, not by the four surfaces that embed this widget + # (Cowork, Co4E, AI-Edit): every one of them had forgotten to, so the + # control stayed frozen in the language it was built in. Owning it here + # means no future embedder can forget either. + on_language_changed(self.retranslate) def current_mode(self) -> str: """Chế độ định tuyến đang chọn; 'off' nếu chưa đặt.""" @@ -149,6 +159,7 @@ class AutoRunToggle(QWidget): self.refresh() self._chk.toggled.connect(self._on_toggled) lay.addWidget(self._chk) + on_language_changed(self.retranslate) # same reason as RoutingToggle def refresh(self) -> None: """Đọc lại trạng thái tự chạy của project đang mở lên ô đánh dấu."""