Files
AR-Autopilot/display_manager/display_manager.py
T
alro65 46dc0423a0 fix(display-manager): lazy launch by default + minimize unassigned apps
- autolaunch default: True → False (on-demand only, saves GPU on startup)
- add ProcessManager.minimize() to minimize a window to taskbar
- add win32_utils.minimize_window() (SW_MINIMIZE via user32)
- DisplayManager._minimize_unassigned(): after each app switch, minimize
  every running app not currently assigned to any screen, freeing iGPU
  resources (critical on J6412 UHD 600 with limited EUs)

Background: J6412 Intel UHD 600 has only 16 EUs @ 800 MHz. Running
AR-ECDIS (MapLibre GL) and GPS (OpenLayers) simultaneously consumes ~60%
iGPU. By minimizing inactive apps Windows suspends their GPU presentation
chain, dropping idle GPU load near zero.

AR_electronics — AR-Autopilot Project
2026-05-24 21:53:00 -04:00

190 lines
7.0 KiB
Python

"""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)
# Minimize apps not assigned to any screen (free GPU)
self._minimize_unassigned()
def _minimize_unassigned(self) -> None:
"""Minimize every running app that is not currently assigned to any screen."""
assigned = set(
self._layout.get(self._screen_serial(s))
for s in self._app.screens()
) - {None}
for app_id in self._proc_mgr.running_ids():
if app_id not in assigned:
self._proc_mgr.minimize(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()