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:
@@ -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()
|
||||
Reference in New Issue
Block a user