"""System tray icon — the pet window is frameless with no taskbar entry, so this menu is the only always-available way to control or exit it: talk now, mute, wander, click-through, nap, history, wake-word tuning, quit. Every entry is a plain callback passed in by ui/app.py; this file knows nothing about the controller or the pet window. """ from __future__ import annotations from typing import Callable, Optional from PySide6.QtCore import Qt from PySide6.QtGui import QAction, QColor, QIcon, QPainter, QPixmap from PySide6.QtWidgets import QMenu, QSystemTrayIcon def _make_icon(muted: bool, napping: bool = False) -> QIcon: pixmap = QPixmap(32, 32) pixmap.fill(Qt.transparent) painter = QPainter(pixmap) painter.setRenderHint(QPainter.Antialiasing) if muted: color = QColor(200, 60, 60) elif napping: color = QColor(120, 120, 140) else: color = QColor(120, 170, 240) painter.setBrush(color) painter.setPen(Qt.NoPen) painter.drawEllipse(2, 2, 28, 28) painter.end() return QIcon(pixmap) class PetTray(QSystemTrayIcon): def __init__( self, on_talk_now: Callable[[], None], on_toggle_mute: Callable[[], bool], on_quit: Callable[[], None], on_set_wander: Optional[Callable[[bool], None]] = None, wander_enabled: bool = True, on_set_click_through: Optional[Callable[[bool], None]] = None, click_through_enabled: bool = False, on_set_nap: Optional[Callable[[bool], None]] = None, on_show_history: Optional[Callable[[], None]] = None, on_show_wake_tuner: Optional[Callable[[], None]] = None, parent=None, ): super().__init__(_make_icon(muted=False), parent) self._on_toggle_mute = on_toggle_mute self._muted = False self._napping = False self.setToolTip("Bolt") menu = QMenu() self._talk_action = QAction("Talk now", menu) self._talk_action.triggered.connect(on_talk_now) menu.addAction(self._talk_action) self._mute_action = QAction("Mute mic", menu) self._mute_action.setCheckable(True) self._mute_action.triggered.connect(self._handle_toggle_mute) menu.addAction(self._mute_action) self._nap_action = None if on_set_nap is not None: self._nap_action = QAction("Nap (no proactive noise)", menu) self._nap_action.setCheckable(True) self._nap_action.triggered.connect(lambda checked: on_set_nap(checked)) menu.addAction(self._nap_action) menu.addSeparator() if on_set_wander is not None: self._wander_action = QAction("Wander around", menu) self._wander_action.setCheckable(True) self._wander_action.setChecked(wander_enabled) self._wander_action.triggered.connect(lambda checked: on_set_wander(checked)) menu.addAction(self._wander_action) if on_set_click_through is not None: self._click_through_action = QAction("Click through the pet", menu) self._click_through_action.setCheckable(True) self._click_through_action.setChecked(click_through_enabled) self._click_through_action.setToolTip( "Ignore the mouse entirely — control it from this menu instead." ) self._click_through_action.triggered.connect(lambda checked: on_set_click_through(checked)) menu.addAction(self._click_through_action) menu.addSeparator() if on_show_history is not None: history_action = QAction("History…", menu) history_action.triggered.connect(on_show_history) menu.addAction(history_action) if on_show_wake_tuner is not None: tuner_action = QAction("Wake word tuning…", menu) tuner_action.triggered.connect(on_show_wake_tuner) menu.addAction(tuner_action) menu.addSeparator() quit_action = QAction("Quit", menu) quit_action.triggered.connect(on_quit) menu.addAction(quit_action) self.setContextMenu(menu) self.show() def _handle_toggle_mute(self) -> None: self._muted = self._on_toggle_mute() self._mute_action.setChecked(self._muted) self._refresh_icon() def set_napping(self, napping: bool) -> None: """Reflect a nap the *controller* decided on (quiet hours, fullscreen, or a petctl command) — not just ones clicked here.""" self._napping = napping if self._nap_action is not None: self._nap_action.setChecked(napping) self._refresh_icon() def _refresh_icon(self) -> None: self.setIcon(_make_icon(self._muted, self._napping)) if self._muted: self.setToolTip("Bolt (muted)") elif self._napping: self.setToolTip("Bolt (napping)") else: self.setToolTip("Bolt")