Files
Bolt-Pet/bolt_pet/ui/tray.py
T
themajesticmagician 80bef6f524 Upload
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 06:25:41 -06:00

134 lines
4.9 KiB
Python

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