Delta team/fix comment ui #10
+95
-2
@@ -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
|
||||
@@ -64,6 +75,7 @@ def set_language(lang: str) -> None:
|
||||
if lang == _current:
|
||||
return
|
||||
_current = lang
|
||||
_apply_bindings()
|
||||
for fn in list(_listeners):
|
||||
try:
|
||||
fn()
|
||||
@@ -98,6 +110,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())
|
||||
|
||||
@@ -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)"},
|
||||
|
||||
+16
-16
@@ -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"},
|
||||
|
||||
+2
-2
@@ -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"},
|
||||
|
||||
+6
-6
@@ -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…"},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: <name>\\n<instructions>`` 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:
|
||||
|
||||
@@ -39,7 +39,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ...config import PROVIDER_LABELS
|
||||
from ...core.co4e import PERMISSION_PRESETS, STEP_DONE, STEP_RUNNING, 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
|
||||
@@ -83,26 +83,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()
|
||||
@@ -110,15 +114,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"))
|
||||
|
||||
@@ -128,28 +132,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)
|
||||
@@ -161,15 +169,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()
|
||||
@@ -177,7 +185,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
|
||||
@@ -189,10 +197,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()
|
||||
@@ -205,17 +213,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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -20,7 +20,7 @@ from PySide6.QtWidgets import (
|
||||
QSplitter, QTableWidget, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ....i18n import tr
|
||||
from ....i18n import bind_tip, tr
|
||||
from ....ui.icons import icon
|
||||
from .event_table import PAGE_SIZE_OPTIONS, ClickOutsideCloser, EventTable
|
||||
from .event_detail_panel import EventDetailPanel
|
||||
@@ -83,7 +83,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))
|
||||
|
||||
@@ -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()
|
||||
@@ -260,6 +260,12 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
|
||||
tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip"))
|
||||
if hasattr(self, "provider_lbl"):
|
||||
self.provider_lbl.setText(tr("app.provider"))
|
||||
# The label is hidden — the combo names itself through its tooltip
|
||||
# (see top_bar._build_account_row), so that is the one users read.
|
||||
self.provider_combo.setToolTip(tr("app.provider"))
|
||||
if hasattr(self, "nav_project"):
|
||||
self.nav_project.setToolTip(tr("app.nav.project_pick"))
|
||||
self.nav_recents_hdr.setText(tr("app.nav.recents"))
|
||||
if hasattr(self, "settings_btn"):
|
||||
self.settings_btn.setText(tr("app.settings"))
|
||||
if hasattr(self, "theme_btn"):
|
||||
@@ -271,6 +277,13 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
|
||||
if getattr(self, "help_agent", None) is not None:
|
||||
self.help_agent.retranslate()
|
||||
self._tray.retranslate()
|
||||
# Thanh trạng thái (góc dưới bên trái) nhận thông báo từ hàng chục nơi
|
||||
# qua signal ``status_message``, và signal đó mang CHUỖI ĐÃ DỊCH chứ
|
||||
# không mang khoá — nên không thể dịch lại câu đang hiện. Đưa nó về câu
|
||||
# nền của ngôn ngữ mới: câu cũ không đọng lại bằng thứ tiếng vừa rời đi,
|
||||
# mà chỗ đó cũng không trống trơn. Thông báo là ghi chú về một việc vừa
|
||||
# xong, nên bỏ nó đi khi đổi ngôn ngữ không làm mất thông tin nào.
|
||||
self.statusBar().showMessage(tr("app.status.ready"))
|
||||
|
||||
# ---- system tray (run in background when the window is closed) ---
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -54,7 +54,12 @@ class TopBarMixin:
|
||||
self._nav_settings_btn.setCursor(Qt.PointingHandCursor)
|
||||
self._nav_settings_btn.clicked.connect(self._open_settings)
|
||||
srow = QHBoxLayout(self._nav_settings_btn)
|
||||
srow.setContentsMargins(_NAV_ROW_INSET, 6, 8, 6)
|
||||
# No vertical padding of its own: ``_rebuild_nav`` pins this button to the
|
||||
# nav rows' OWN height, so the 6px a row pads with is already inside
|
||||
# that number. Adding it again here made the row taller than the button
|
||||
# (28 wanted, 20 given), which both clipped the icon and pushed the text
|
||||
# 8px below an even pitch with Dashboard / Giám sát.
|
||||
srow.setContentsMargins(_NAV_ROW_INSET, 0, 8, 0)
|
||||
srow.setSpacing(_NAV_ROW_GAP)
|
||||
self._nav_settings_icon = QLabel()
|
||||
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16))
|
||||
@@ -63,6 +68,9 @@ class TopBarMixin:
|
||||
srow.addWidget(self._nav_settings_icon)
|
||||
srow.addWidget(self._nav_settings_text)
|
||||
srow.addStretch(1)
|
||||
# The first _rebuild_nav() ran before this button existed (it is what
|
||||
# fills the list this row belongs under), so take the height here too.
|
||||
self._nav_settings_btn.setFixedHeight(self.nav_bottom.sizeHintForRow(0))
|
||||
nvl.addWidget(self._nav_settings_btn)
|
||||
self._account_row = self._build_account_row()
|
||||
|
||||
@@ -194,6 +202,39 @@ class TopBarMixin:
|
||||
# Khong bao "dang dung <provider>" o thanh trang thai: chinh bo chon
|
||||
# provider nam ngay tren man hinh va da hien thu vua chon, nen dong thong
|
||||
# bao chi nhac lai mot thu nguoi dung vua tu tay lam.
|
||||
def _lang_busy_overlay(self):
|
||||
"""The window's busy cover, built on first use.
|
||||
|
||||
Built lazily so a window that never changes language never gets one —
|
||||
and so ``_open_settings`` can be checked for "no switch, no flash".
|
||||
"""
|
||||
overlay = getattr(self, "_lang_busy", None)
|
||||
if overlay is None:
|
||||
from .busy_overlay import BusyOverlay
|
||||
overlay = BusyOverlay(self)
|
||||
self._lang_busy = overlay
|
||||
return overlay
|
||||
|
||||
def _switch_language(self, lang: str) -> None:
|
||||
"""Apply a new UI language behind a busy cover.
|
||||
|
||||
``set_language`` runs every registered widget's re-translation on the
|
||||
GUI thread, which on a large skill library takes long enough to look
|
||||
like a hang. Nothing can raise a cover once that has started (no event
|
||||
loop is left running), so it goes up FIRST — see ``busy_overlay.py``.
|
||||
|
||||
The message is read before the switch on purpose: mid-switch the only
|
||||
language the user can still read is the one being left behind.
|
||||
"""
|
||||
message = tr("app.lang.switching")
|
||||
self.language_combo.setEnabled(False)
|
||||
try:
|
||||
self._lang_busy_overlay().run_blocking(message, lambda: set_language(lang))
|
||||
finally:
|
||||
# In a ``finally`` so a listener that raises cannot leave the
|
||||
# switcher locked for the rest of the session.
|
||||
self.language_combo.setEnabled(True)
|
||||
|
||||
def _on_language_changed(self, _idx: int) -> None:
|
||||
"""Đổi ngôn ngữ giao diện; trùng ngôn ngữ hiện tại thì bỏ qua để không dựng lại
|
||||
toàn bộ chữ vô ích.
|
||||
@@ -203,7 +244,7 @@ class TopBarMixin:
|
||||
return
|
||||
self.ctx.config.language = lang
|
||||
self.ctx.save()
|
||||
set_language(lang) # notifies every registered persistent widget
|
||||
self._switch_language(lang) # notifies every registered persistent widget
|
||||
def _open_settings(self) -> None:
|
||||
"""Mở hộp thoại Cài đặt; bấm Lưu thì áp lại theme và làm mới thanh trên."""
|
||||
dlg = SettingsDialog(self.ctx, self)
|
||||
@@ -214,7 +255,10 @@ class TopBarMixin:
|
||||
from ...ui.icons import icon as _theme_icon
|
||||
self.theme_btn.setIcon(
|
||||
_theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
|
||||
set_language(self.ctx.config.language) # apply if changed in Settings
|
||||
# Guarded, not left to set_language's own no-op check: the cover
|
||||
# around the switch would otherwise flash on every Save.
|
||||
if self.ctx.config.language != get_language():
|
||||
self._switch_language(self.ctx.config.language)
|
||||
# reflect provider/theme/language changes
|
||||
i = self.provider_combo.findData(self.ctx.config.active_provider)
|
||||
if i >= 0:
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
+8
-4
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+27
-13
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+12
-1
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user