CI / test (push) Canceled after 0s
fix các bug theo yêu cầu https://fptsoftware362-my.sharepoint.com/❌/g/personal/nampdt_fpt_com/IQAHBJ4A9xqDTLgvt2bhukJEAdRB5LRz2hbJpTivvIiBSYM?wdExp=TEAMS-TREATMENT&web=1&isSPOFile=1&ovuser=f01e930a-b52e-42b1-b70f-a8882b5d043b%2CAnhTNM1%40fpt.com&clickparams=eyJBcHBOYW1lIjoiVGVhbXMtRGVza3RvcCIsIkFwcFZlcnNpb24iOiI0OS8yNjA4MTMxOTMxNyIsIkhhc0ZlZGVyYXRlZFVzZXIiOmZhbHNlfQ%3D%3D --------- Co-authored-by: Duy Le Huu <duylh19@fpt.com> Reviewed-on: #10 Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
294 lines
14 KiB
Python
294 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, _NAV_SETTINGS_GAP
|
|
|
|
# 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))
|
|
# Khe TRÊN hàng Cài đặt, xin thẳng từ layout — thanh rail đặt
|
|
# ``setSpacing(0)`` nên không có khoảng nào sẵn, và margin trong QSS thì
|
|
# không mua được pixel nào (xem ``_NAV_SETTINGS_GAP``). Cài đặt là việc
|
|
# khác với nhóm Dashboard/Giám sát ngay trên nó; dán sát vào thì hai thứ
|
|
# đọc thành một khối.
|
|
nvl.addSpacing(_NAV_SETTINGS_GAP)
|
|
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)
|