"""AR Display Manager orchestrator.""" from __future__ import annotations from pathlib import Path from PySide6.QtCore import QObject, QRect, QTimer from PySide6.QtGui import QIcon from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon from display_manager.config import DisplayManagerConfig, LayoutStore from display_manager.floating_button import FloatingButton from display_manager.process_manager import ProcessManager REPO_ROOT = Path(__file__).resolve().parents[1] _LOGO = REPO_ROOT / "display" / "assets" / "images" / "ar_logo_full.png" class DisplayManager(QObject): """Creates one FloatingButton per screen, manages app windows.""" def __init__(self, app: QApplication) -> None: super().__init__() self._app = app self._config = DisplayManagerConfig.load() self._layout = LayoutStore() self._proc_mgr = ProcessManager(self._config, self) self._buttons: dict[str, FloatingButton] = {} # serial → button # React to screen add/remove app.screenAdded.connect(self._on_screen_added) app.screenRemoved.connect(self._on_screen_removed) # Bootstrap buttons for screen in app.screens(): self._add_button(screen) # System tray self._tray = self._build_tray() self._tray.show() # Launch apps if self._config.autolaunch: # Slight delay so the GUI is visible first QTimer.singleShot(1_500, self._proc_mgr.launch_all) # Restore last layout after apps have had time to show windows QTimer.singleShot(8_000, self._restore_layout) # ---------------------------------------------------------------- Screens def _screen_serial(self, screen: object) -> str: """Unique identifier for a QScreen (name + geometry hash).""" from PySide6.QtGui import QScreen s: QScreen = screen # type: ignore[assignment] g = s.geometry() return f"{s.name()}_{g.x()}_{g.y()}_{g.width()}_{g.height()}" def _on_screen_added(self, screen: object) -> None: self._add_button(screen) def _on_screen_removed(self, screen: object) -> None: serial = self._screen_serial(screen) btn = self._buttons.pop(serial, None) if btn: btn.hide() btn.deleteLater() def _add_button(self, screen: object) -> None: from PySide6.QtGui import QScreen s: QScreen = screen # type: ignore[assignment] serial = self._screen_serial(s) if serial in self._buttons: return btn = FloatingButton( screen_serial=serial, screen_geometry=s.geometry(), config=self._config, on_app_select=self._on_app_select, get_current_app=self._layout.get, get_running_ids=self._proc_mgr.running_ids, ) self._buttons[serial] = btn # ---------------------------------------------------------------- App switching def _on_app_select(self, screen_serial: str, app_id: str) -> None: """User chose *app_id* for the screen identified by *screen_serial*.""" # Find the QScreen for this serial screen_geo: QRect | None = None for s in self._app.screens(): if self._screen_serial(s) == screen_serial: screen_geo = s.geometry() break if screen_geo is None: return # Launch if not running if not self._proc_mgr.is_running(app_id): self._proc_mgr.launch_if_configured(app_id) # Delay window placement until the app has time to start QTimer.singleShot( 4_000, lambda: self._bring_app(app_id, screen_geo), # type: ignore[arg-type] ) else: self._bring_app(app_id, screen_geo) self._layout.set(screen_serial, app_id) def _bring_app(self, app_id: str, geo: QRect) -> None: self._proc_mgr.bring_to_screen(app_id, geo.x(), geo.y(), geo.width(), geo.height()) def _restore_layout(self) -> None: """After startup, move apps to their last assigned screens.""" for s in self._app.screens(): serial = self._screen_serial(s) app_id = self._layout.get(serial) if app_id and self._proc_mgr.is_running(app_id): geo = s.geometry() self._bring_app(app_id, geo) # ---------------------------------------------------------------- Tray icon def _build_tray(self) -> QSystemTrayIcon: if _LOGO.exists(): icon = QIcon(str(_LOGO)) else: icon = QApplication.style().standardIcon( # type: ignore[union-attr] __import__("PySide6.QtWidgets", fromlist=["QStyle"]).QStyle.StandardPixmap.SP_ComputerIcon ) tray = QSystemTrayIcon(icon, self) tray.setToolTip("AR Display Manager") menu = QMenu() menu.addAction("AR Display Manager").setEnabled(False) menu.addSeparator() launch_menu = menu.addMenu("Launch app…") from display_manager.app_registry import APPS for app_meta in APPS: act = launch_menu.addAction(f"{app_meta.icon} {app_meta.name}") act.triggered.connect( lambda checked=False, aid=app_meta.id: self._proc_mgr.launch_if_configured(aid) ) menu.addSeparator() settings_act = menu.addAction("⚙ Settings…") settings_act.triggered.connect(self._open_settings) menu.addSeparator() quit_act = menu.addAction("Quit") quit_act.triggered.connect(self._quit) tray.setContextMenu(menu) tray.activated.connect(self._on_tray_activated) return tray def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: if reason == QSystemTrayIcon.ActivationReason.DoubleClick: # Show/hide all floating buttons any_visible = any(b.isVisible() for b in self._buttons.values()) for btn in self._buttons.values(): btn.setVisible(not any_visible) def _open_settings(self) -> None: from display_manager.settings_dialog import SettingsDialog dlg = SettingsDialog(self._config) if dlg.exec(): # Reposition buttons if button_position changed for s in self._app.screens(): serial = self._screen_serial(s) btn = self._buttons.get(serial) if btn: btn.update_screen(s.geometry()) def _quit(self) -> None: self._app.quit()