42b2eec2e1
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
278 lines
9.4 KiB
Python
278 lines
9.4 KiB
Python
"""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()
|