diff --git a/app.py b/app.py index 24ff7a5..475d9f4 100644 --- a/app.py +++ b/app.py @@ -20,6 +20,8 @@ from . import APP_NAME, DISPLAY_NAME, __version__ from .config import PROVIDER_LABELS, AppConfig from .i18n import LANGUAGE_SHORT, LANGUAGES, get_language, on_language_changed, set_language, tr from .presentation.shell.bootstrap import build_context +from .presentation.shell.lifecycle_coordinator import LifecycleCoordinator +from .presentation.shell.tray_manager import TrayManager from .state import AppContext from .ui.widgets import tidy_popup from .theme import current_palette, set_active_theme, stylesheet @@ -117,6 +119,11 @@ class _NavItemDelegate(QStyledItemDelegate): class MainWindow(QMainWindow): + #: Biểu tượng khay, hoặc None nếu máy không có khay. Vẫn giữ tên cũ vì + #: còn vài chỗ đọc thẳng self.tray; bản thân việc dựng/ẩn/thông báo đã + #: chuyển sang self._tray (TrayManager). + tray = property(lambda self: self._tray.icon) + # Nav rows (Dashboard/Schedule/Monitoring are lazy; Workspace is the eager home page). _ROW_DASHBOARD, _ROW_SCHEDULE, _ROW_WORKSPACE, _ROW_MONITORING = 0, 1, 2, 3 @@ -125,7 +132,9 @@ class MainWindow(QMainWindow): self.ctx = ctx self._user_name = user_name self._really_quit = False - self.tray = None + self._life = LifecycleCoordinator(self) + # Khay hệ thống: presentation/shell/tray_manager.py (R08-T10). + self._tray = TrayManager(self, icon=app_icon, tooltip=DISPLAY_NAME, tr=tr) self._nav_collapsed = False # icon-only nav rail toggle (Task: collapsible nav) self._history_collapsed = False # remembers History's own collapse-to-strip state self.setWindowTitle(f"{DISPLAY_NAME} v{__version__}") @@ -386,7 +395,7 @@ class MainWindow(QMainWindow): self._credit.setStyleSheet("padding: 0 10px;") self.statusBar().addPermanentWidget(self._credit) self._restore_sessions() - self._setup_tray() + self._tray.setup() # Start the task scheduler last, once the whole window exists — it # catches up any overdue tasks right away (first tick runs inline). self.task_scheduler.start() @@ -455,31 +464,9 @@ class MainWindow(QMainWindow): self.logo_lbl.setText(tr("app.logo")) if getattr(self, "help_agent", None) is not None: self.help_agent.retranslate() - if self.tray is not None: - self.tray.setToolTip(DISPLAY_NAME) - if hasattr(self, "_tray_open_act"): - self._tray_open_act.setText(tr("app.tray.open")) - self._tray_quit_act.setText(tr("app.tray.quit")) + self._tray.retranslate() # ---- system tray (run in background when the window is closed) --- - def _setup_tray(self) -> None: - from PySide6.QtGui import QAction - - if not QSystemTrayIcon.isSystemTrayAvailable(): - return - self.tray = QSystemTrayIcon(app_icon(), self) - self.tray.setToolTip(DISPLAY_NAME) - menu = QMenu() - self._tray_open_act = QAction(tr("app.tray.open"), self) - self._tray_open_act.triggered.connect(self._show_window) - self._tray_quit_act = QAction(tr("app.tray.quit"), self) - self._tray_quit_act.triggered.connect(self._quit_app) - menu.addAction(self._tray_open_act) - menu.addAction(self._tray_quit_act) - self.tray.setContextMenu(menu) - self.tray.activated.connect( - lambda reason: self._show_window() if reason == QSystemTrayIcon.Trigger else None) - self.tray.show() def _page_index(self, widget) -> int: return self.pages.indexOf(widget) @@ -892,12 +879,7 @@ class MainWindow(QMainWindow): if (self.tray is not None and self.ctx.config.data.get("tray", {}).get("notify_on_done", True) and not self.isActiveWindow()): - try: - self.tray.showMessage( - DISPLAY_NAME, msg, - QSystemTrayIcon.Information if ok else QSystemTrayIcon.Warning, 5000) - except Exception: # noqa: BLE001 - pass + self._tray.show_message(DISPLAY_NAME, msg, error=not ok) self._refresh_history() def _notify_task(self, tab, kind: str, result: dict) -> None: @@ -919,11 +901,7 @@ class MainWindow(QMainWindow): err = (result or {}).get("error") title = tr("app.toast.error", name=name) if err else tr("app.toast.done", name=name) body = (err if err else (tab._last_assistant_text() or "Task completed."))[:140] - icon = QSystemTrayIcon.Critical if err else QSystemTrayIcon.Information - try: - self.tray.showMessage(title, body, icon, 5000) - except Exception: - pass + self._tray.show_message(title, body, error=bool(err)) def _show_window(self) -> None: self.showNormal() @@ -1202,27 +1180,17 @@ class MainWindow(QMainWindow): # Share of the available screen the window takes when it has room to. Fixed # pixels do not travel: 1180×760 fills a laptop and looks lost on a 4K # panel. `want_*` stays the floor so a small screen behaves as before. - _SCREEN_SHARE_W, _SCREEN_SHARE_H = 0.80, 0.85 - + # Canh cửa sổ và tắt sạch: presentation/shell/lifecycle_coordinator.py def _fit_to_screen(self, want_w: int, want_h: int) -> None: - screen = self.screen() or QGuiApplication.primaryScreen() - avail = screen.availableGeometry() if screen else None - if avail is None: - self.resize(want_w, want_h) + self._life.fit_to_screen(want_w, want_h) + + def _on_screen_maybe_changed(self) -> None: + if not self._life.screen_maybe_changed(): return - margin = 60 - # Take a share of the screen, never less than the asked-for size and - # never more than the screen can show. - w = min(max(want_w, int(avail.width() * self._SCREEN_SHARE_W)), - avail.width() - margin) - h = min(max(want_h, int(avail.height() * self._SCREEN_SHARE_H)), - avail.height() - margin) - # minimum must never exceed what the screen can show - self.setMinimumSize(min(820, avail.width() - margin), min(520, avail.height() - margin)) - self.resize(max(w, 1), max(h, 1)) - frame = self.frameGeometry() - frame.moveCenter(avail.center()) - self.move(frame.topLeft()) + if getattr(self, "help_agent", None) is not None: + self._update_dock_guard() + self.help_agent.reposition() + def moveEvent(self, event): # noqa: N802 - Qt override super().moveEvent(event) @@ -1230,49 +1198,18 @@ class MainWindow(QMainWindow): # the floating assistant re-pins and the panes re-decide if they fit. self._on_screen_maybe_changed() - def _on_screen_maybe_changed(self) -> None: - screen = self.screen() - if screen is getattr(self, "_last_screen", None): - return - self._last_screen = screen - avail = screen.availableGeometry() if screen else None - if avail is not None: - self.setMinimumSize(min(820, avail.width() - 60), - min(520, avail.height() - 60)) - if getattr(self, "help_agent", None) is not None: - self._update_dock_guard() - self.help_agent.reposition() # ---- lifecycle --------------------------------------------------- def closeEvent(self, event) -> None: # noqa: N802 - keep = (self.tray is not None - and self.ctx.config.data.get("tray", {}).get("minimize_on_close", True)) - if keep and not self._really_quit: - # Keep running in the background; tasks continue and autosave. + if self._life.should_keep_running(): + # Chạy nền tiếp: task vẫn chạy và vẫn tự lưu. event.ignore() self.hide() - try: - self.tray.showMessage( - DISPLAY_NAME, tr("app.tray.running_body"), - QSystemTrayIcon.Information, 4000) - except Exception: - pass + self._tray.show_message(DISPLAY_NAME, tr("app.tray.running_body"), msec=4000) return # Real quit: stop every running turn (a tab may have several), then close. - self.task_scheduler.stop() # also stops any scheduled tasks - if getattr(self, "routing_scheduler", None) is not None: - self.routing_scheduler.stop() - for tab in (self.cowork,): - for w in tab.active_workers(): - if w.isRunning(): - w.request_stop() - w.wait(1500) - # Safely stop codebase-memory UI if the method exists - if hasattr(self.structure, 'stop_cmem_ui'): - self.structure.stop_cmem_ui() - self.ctx.stop_mcp_connections() # never leave a connected MCP server subprocess behind - if self.tray is not None: - self.tray.hide() + self._life.shutdown() + self._tray.hide() super().closeEvent(event) diff --git a/presentation/shell/lifecycle_coordinator.py b/presentation/shell/lifecycle_coordinator.py new file mode 100644 index 0000000..a3ce20a --- /dev/null +++ b/presentation/shell/lifecycle_coordinator.py @@ -0,0 +1,110 @@ +"""Vòng đời cửa sổ chính — R08-T10. + +Bóc từ ``app.py::MainWindow``. Hai việc, đều không phải việc của giao diện: + +1. **Canh cửa sổ theo màn hình đang đứng.** Người dùng có hai màn khác độ phân + giải và khác tỉ lệ phóng; kéo cửa sổ sang màn kia là vùng làm việc đổi. Đây + là số học thuần, không đụng widget nào ngoài chính cửa sổ. +2. **Tắt cho sạch.** Dừng bộ lập lịch, dừng mọi lượt chạy còn dở, ngắt tiến + trình MCP. Thiếu một bước là để lại tiến trình con chạy mồ côi sau khi + người dùng đã thoát. + +Các hàm ``closeEvent``/``moveEvent``/``resizeEvent`` vẫn phải nằm ở lớp cửa sổ +— Qt gọi thẳng vào đó — nhưng phần quyết định thì ở đây. +""" +from __future__ import annotations + +from PySide6.QtGui import QGuiApplication + +#: Cửa sổ chiếm bao nhiêu phần màn hình khi mở lần đầu. +SCREEN_SHARE_W, SCREEN_SHARE_H = 0.80, 0.85 + +#: Chừa mép để cửa sổ không đụng thanh tác vụ. +MARGIN = 60 + +#: Kích thước tối thiểu mong muốn — vẫn phải nhỏ hơn màn hình thật. +MIN_W, MIN_H = 820, 520 + + +class LifecycleCoordinator: + def __init__(self, window): + self.window = window + self._last_screen = None + + # ---- canh theo màn hình ---------------------------------------------- + + def fit_to_screen(self, want_w: int, want_h: int) -> None: + w = self.window + screen = w.screen() or QGuiApplication.primaryScreen() + avail = screen.availableGeometry() if screen else None + if avail is None: + w.resize(want_w, want_h) + return + + # Lấy một phần màn hình: không bao giờ nhỏ hơn kích thước yêu cầu, cũng + # không bao giờ lớn hơn thứ màn hình hiển thị nổi. + width = min(max(want_w, int(avail.width() * SCREEN_SHARE_W)), + avail.width() - MARGIN) + height = min(max(want_h, int(avail.height() * SCREEN_SHARE_H)), + avail.height() - MARGIN) + self._apply_minimum(avail) + w.resize(max(width, 1), max(height, 1)) + + frame = w.frameGeometry() + frame.moveCenter(avail.center()) + w.move(frame.topLeft()) + + def screen_maybe_changed(self) -> bool: + """Gọi khi cửa sổ bị di chuyển. Trả True nếu đúng là đã đổi màn hình. + + Trả về bool để chỗ gọi biết có cần xếp lại mấy thứ nổi hay không — + kéo cửa sổ trong cùng một màn thì không cần làm gì cả. + """ + w = self.window + screen = w.screen() + if screen is self._last_screen: + return False + self._last_screen = screen + avail = screen.availableGeometry() if screen else None + if avail is not None: + self._apply_minimum(avail) + return True + + def _apply_minimum(self, avail) -> None: + # Kích thước tối thiểu không bao giờ được vượt quá thứ màn hình hiển + # thị nổi — nếu không thì cửa sổ không thu nhỏ vừa màn được nữa. + self.window.setMinimumSize(min(MIN_W, avail.width() - MARGIN), + min(MIN_H, avail.height() - MARGIN)) + + # ---- đóng và tắt ------------------------------------------------------ + + def should_keep_running(self) -> bool: + """Đóng cửa sổ có nghĩa là chạy nền tiếp, hay là thoát hẳn? + + Chạy nền tiếp chỉ khi có khay hệ thống để quay lại — không có khay mà + vẫn ẩn đi thì người dùng mất luôn đường vào app. + """ + w = self.window + if w.tray is None or w._really_quit: + return False + return bool(w.ctx.config.data.get("tray", {}).get("minimize_on_close", True)) + + def shutdown(self) -> None: + """Dừng mọi thứ đang chạy. Thứ tự có ý nghĩa: bộ lập lịch trước, để nó + không kịp khởi động thêm việc mới trong lúc ta đang dừng việc cũ.""" + w = self.window + w.task_scheduler.stop() # dừng luôn các task đã lên lịch + if getattr(w, "routing_scheduler", None) is not None: + w.routing_scheduler.stop() + + for tab in (w.cowork,): + for worker in tab.active_workers(): + if worker.isRunning(): + worker.request_stop() + worker.wait(1500) + + if hasattr(w.structure, "stop_cmem_ui"): + w.structure.stop_cmem_ui() + + # Không bao giờ để lại tiến trình MCP đã kết nối chạy mồ côi. + w.ctx.stop_mcp_connections() diff --git a/presentation/shell/tray_manager.py b/presentation/shell/tray_manager.py new file mode 100644 index 0000000..7640d09 --- /dev/null +++ b/presentation/shell/tray_manager.py @@ -0,0 +1,76 @@ +"""Biểu tượng khay hệ thống — R08-T10. + +Bóc từ ``app.py::MainWindow``. Giữ biểu tượng khay, menu chuột phải của nó, và +việc bắn thông báo bong bóng. + +Vì sao tách: khay là thứ **có thể không tồn tại**. Máy không có khay hệ thống +(một số môi trường Linux, phiên RDP) thì ``isSystemTrayAvailable()`` trả False +và mọi thứ ở đây phải im lặng chấp nhận. Trộn lẫn trong MainWindow thì mỗi chỗ +dùng đều phải tự nhớ kiểm ``if self.tray is not None`` — đã có 6 chỗ như thế. +Gói lại thì chỗ gọi cứ gọi, không có khay thì không có gì xảy ra. +""" +from __future__ import annotations + +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QMenu, QSystemTrayIcon + + +class TrayManager: + """Khay hệ thống của một cửa sổ. An toàn khi máy không có khay.""" + + def __init__(self, window, *, icon, tooltip: str, tr): + self.window = window + self._tr = tr + self.icon: QSystemTrayIcon | None = None + self._open_act: QAction | None = None + self._quit_act: QAction | None = None + self._tooltip = tooltip + self._app_icon = icon + + # ---- dựng ------------------------------------------------------------ + + def setup(self) -> None: + """Dựng biểu tượng khay. Không có khay thì lặng lẽ bỏ qua.""" + if not QSystemTrayIcon.isSystemTrayAvailable(): + return + w = self.window + self.icon = QSystemTrayIcon(self._app_icon(), w) + self.icon.setToolTip(self._tooltip) + + menu = QMenu() + self._open_act = QAction(self._tr("app.tray.open"), w) + self._open_act.triggered.connect(w._show_window) + self._quit_act = QAction(self._tr("app.tray.quit"), w) + self._quit_act.triggered.connect(w._quit_app) + menu.addAction(self._open_act) + menu.addAction(self._quit_act) + self.icon.setContextMenu(menu) + + self.icon.activated.connect( + lambda reason: w._show_window() if reason == QSystemTrayIcon.Trigger else None) + self.icon.show() + + def retranslate(self) -> None: + if self.icon is not None: + self.icon.setToolTip(self._tooltip) + if self._open_act is not None: + self._open_act.setText(self._tr("app.tray.open")) + self._quit_act.setText(self._tr("app.tray.quit")) + + def hide(self) -> None: + if self.icon is not None: + self.icon.hide() + + # ---- thông báo ------------------------------------------------------- + + def show_message(self, title: str, body: str, *, error: bool = False, + msec: int = 5000) -> None: + """Bắn bong bóng khay. Không có khay, hoặc hệ điều hành từ chối, thì + thôi — một thông báo không hiện được không đáng làm hỏng lượt chạy.""" + if self.icon is None: + return + kind = QSystemTrayIcon.Critical if error else QSystemTrayIcon.Information + try: + self.icon.showMessage(title, body, kind, msec) + except Exception: # noqa: BLE001 + pass