From c77ce36191b77f095c34117f462b2709cda42295 Mon Sep 17 00:00:00 2001 From: Nam Pham Dinh Thanh Date: Tue, 25 Aug 2026 19:56:37 +0900 Subject: [PATCH] =?UTF-8?q?refactor(shell):=20R08-T10=20=E2=80=94=20bootst?= =?UTF-8?q?rap=20+=20t=C3=A1ch=20TrayManager=20v=C3=A0=20LifecycleCoordina?= =?UTF-8?q?tor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.py 1356 -> 1293 dòng. presentation/shell/ có 3 file: bootstrap.py Composition Root (đã vào ở commit trước) tray_manager.py khay hệ thống + thông báo bong bóng lifecycle_coordinator.py canh cửa sổ theo màn hình + tắt cho sạch Vì sao tách khay: khay là thứ CÓ THỂ KHÔNG TỒN TẠI (một số môi trường Linux, phiên RDP). Trước đây mỗi chỗ dùng phải tự nhớ kiểm `if self.tray is not None` — có 6 chỗ như thế, và 3 chỗ còn phải tự bọc try/except quanh showMessage. Gói lại thì chỗ gọi cứ gọi, không có khay thì không có gì xảy ra. Vì sao tách vòng đời: hai việc trong đó không phải việc của giao diện. Canh cửa sổ theo màn hình là số học thuần (anh Nam có hai màn khác độ phân giải và khác tỉ lệ phóng — kéo qua lại là vùng làm việc đổi). Còn shutdown là thứ tự dừng có ý nghĩa: bộ lập lịch trước để nó không kịp khởi động việc mới trong lúc ta đang dừng việc cũ, rồi mới tới worker, rồi ngắt tiến trình MCP. closeEvent/moveEvent/resizeEvent vẫn ở lớp cửa sổ vì Qt gọi thẳng vào đó, nhưng phần quyết định đã chuyển đi. closeEvent từ 30 dòng còn 11. Giữ self.tray thành property trỏ vào self._tray.icon — vài chỗ còn đọc tên cũ. Đã lấy mốc trước khi bóc rồi so lại sau: 24/24 checker trong tools/ qua cả hai lần. Đây là bộ đặc tả thật cho MainWindow (check_nav, check_rail_align, check_layout_geometry, check_controls_alive... dựng cửa sổ thật offscreen trên BẢN SAO của ~/.cowork_local, scheduler bị vô hiệu hoá). 632 test xanh. CHƯA làm hết R08-T10: plan ghi tách thành main_window.py + tray_manager.py + lifecycle_coordinator.py. Hai file sau đã xong, main_window.py thì chưa — MainWindow vẫn nằm trong app.py và vẫn 1095 dòng. Đo lại thì khối lượng không nằm ở ba cụm plan nêu mà ở hai cụm khác: nav rail 18 method, ~340 dòng topbar 8 method, ~157 dòng __init__ 279 dòng Hai cụm đó dính chặt vào state của cửa sổ, chuyển đi cần đổi giao diện giữa chúng chứ không phải dời chỗ, nên tôi dừng ở đây thay vì làm nửa vời. Co-Authored-By: Claude Opus 5 --- app.py | 119 +++++--------------- presentation/shell/lifecycle_coordinator.py | 110 ++++++++++++++++++ presentation/shell/tray_manager.py | 76 +++++++++++++ 3 files changed, 214 insertions(+), 91 deletions(-) create mode 100644 presentation/shell/lifecycle_coordinator.py create mode 100644 presentation/shell/tray_manager.py 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