Files
AR-Autopilot/display_manager/floating_button.py
T
alro65 42b2eec2e1 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
2026-05-24 21:48:27 -04:00

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()