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"