156 lines
5.8 KiB
Python
156 lines
5.8 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, voice reset,
|
|
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,
|
|
on_reset_voice: 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)
|
|
|
|
# Only ever enabled while a server-picked voice (speak_as) is in use —
|
|
# it's the way back from "talk like a pirate", which nothing else
|
|
# undoes short of a restart.
|
|
self._voice_action = None
|
|
if on_reset_voice is not None:
|
|
self._voice_action = QAction("Use default voice", menu)
|
|
self._voice_action.setEnabled(False)
|
|
self._voice_action.triggered.connect(on_reset_voice)
|
|
menu.addAction(self._voice_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_voice(self, voice: str) -> None:
|
|
"""Reflect the voice the controller is speaking in — a name (or id)
|
|
when the server picked one, "" for Bolt's own."""
|
|
if self._voice_action is None:
|
|
return
|
|
self._voice_action.setEnabled(bool(voice))
|
|
self._voice_action.setText(
|
|
f"Use default voice (now: {voice})" if voice else "Use default voice"
|
|
)
|
|
|
|
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")
|