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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 21:23:12 +09:00

288 lines
14 KiB
Python

"""Thanh trên cùng và hàng tài khoản — R08-T10.
Bóc từ ``MainWindow``: logo, chọn provider, chọn ngôn ngữ, nút đổi giao diện,
và lối mở hộp thoại Cài đặt.
Cùng lý do mixin như ``nav_rail.py``: các phương thức này đọc/ghi state của cửa
sổ. Xem ghi chú ở đầu file đó.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QComboBox, QHBoxLayout, QLabel, QMenu, QPushButton, QToolButton, QVBoxLayout, QWidget
from ...config import PROVIDER_LABELS
from ...i18n import LANGUAGE_SHORT, LANGUAGES, get_language, on_language_changed, set_language, tr
from .branding import ASSETS
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET
from ...ui.widgets import tidy_popup
from ...theme import set_active_theme, stylesheet
from ...ui.settings_dialog import SettingsDialog
class TopBarMixin:
"""Thanh trên cùng. Trộn vào MainWindow."""
def _build_rail_bottom(self, nvl) -> None:
"""Đáy thanh rail: nhóm ghim dưới, nút Cài đặt, hàng tài khoản.
Nằm ở file thanh trên cùng chứ không phải file rail, vì ba thứ này
đều là "tài khoản và thiết lập" — cùng mối quan tâm với
``_build_account_row`` ngay bên dưới, chỉ khác chỗ đặt trên màn hình.
"""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton
from ...i18n import tr
from ...ui.icons import icon as _icon
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET
# Bottom-pinned group: the places you visit occasionally, kept out of the
# way of the ones you live in. A hairline (styled via #navrailBottom in
# theme.py) separates the two lists.
nvl.addWidget(self.nav_bottom, 0)
# Settings reads as one more row under Dashboard / Giám sát, so its icon
# and label must start exactly where theirs do. Letting QPushButton place
# them does not achieve that: the gap it leaves between icon and text is
# the platform style's, and on macOS it is visibly tighter than the tree
# rows above — a Windows-tuned nudge only moved the mismatch. So the row
# is laid out here, in the same two numbers the tree uses: 4px in, 6px
# between.
self._nav_settings_btn = QPushButton()
self._nav_settings_btn.setObjectName("navSettingsBtn")
self._nav_settings_btn.setFlat(True)
self._nav_settings_btn.setCursor(Qt.PointingHandCursor)
self._nav_settings_btn.clicked.connect(self._open_settings)
srow = QHBoxLayout(self._nav_settings_btn)
# 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))
self._nav_settings_icon.setFixedSize(16, 16)
self._nav_settings_text = QLabel(tr("app.settings"))
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()
def _build_topbar(self) -> QWidget:
"""Dựng thanh trên: thương hiệu, bộ chọn provider/ngôn ngữ/theme và hàng tài khoản."""
bar = QWidget()
bar.setObjectName("topbar")
# Styled centrally (see theme._TEMPLATE): flat, with a single hairline
# separating it from the content below — no card box behind it.
h = QHBoxLayout(bar)
h.setContentsMargins(16, 10, 12, 10)
h.setSpacing(10)
# FPT logo slot in front of the brand text: shown only when a logo
# image has been dropped into assets/ (see _brand_logo_pixmap) — the
# brand works text-only until the real artwork is supplied.
self.logo_img = QLabel()
logo_pm = self._brand_logo_pixmap()
if logo_pm is not None:
self.logo_img.setPixmap(logo_pm)
else:
self.logo_img.setVisible(False)
h.addWidget(self.logo_img)
self.logo_lbl = QLabel(tr("app.logo"))
self.logo_lbl.setObjectName("brand") # styled centrally — see theme._TEMPLATE
h.addWidget(self.logo_lbl)
h.addStretch(1)
# Provider / language / theme / Settings used to live here, five controls
# wide across the top of every screen. They are per-account settings, not
# per-screen ones, so they moved to the account row at the foot of the
# rail (_build_account_row) — same widgets, same handlers, new home.
return bar
def _build_account_row(self) -> QWidget:
"""The rail's foot: who you are, and the settings that follow you.
Nothing new is introduced here — these are the exact widgets the top bar
used to hold, moved as-is so every existing signal still lands.
"""
box = QWidget()
box.setObjectName("navAccount")
v = QVBoxLayout(box)
v.setContentsMargins(6, 4, 6, 4)
v.setSpacing(4)
who = QHBoxLayout()
who.setSpacing(4)
self.account_lbl = QLabel(f"👤 {self._user_name}" if self._user_name else "👤")
self.account_lbl.setObjectName("hint")
who.addWidget(self.account_lbl, 1)
self.language_combo = QComboBox()
for key in LANGUAGES:
self.language_combo.addItem(LANGUAGE_SHORT.get(key, key.upper()), key)
self.language_combo.setItemData(
self.language_combo.count() - 1, LANGUAGES[key], Qt.ToolTipRole)
idx = self.language_combo.findData(get_language())
if idx >= 0:
self.language_combo.setCurrentIndex(idx)
tidy_popup(self.language_combo)
self.language_combo.currentIndexChanged.connect(self._on_language_changed)
who.addWidget(self.language_combo)
self.theme_btn = self._build_theme_button()
who.addWidget(self.theme_btn)
v.addLayout(who)
self.provider_lbl = QLabel(tr("app.provider"))
self.provider_lbl.setObjectName("hint")
self.provider_lbl.setVisible(False) # the combo names itself in the rail
self.provider_combo = QComboBox()
self.provider_combo.setToolTip(tr("app.provider"))
for key, label in PROVIDER_LABELS.items():
self.provider_combo.addItem(label, key)
tidy_popup(self.provider_combo)
idx = self.provider_combo.findData(self.ctx.config.active_provider)
if idx >= 0:
self.provider_combo.setCurrentIndex(idx)
self.provider_combo.currentIndexChanged.connect(self._on_provider_changed)
v.addWidget(self.provider_lbl)
v.addWidget(self.provider_combo)
return box
def _brand_logo_pixmap(self):
"""The FPT logo scaled to top-bar height, or None while no logo file
exists yet — drop the artwork into src/cowork_local/assets/ under one
of the _BRAND_LOGO_NAMES and it appears on next launch."""
from PySide6.QtGui import QPixmap
for name in self._BRAND_LOGO_NAMES:
path = ASSETS / name
if not path.exists():
continue
pm = QPixmap(str(path))
if pm.isNull():
continue
return pm.scaledToHeight(self._BRAND_LOGO_HEIGHT, Qt.SmoothTransformation)
return None
def _build_theme_button(self) -> QToolButton:
"""A single icon button (System/Dark/Light) replacing the old
Settings-only theme dropdown — one click applies the choice
immediately via the existing _apply_theme(), no dialog round-trip."""
from ...ui.icons import icon as _icon
btn = QToolButton()
btn.setPopupMode(QToolButton.InstantPopup)
menu = QMenu(btn)
self._theme_actions = {}
for value, icon_name in self._THEME_ICONS.items():
act = menu.addAction(_icon(icon_name), tr(f"settings.theme_{value}"))
act.triggered.connect(lambda _checked=False, v=value: self._set_theme(v))
self._theme_actions[value] = act
btn.setMenu(menu)
btn.setIcon(_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
return btn
def _set_theme(self, value: str) -> None:
"""Đổi theme, ghi cấu hình và áp stylesheet mới lên toàn ứng dụng."""
from ...ui.icons import icon as _icon
self.ctx.config.theme = value
self.ctx.save()
self._apply_theme()
self.theme_btn.setIcon(_icon(self._THEME_ICONS.get(value, "monitor")))
def _on_provider_changed(self, _idx: int) -> None:
"""Đổi provider đang dùng: lưu cấu hình rồi làm mới mọi bề mặt phụ thuộc nó
(tiêu đề Cowork, bộ chọn model của AI-Edit).
"""
self.ctx.config.active_provider = self.provider_combo.currentData()
self.ctx.save()
self.cowork.refresh_header()
# Reload the Cowork tab's Agent (Model) list for the newly selected provider.
self.cowork.refresh_agents()
self.workspace.refresh_ai_models() # + the Folder AI-edit model picker
# 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.
"""
lang = self.language_combo.currentData()
if not lang or lang == get_language():
return
self.ctx.config.language = lang
self.ctx.save()
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)
if dlg.exec():
self._apply_theme()
# Settings can change the theme too — keep the rail's toggle icon
# showing the value that is actually in effect.
from ...ui.icons import icon as _theme_icon
self.theme_btn.setIcon(
_theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
# 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:
self.provider_combo.setCurrentIndex(i)
li = self.language_combo.findData(get_language())
if li >= 0:
self.language_combo.blockSignals(True)
self.language_combo.setCurrentIndex(li)
self.language_combo.blockSignals(False)
self.cowork.refresh_header()
self.cowork.refresh_agents()
self.workspace.refresh_ai_models() # + the Folder AI-edit model picker
max_files = int(self.ctx.config.data.get("attachments", {}).get("max_files", 10) or 0)
self.cowork.composer.set_max_attachments(max_files)
self.sidebar.refresh()
self.statusBar().showMessage(tr("app.status.settings_saved"))
def _apply_theme(self) -> None:
"""Áp theme đang cấu hình lên toàn bộ ứng dụng."""
app = QApplication.instance()
if app:
set_active_theme(self.ctx.config.theme)
app.setStyleSheet(stylesheet(self.ctx.config.theme))
# Re-apply theme styles to chat bubbles so they adapt to the new theme.
self.cowork.apply_theme()
if getattr(self, "help_agent", None) is not None:
self.help_agent.apply_theme() # chat body follows theme (header stays fixed)