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:
@@ -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))
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""AR Display Manager — multi-monitor app switcher for the Integrated Bridge System."""
|
||||
@@ -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())
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user