From 42b2eec2e1a2d9676f212004f37e759b0744c295 Mon Sep 17 00:00:00 2001 From: alro1965 Date: Sun, 24 May 2026 21:48:27 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20AR=20Display=20Manager=20=E2=80=94=20mu?= =?UTF-8?q?lti-monitor=20floating=20app=20switcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the AR Display Manager daemon (Task #9): - display_manager/ package with 8 modules: - app_registry.py : static metadata for the 4 bridge apps - config.py : JSON-persisted config + per-screen layout store - win32_utils.py : ctypes wrappers (EnumWindows, SetWindowPos, ShowWindow) - process_manager.py: launch + track app processes, HWND lookup - floating_button.py: always-on-top 52Γ—52 px AR button per monitor with draggable placement + custom-painted popup - display_manager.py: orchestrator (QScreens β†’ buttons β†’ app placement + system tray with Settings/Launch/Quit) - settings_dialog.py: modal dialog for exe paths, button position, and Windows autostart toggle - autostart.py : HKCU Run registry read/write helpers - display_manager_main.py at repo root: launcher script - Studio Overview tab: "Lanzar Display Manager" button - pyproject.toml: display-manager optional dependency group (PySide6) Layout persistence: ~/.ar-autopilot/display_manager/layout.json Config: ~/.ar-autopilot/display_manager/config.json Supports up to 4 monitors (2 HDMI native + 2 USB DisplayLink). Double-click tray icon to toggle button visibility. AR_electronics β€” AR-Autopilot Project --- arautopilot/studio/main_window.py | 51 ++++++ display_manager/__init__.py | 1 + display_manager/app.py | 45 +++++ display_manager/app_registry.py | 45 +++++ display_manager/autostart.py | 66 +++++++ display_manager/config.py | 110 ++++++++++++ display_manager/display_manager.py | 177 ++++++++++++++++++ display_manager/floating_button.py | 277 +++++++++++++++++++++++++++++ display_manager/process_manager.py | 124 +++++++++++++ display_manager/settings_dialog.py | 110 ++++++++++++ display_manager/win32_utils.py | 84 +++++++++ display_manager_main.py | 25 +++ pyproject.toml | 5 + 13 files changed, 1120 insertions(+) create mode 100644 display_manager/__init__.py create mode 100644 display_manager/app.py create mode 100644 display_manager/app_registry.py create mode 100644 display_manager/autostart.py create mode 100644 display_manager/config.py create mode 100644 display_manager/display_manager.py create mode 100644 display_manager/floating_button.py create mode 100644 display_manager/process_manager.py create mode 100644 display_manager/settings_dialog.py create mode 100644 display_manager/win32_utils.py create mode 100644 display_manager_main.py diff --git a/arautopilot/studio/main_window.py b/arautopilot/studio/main_window.py index 48258b9..7c607ac 100644 --- a/arautopilot/studio/main_window.py +++ b/arautopilot/studio/main_window.py @@ -18,9 +18,12 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QFont, QPixmap from PySide6.QtWidgets import ( QFrame, + QHBoxLayout, QLabel, QListWidget, QMainWindow, + QMessageBox, + QPushButton, QSplitter, QStatusBar, QTabWidget, @@ -193,6 +196,30 @@ class StudioMainWindow(QMainWindow): guide.setTextFormat(Qt.TextFormat.RichText) guide.setWordWrap(True) layout.addWidget(guide) + + # AR Display Manager quick launch + sep2 = QFrame() + sep2.setFrameShape(QFrame.Shape.HLine) + sep2.setStyleSheet(f"color:{ACCENT_MID}; margin: 10px 0;") + layout.addWidget(sep2) + + dm_row = QHBoxLayout() + dm_label = QLabel( + f"πŸ–₯ AR Display Manager
" + f"" + "Gestiona cuΓ‘l app aparece en cada monitor del J6412 " + "(hasta 4 pantallas simultΓ‘neas)." + ) + dm_label.setTextFormat(Qt.TextFormat.RichText) + dm_label.setWordWrap(True) + dm_row.addWidget(dm_label, stretch=1) + + dm_btn = QPushButton("Lanzar Display Manager") + dm_btn.setMinimumWidth(200) + dm_btn.clicked.connect(self._launch_display_manager) + dm_row.addWidget(dm_btn) + layout.addLayout(dm_row) + layout.addStretch(1) footer = QLabel( @@ -204,3 +231,27 @@ class StudioMainWindow(QMainWindow): layout.addWidget(footer) return w + + # ── Display Manager launcher ────────────────────────────────────────────── + + def _launch_display_manager(self) -> None: + import subprocess + import sys as _sys + launcher = REPO_ROOT / "display_manager_main.py" + if not launcher.exists(): + QMessageBox.warning( + self, + "Not found", + f"display_manager_main.py not found at:\n{launcher}", + ) + return + try: + subprocess.Popen( + [_sys.executable, str(launcher)], + creationflags=( + subprocess.CREATE_NEW_PROCESS_GROUP + if _sys.platform == "win32" else 0 + ), + ) + except Exception as exc: + QMessageBox.critical(self, "Launch failed", str(exc)) diff --git a/display_manager/__init__.py b/display_manager/__init__.py new file mode 100644 index 0000000..a4921e5 --- /dev/null +++ b/display_manager/__init__.py @@ -0,0 +1 @@ +"""AR Display Manager β€” multi-monitor app switcher for the Integrated Bridge System.""" diff --git a/display_manager/app.py b/display_manager/app.py new file mode 100644 index 0000000..dcbb8f9 --- /dev/null +++ b/display_manager/app.py @@ -0,0 +1,45 @@ +"""AR Display Manager entry point.""" +from __future__ import annotations + +import signal +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def run(argv: list[str] | None = None) -> int: + """Launch the AR Display Manager daemon.""" + try: + from PySide6.QtWidgets import QApplication + except ImportError: + sys.stderr.write( + "PySide6 is not installed. Run:\n\n" + " pip install PySide6\n" + ) + return 2 + + signal.signal(signal.SIGINT, signal.SIG_DFL) # Ctrl+C kills the process + + app = QApplication(argv if argv is not None else sys.argv) + app.setApplicationName("AR Display Manager") + app.setQuitOnLastWindowClosed(False) # keep running when popup closes + + # Brand icon + from PySide6.QtGui import QIcon + _logo = REPO_ROOT / "display" / "assets" / "images" / "ar_logo_full.png" + if _logo.exists(): + app.setWindowIcon(QIcon(str(_logo))) + + # Apply brand stylesheet + from arautopilot.studio.ar_style import apply_ar_style + apply_ar_style(app) + + from display_manager.display_manager import DisplayManager + _mgr = DisplayManager(app) # noqa: F841 β€” kept alive via QObject parent=None + + return app.exec() + + +if __name__ == "__main__": + sys.exit(run()) diff --git a/display_manager/app_registry.py b/display_manager/app_registry.py new file mode 100644 index 0000000..0621610 --- /dev/null +++ b/display_manager/app_registry.py @@ -0,0 +1,45 @@ +"""Static metadata for the four AR Bridge applications.""" +from __future__ import annotations +from dataclasses import dataclass + +@dataclass(frozen=True) +class AppMeta: + id: str + name: str + icon: str # emoji used in the button popup + color: str # hex accent colour for the card + description: str + +# Ordered list β€” shown in this order in the popup +APPS: list[AppMeta] = [ + AppMeta( + id="autopilot", + name="AR-Autopilot", + icon="β›΅", + color="#2563EB", + description="Autopilot control display", + ), + AppMeta( + id="ecdis", + name="AR-ECDIS", + icon="πŸ—Ί", + color="#22C55E", + description="Electronic chart display", + ), + AppMeta( + id="compass", + name="Compass", + icon="🧭", + color="#F59E0B", + description="Ship motion & compass", + ), + AppMeta( + id="gps", + name="GPS Navigator", + icon="πŸ“", + color="#8B5CF6", + description="GPS navigation display", + ), +] + +APP_BY_ID: dict[str, AppMeta] = {a.id: a for a in APPS} diff --git a/display_manager/autostart.py b/display_manager/autostart.py new file mode 100644 index 0000000..97aa5ea --- /dev/null +++ b/display_manager/autostart.py @@ -0,0 +1,66 @@ +"""Register / unregister AR Display Manager as a Windows autostart entry.""" +from __future__ import annotations + +import sys +from pathlib import Path + +_REG_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" +_APP_NAME = "AR Display Manager" + + +def _pythonw() -> str: + """Return the pythonw.exe path (no console window on start).""" + exe = Path(sys.executable) + pw = exe.parent / "pythonw.exe" + return str(pw) if pw.exists() else sys.executable + + +def enable() -> bool: + """Add the autostart registry entry. Returns True on success.""" + if sys.platform != "win32": + return False + try: + import winreg # type: ignore[import-not-found] + launcher = Path(__file__).resolve().parents[1] / "display_manager_main.py" + cmd = f'"{_pythonw()}" "{launcher}"' + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, _REG_KEY, 0, winreg.KEY_SET_VALUE + ) as key: + winreg.SetValueEx(key, _APP_NAME, 0, winreg.REG_SZ, cmd) + return True + except Exception: + return False + + +def disable() -> bool: + """Remove the autostart registry entry. Returns True if it existed.""" + if sys.platform != "win32": + return False + try: + import winreg # type: ignore[import-not-found] + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, _REG_KEY, 0, winreg.KEY_SET_VALUE + ) as key: + winreg.DeleteValue(key, _APP_NAME) + return True + except FileNotFoundError: + return False + except Exception: + return False + + +def is_enabled() -> bool: + """Return True if the autostart entry exists.""" + if sys.platform != "win32": + return False + try: + import winreg # type: ignore[import-not-found] + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, _REG_KEY, 0, winreg.KEY_READ + ) as key: + winreg.QueryValueEx(key, _APP_NAME) + return True + except FileNotFoundError: + return False + except Exception: + return False diff --git a/display_manager/config.py b/display_manager/config.py new file mode 100644 index 0000000..bbdc640 --- /dev/null +++ b/display_manager/config.py @@ -0,0 +1,110 @@ +"""Configuration and layout persistence for AR Display Manager.""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +CONFIG_DIR = Path.home() / ".ar-autopilot" / "display_manager" +CONFIG_FILE = CONFIG_DIR / "config.json" +LAYOUT_FILE = CONFIG_DIR / "layout.json" + + +@dataclass +class AppExeConfig: + """Path + launch args for one application.""" + exe: str = "" + args: list[str] = field(default_factory=list) + title_hint: str = "" # substring to match window title when searching + + +_DEFAULT_EXES: dict[str, AppExeConfig] = { + "autopilot": AppExeConfig( + exe=str( + REPO_ROOT / "display" / "build" / "windows" / "x64" / "runner" + / "Release" / "display.exe" + ), + title_hint="AR Autopilot", + ), + "ecdis": AppExeConfig(exe="", title_hint="AR-ECDIS"), + "compass": AppExeConfig(exe="", title_hint="Compass"), + "gps": AppExeConfig(exe="", title_hint="GPS Navigator"), +} + + +@dataclass +class DisplayManagerConfig: + apps: dict[str, AppExeConfig] = field(default_factory=dict) + button_position: str = "top-right" # top-right | top-left | bottom-right | bottom-left + button_margin: int = 12 # px from screen edge + autolaunch: bool = True # launch apps on startup if exe exists + + # ------------------------------------------------------------------ I/O + @classmethod + def load(cls) -> "DisplayManagerConfig": + cfg = cls(apps=dict(_DEFAULT_EXES)) + if not CONFIG_FILE.exists(): + return cfg + try: + with open(CONFIG_FILE, encoding="utf-8") as f: + data = json.load(f) + cfg.button_position = data.get("button_position", cfg.button_position) + cfg.button_margin = int(data.get("button_margin", cfg.button_margin)) + cfg.autolaunch = bool(data.get("autolaunch", cfg.autolaunch)) + for app_id, raw in data.get("apps", {}).items(): + cfg.apps[app_id] = AppExeConfig( + exe=raw.get("exe", ""), + args=raw.get("args", []), + title_hint=raw.get("title_hint", ""), + ) + except Exception: + pass + return cfg + + def save(self) -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump( + { + "button_position": self.button_position, + "button_margin": self.button_margin, + "autolaunch": self.autolaunch, + "apps": { + k: {"exe": v.exe, "args": v.args, "title_hint": v.title_hint} + for k, v in self.apps.items() + }, + }, + f, + indent=2, + ) + + +# --------------------------------------------------------------------------- Layout + +class LayoutStore: + """Persists {screen_serial β†’ app_id} so layout survives restarts.""" + + def __init__(self) -> None: + self._data: dict[str, str] = {} + self._load() + + def _load(self) -> None: + if LAYOUT_FILE.exists(): + try: + with open(LAYOUT_FILE, encoding="utf-8") as f: + self._data = json.load(f) + except Exception: + self._data = {} + + def get(self, screen_serial: str) -> str | None: + return self._data.get(screen_serial) + + def set(self, screen_serial: str, app_id: str) -> None: + self._data[screen_serial] = app_id + self._save() + + def _save(self) -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with open(LAYOUT_FILE, "w", encoding="utf-8") as f: + json.dump(self._data, f, indent=2) diff --git a/display_manager/display_manager.py b/display_manager/display_manager.py new file mode 100644 index 0000000..5e34b41 --- /dev/null +++ b/display_manager/display_manager.py @@ -0,0 +1,177 @@ +"""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() diff --git a/display_manager/floating_button.py b/display_manager/floating_button.py new file mode 100644 index 0000000..6dd0a8f --- /dev/null +++ b/display_manager/floating_button.py @@ -0,0 +1,277 @@ +"""Always-on-top floating AR button β€” one per monitor.""" +from __future__ import annotations + +from typing import Callable + +from PySide6.QtCore import ( + QPoint, QRect, QSize, Qt, +) +from PySide6.QtGui import ( + QColor, QFont, QMouseEvent, QPainter, QPainterPath, +) +from PySide6.QtWidgets import QWidget + +from display_manager.app_registry import APP_BY_ID, APPS +from display_manager.config import DisplayManagerConfig + +_BTN_SIZE = 52 # button width and height, px +_BTN_RADIUS = 14 # corner radius +_POPUP_W = 260 +_POPUP_ITEM_H = 64 + + +class AppSwitcherPopup(QWidget): + """Frameless popup that lists the four apps; appears near the floating button.""" + + def __init__( + self, + current_app_id: str | None, + running_ids: set[str], + on_select: Callable[[str], None], + parent: QWidget | None = None, + ) -> None: + super().__init__( + parent, + Qt.WindowType.Tool + | Qt.WindowType.FramelessWindowHint + | Qt.WindowType.WindowStaysOnTopHint, + ) + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + self._current = current_app_id + self._running = running_ids + self._on_select = on_select + self._hovered: str | None = None + self.setFixedWidth(_POPUP_W) + self.setFixedHeight(len(APPS) * _POPUP_ITEM_H + 16) + self.setMouseTracking(True) + + def paintEvent(self, _event: object) -> None: # type: ignore[override] + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Background + bg = QColor("#0D1B2A") + bg.setAlpha(240) + path = QPainterPath() + path.addRoundedRect(0, 0, self.width(), self.height(), 12, 12) + p.fillPath(path, bg) + p.setPen(QColor("#2563EB")) + p.drawPath(path) + + y = 8 + for app in APPS: + rect = QRect(8, y, _POPUP_W - 16, _POPUP_ITEM_H - 4) + + # Card background + is_current = app.id == self._current + is_hovered = app.id == self._hovered + is_running = app.id in self._running + + card_bg = QColor(app.color) + if is_current: + card_bg.setAlpha(80) + elif is_hovered: + card_bg.setAlpha(50) + else: + card_bg.setAlpha(25) + + card_path = QPainterPath() + card_path.addRoundedRect(rect, 8, 8) + p.fillPath(card_path, card_bg) + if is_current or is_hovered: + p.setPen(QColor(app.color)) + p.drawPath(card_path) + + # Icon + icon_font = QFont("Segoe UI Emoji", 20) + p.setFont(icon_font) + p.setPen(QColor("#E2E8F0")) + p.drawText(QRect(rect.x() + 8, rect.y(), 44, rect.height()), Qt.AlignmentFlag.AlignVCenter, app.icon) + + # Name + description + name_font = QFont("Segoe UI", 11, QFont.Weight.Bold) + p.setFont(name_font) + p.setPen(QColor("#E2E8F0") if is_running else QColor("#8899AA")) + p.drawText(QRect(rect.x() + 58, rect.y() + 6, rect.width() - 66, 20), Qt.AlignmentFlag.AlignLeft, app.name) + + desc_font = QFont("Segoe UI", 9) + p.setFont(desc_font) + p.setPen(QColor("#8899AA")) + p.drawText(QRect(rect.x() + 58, rect.y() + 28, rect.width() - 66, 18), Qt.AlignmentFlag.AlignLeft, app.description) + + # Running indicator dot + if is_running: + dot_x = rect.right() - 10 + dot_y = rect.y() + rect.height() // 2 - 4 + p.setBrush(QColor("#22C55E")) + p.setPen(Qt.PenStyle.NoPen) + p.drawEllipse(dot_x, dot_y, 8, 8) + + y += _POPUP_ITEM_H + + def mouseMoveEvent(self, event: QMouseEvent) -> None: + self._hovered = self._app_at(event.position().y()) + self.update() + + def mousePressEvent(self, event: QMouseEvent) -> None: + app_id = self._app_at(event.position().y()) + if app_id: + self._on_select(app_id) + self.close() + + def leaveEvent(self, _event: object) -> None: + self._hovered = None + self.update() + + def _app_at(self, y: float) -> str | None: + idx = int((y - 8) // _POPUP_ITEM_H) + if 0 <= idx < len(APPS): + return APPS[idx].id + return None + + def focusOutEvent(self, _event: object) -> None: # type: ignore[override] + self.close() + + +class FloatingButton(QWidget): + """Small always-on-top AR button anchored to one monitor.""" + + def __init__( + self, + screen_serial: str, + screen_geometry: QRect, + config: DisplayManagerConfig, + on_app_select: Callable[[str, str], None], # (screen_serial, app_id) + get_current_app: Callable[[str], str | None], + get_running_ids: Callable[[], set[str]], + parent: QWidget | None = None, + ) -> None: + super().__init__( + parent, + Qt.WindowType.Tool + | Qt.WindowType.FramelessWindowHint + | Qt.WindowType.WindowStaysOnTopHint, + ) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False) + self.setFixedSize(QSize(_BTN_SIZE, _BTN_SIZE)) + + self._serial = screen_serial + self._geo = screen_geometry + self._config = config + self._on_app_select = on_app_select + self._get_current = get_current_app + self._get_running = get_running_ids + self._popup: AppSwitcherPopup | None = None + self._drag_start: QPoint | None = None + self._dragging = False + + self._reposition() + self.show() + + # ---------------------------------------------------------------- Position + + def _reposition(self) -> None: + pos = self._config.button_position + m = self._config.button_margin + g = self._geo + if pos == "top-left": + x, y = g.left() + m, g.top() + m + elif pos == "bottom-left": + x, y = g.left() + m, g.bottom() - _BTN_SIZE - m + elif pos == "bottom-right": + x, y = g.right() - _BTN_SIZE - m, g.bottom() - _BTN_SIZE - m + else: # top-right (default) + x, y = g.right() - _BTN_SIZE - m, g.top() + m + self.move(x, y) + + def update_screen(self, geo: QRect) -> None: + self._geo = geo + self._reposition() + + # ---------------------------------------------------------------- Paint + + def paintEvent(self, _event: object) -> None: # type: ignore[override] + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Glow ring for current app colour + current_id = self._get_current(self._serial) + if current_id and current_id in APP_BY_ID: + ring_col = QColor(APP_BY_ID[current_id].color) + ring_col.setAlpha(120) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(ring_col) + p.drawRoundedRect(0, 0, _BTN_SIZE, _BTN_SIZE, _BTN_RADIUS + 2, _BTN_RADIUS + 2) + + # Button background + bg = QColor("#0D1B2A") + bg.setAlpha(220) + p.setBrush(bg) + border_col = QColor("#2563EB") + border_col.setAlpha(200) + p.setPen(border_col) + p.drawRoundedRect(2, 2, _BTN_SIZE - 4, _BTN_SIZE - 4, _BTN_RADIUS, _BTN_RADIUS) + + # "AR" text + font = QFont("Segoe UI", 13, QFont.Weight.Bold) + p.setFont(font) + p.setPen(QColor("#60B8FF")) + p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "AR") + + # ---------------------------------------------------------------- Interaction + + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self._drag_start = event.globalPosition().toPoint() + self._dragging = False + + def mouseMoveEvent(self, event: QMouseEvent) -> None: + if self._drag_start is not None: + delta = event.globalPosition().toPoint() - self._drag_start + if delta.manhattanLength() > 5: + self._dragging = True + new_pos = self.pos() + delta + self.move(new_pos) + self._drag_start = event.globalPosition().toPoint() + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.MouseButton.LeftButton and not self._dragging: + self._show_popup() + self._drag_start = None + self._dragging = False + + # ---------------------------------------------------------------- Popup + + def _show_popup(self) -> None: + if self._popup and not self._popup.isHidden(): + self._popup.close() + self._popup = None + return + + current = self._get_current(self._serial) + running = self._get_running() + + def on_select(app_id: str) -> None: + self._on_app_select(self._serial, app_id) + self.update() + + popup = AppSwitcherPopup( + current_app_id=current, + running_ids=running, + on_select=on_select, + ) + self._popup = popup + + # Position popup near button (prefer below, flip if near bottom) + btn_global = self.mapToGlobal(QPoint(0, 0)) + px = btn_global.x() - _POPUP_W + _BTN_SIZE + py = btn_global.y() + _BTN_SIZE + 4 + geo = self._geo + if py + popup.height() > geo.bottom(): + py = btn_global.y() - popup.height() - 4 + if px < geo.left(): + px = geo.left() + 4 + popup.move(px, py) + popup.show() + popup.setFocus() diff --git a/display_manager/process_manager.py b/display_manager/process_manager.py new file mode 100644 index 0000000..892e032 --- /dev/null +++ b/display_manager/process_manager.py @@ -0,0 +1,124 @@ +"""Launch and track the four AR Bridge applications.""" +from __future__ import annotations + +import subprocess +import sys +import time +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import QObject, QTimer, Signal + +from display_manager.config import AppExeConfig, DisplayManagerConfig +from display_manager import win32_utils + + +class AppHandle: + """Tracks one running application.""" + + def __init__(self, app_id: str, proc: subprocess.Popen[bytes]) -> None: + self.app_id = app_id + self.proc = proc + self.hwnd: Optional[int] = None + self._hwnd_found_at: float = 0.0 + + @property + def pid(self) -> int: + return self.proc.pid + + def find_hwnd(self, title_hint: str = "") -> int | None: + """Try to locate the main window HWND (may not be available immediately).""" + hwnd = win32_utils.find_window_by_pid(self.pid) + if hwnd is None and title_hint: + hwnd = win32_utils.find_window_by_title_hint(title_hint) + if hwnd is not None: + self.hwnd = hwnd + self._hwnd_found_at = time.monotonic() + return hwnd + + def is_running(self) -> bool: + return self.proc.poll() is None + + def is_window_alive(self) -> bool: + return self.hwnd is not None and win32_utils.is_window_alive(self.hwnd) + + +class ProcessManager(QObject): + """Manages the lifecycle of all registered apps. + + Emits ``app_state_changed`` whenever an app starts or stops. + """ + + app_state_changed = Signal(str, bool) # (app_id, is_running) + + _POLL_MS = 3_000 # check process health every 3 s + + def __init__(self, config: DisplayManagerConfig, parent: QObject | None = None) -> None: + super().__init__(parent) + self._config = config + self._handles: dict[str, AppHandle] = {} + + self._timer = QTimer(self) + self._timer.timeout.connect(self._poll) + self._timer.start(self._POLL_MS) + + # ---------------------------------------------------------------- Public + + def launch_if_configured(self, app_id: str) -> bool: + """Launch *app_id* if it has a valid exe and is not already running.""" + if app_id in self._handles and self._handles[app_id].is_running(): + return True + exe_cfg = self._config.apps.get(app_id) + if not exe_cfg or not exe_cfg.exe or not Path(exe_cfg.exe).exists(): + return False + return self._launch(app_id, exe_cfg) + + def launch_all(self) -> None: + """Attempt to launch every configured app.""" + for app_id in self._config.apps: + self.launch_if_configured(app_id) + + def bring_to_screen(self, app_id: str, x: int, y: int, w: int, h: int) -> bool: + """Move *app_id*'s window to the given screen rect. Returns False if not running.""" + handle = self._handles.get(app_id) + if handle is None or not handle.is_running(): + return False + title_hint = self._config.apps.get(app_id, AppExeConfig()).title_hint + hwnd = handle.find_hwnd(title_hint) + if hwnd is None: + return False + win32_utils.move_and_maximize(hwnd, x, y, w, h) + return True + + def is_running(self, app_id: str) -> bool: + h = self._handles.get(app_id) + return h is not None and h.is_running() + + def running_ids(self) -> set[str]: + return {aid for aid, h in self._handles.items() if h.is_running()} + + # ---------------------------------------------------------------- Private + + def _launch(self, app_id: str, exe_cfg: AppExeConfig) -> bool: + try: + cmd = [exe_cfg.exe] + exe_cfg.args + proc = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=( + subprocess.CREATE_NEW_PROCESS_GROUP + if sys.platform == "win32" else 0 + ), + ) + self._handles[app_id] = AppHandle(app_id, proc) + self.app_state_changed.emit(app_id, True) + return True + except Exception: + return False + + def _poll(self) -> None: + for app_id, handle in list(self._handles.items()): + if not handle.is_running(): + del self._handles[app_id] + self.app_state_changed.emit(app_id, False) diff --git a/display_manager/settings_dialog.py b/display_manager/settings_dialog.py new file mode 100644 index 0000000..7c8ed96 --- /dev/null +++ b/display_manager/settings_dialog.py @@ -0,0 +1,110 @@ +"""Settings dialog for AR Display Manager β€” configure exe paths and button position.""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + +from display_manager.app_registry import APPS +from display_manager.autostart import disable as autostart_disable +from display_manager.autostart import enable as autostart_enable +from display_manager.autostart import is_enabled as autostart_is_enabled +from display_manager.config import AppExeConfig, DisplayManagerConfig + + +class SettingsDialog(QDialog): + """Modal settings editor for the Display Manager.""" + + def __init__(self, config: DisplayManagerConfig, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle("AR Display Manager β€” Settings") + self.setMinimumWidth(520) + self._config = config + self._fields: dict[str, QLineEdit] = {} + self._build_ui() + + def _build_ui(self) -> None: + layout = QVBoxLayout(self) + + # Button position + pos_group = QGroupBox("Floating button") + pos_form = QFormLayout(pos_group) + self._pos_combo = QComboBox() + for label, val in [ + ("Top-right (default)", "top-right"), + ("Top-left", "top-left"), + ("Bottom-right", "bottom-right"), + ("Bottom-left", "bottom-left"), + ]: + self._pos_combo.addItem(label, userData=val) + idx = self._pos_combo.findData(self._config.button_position) + if idx >= 0: + self._pos_combo.setCurrentIndex(idx) + pos_form.addRow("Position on each monitor:", self._pos_combo) + layout.addWidget(pos_group) + + # App executables + apps_group = QGroupBox("Application executables") + apps_form = QFormLayout(apps_group) + for app_meta in APPS: + exe_cfg = self._config.apps.get(app_meta.id, AppExeConfig()) + row = QHBoxLayout() + field = QLineEdit(exe_cfg.exe) + field.setPlaceholderText("(not configured β€” click Browse)") + self._fields[app_meta.id] = field + row.addWidget(field, stretch=1) + browse = QPushButton("Browse…") + browse.clicked.connect(lambda checked=False, f=field: self._browse(f)) + row.addWidget(browse) + apps_form.addRow(f"{app_meta.icon} {app_meta.name}:", row) + layout.addWidget(apps_group) + + # Autostart + sys_group = QGroupBox("System") + sys_form = QFormLayout(sys_group) + self._autostart_chk = QCheckBox("Start AR Display Manager with Windows") + self._autostart_chk.setChecked(autostart_is_enabled()) + sys_form.addRow(self._autostart_chk) + layout.addWidget(sys_group) + + # Buttons + btns = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + btns.accepted.connect(self._accept) + btns.rejected.connect(self.reject) + layout.addWidget(btns) + + def _browse(self, field: QLineEdit) -> None: + path, _ = QFileDialog.getOpenFileName( + self, "Select executable", str(Path.home()), "Executables (*.exe);;All files (*)" + ) + if path: + field.setText(path) + + def _accept(self) -> None: + self._config.button_position = self._pos_combo.currentData() + for app_meta in APPS: + if app_meta.id not in self._config.apps: + self._config.apps[app_meta.id] = AppExeConfig() + self._config.apps[app_meta.id].exe = self._fields[app_meta.id].text() + self._config.save() + # Autostart + if self._autostart_chk.isChecked(): + autostart_enable() + else: + autostart_disable() + self.accept() diff --git a/display_manager/win32_utils.py b/display_manager/win32_utils.py new file mode 100644 index 0000000..16b5c8a --- /dev/null +++ b/display_manager/win32_utils.py @@ -0,0 +1,84 @@ +"""Windows API helpers for window management (ctypes, no extra dependencies).""" +from __future__ import annotations + +import ctypes +import ctypes.wintypes as wt +import sys + +if sys.platform != "win32": + # Stub for non-Windows development/testing + def find_window_by_pid(pid: int) -> int | None: + return None + + def move_and_maximize(hwnd: int, x: int, y: int, w: int, h: int) -> None: + pass + + def is_window_alive(hwnd: int) -> bool: + return False + +else: + _user32 = ctypes.windll.user32 # type: ignore[attr-defined] + + # Constants + _HWND_TOP = 0 + _SW_RESTORE = 9 + _SW_MAXIMIZE = 3 + _SWP_SHOWWINDOW = 0x0040 + _SWP_FRAMECHANGED = 0x0020 + _GW_OWNER = 4 + + # Callback type for EnumWindows + _WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, wt.HWND, wt.LPARAM) + + def find_window_by_pid(pid: int) -> int | None: + """Return the first visible top-level HWND belonging to *pid*.""" + found: list[int] = [] + + def _cb(hwnd: int, _: int) -> bool: + if not _user32.IsWindowVisible(hwnd): + return True + # Skip windows with an owner (child dialogs, etc.) + if _user32.GetWindow(hwnd, _GW_OWNER): + return True + lp_pid = wt.DWORD() + _user32.GetWindowThreadProcessId(hwnd, ctypes.byref(lp_pid)) + if lp_pid.value == pid: + found.append(hwnd) + return False # stop enumeration + return True + + _user32.EnumWindows(_WNDENUMPROC(_cb), 0) + return found[0] if found else None + + def find_window_by_title_hint(hint: str) -> int | None: + """Return the first visible top-level HWND whose title contains *hint*.""" + if not hint: + return None + found: list[int] = [] + + def _cb(hwnd: int, _: int) -> bool: + if not _user32.IsWindowVisible(hwnd): + return True + buf = ctypes.create_unicode_buffer(512) + _user32.GetWindowTextW(hwnd, buf, 512) + if hint.lower() in buf.value.lower(): + found.append(hwnd) + return False + return True + + _user32.EnumWindows(_WNDENUMPROC(_cb), 0) + return found[0] if found else None + + def move_and_maximize(hwnd: int, x: int, y: int, w: int, h: int) -> None: + """Move a window to (x, y, w, h) then maximize it on that monitor.""" + _user32.ShowWindow(hwnd, _SW_RESTORE) + _user32.SetWindowPos( + hwnd, _HWND_TOP, + x, y, w, h, + _SWP_SHOWWINDOW | _SWP_FRAMECHANGED, + ) + _user32.ShowWindow(hwnd, _SW_MAXIMIZE) + _user32.SetForegroundWindow(hwnd) + + def is_window_alive(hwnd: int) -> bool: + return bool(_user32.IsWindow(hwnd)) diff --git a/display_manager_main.py b/display_manager_main.py new file mode 100644 index 0000000..c816bf6 --- /dev/null +++ b/display_manager_main.py @@ -0,0 +1,25 @@ +"""AR Display Manager β€” repo-root launcher. + +Usage: + python display_manager_main.py + python -m display_manager.app + +The daemon detects all connected monitors and places a small floating +AR button in the corner of each. Clicking the button opens the app +switcher: choose which of the four AR Bridge apps you want visible on +that monitor. The chosen app's window is moved and maximized there; +the others continue running in the background. + +Layout (which app is on which screen) is persisted in +~/.ar-autopilot/display_manager/layout.json so the arrangement +survives restarts. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from display_manager.app import run + +if __name__ == "__main__": + sys.exit(run()) diff --git a/pyproject.toml b/pyproject.toml index 8840f88..62edd13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,11 @@ license-server = [ "pydantic>=2.7", "python-dotenv>=1.0", ] +# AR Display Manager β€” multi-monitor app switcher for the Integrated Bridge System. +# Same PySide6 dep as the Studio; listed separately so it can be installed standalone. +display-manager = [ + "PySide6>=6.6", +] [project.urls] Homepage = "https://github.com/alro65/AR-Autopilot"