feat: AR Display Manager — multi-monitor floating app switcher

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
This commit is contained in:
2026-05-24 21:48:27 -04:00
parent b68cd64cf1
commit 42b2eec2e1
13 changed files with 1120 additions and 0 deletions
+177
View File
@@ -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()