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
+51
View File
@@ -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"<b style='color:{ACCENT_MID}'>🖥 AR Display Manager</b><br/>"
f"<span style='color:{TEXT_MUTED};font-size:11px;'>"
"Gestiona cuál app aparece en cada monitor del J6412 "
"(hasta 4 pantallas simultáneas).</span>"
)
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))
+1
View File
@@ -0,0 +1 @@
"""AR Display Manager — multi-monitor app switcher for the Integrated Bridge System."""
+45
View File
@@ -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())
+45
View File
@@ -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}
+66
View File
@@ -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
+110
View File
@@ -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)
+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()
+277
View File
@@ -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()
+124
View File
@@ -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)
+110
View File
@@ -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()
+84
View File
@@ -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))
+25
View File
@@ -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())
+5
View File
@@ -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"