Files
cowork-local/presentation/shell/top_bar.py
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00:00

245 lines
12 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)
srow.setContentsMargins(_NAV_ROW_INSET, 6, 8, 6)
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)
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
self.statusBar().showMessage(
tr("app.status.using_provider",
label=PROVIDER_LABELS.get(self.ctx.config.active_provider))
)
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()
set_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")))
set_language(self.ctx.config.language) # apply if changed in Settings
# 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)