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