Files
Bolt-Pet/bolt_pet/ui/app.py
T

136 lines
4.8 KiB
Python

"""Wires everything together: QApplication, the pet window, the tray icon,
the history / wake-tuner windows, the global push-to-talk hotkey, and the
background PetController thread that owns the mic/wake/server/TTS pipeline.
Everything the controller wants the UI to do arrives as a Qt signal, so the
worker thread never touches a widget.
"""
from __future__ import annotations
import sys
from PySide6.QtCore import QThread
from PySide6.QtWidgets import QApplication
from .. import config, updater
from ..controller import PetController
from ..hotkey import GlobalHotkey
from ..state import PetState
from .history_window import HistoryWindow
from .pet_window import PetWindow
from .tray import PetTray
from .wake_tuner import WakeTunerWindow
def _log(message: str) -> None:
print(message, flush=True)
def run() -> int:
app = QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False) # tray-driven app; closing the pet isn't "quit"
window = PetWindow()
window.show()
controller = PetController()
thread = QThread()
controller.moveToThread(thread)
thread.started.connect(controller.run)
controller.state_changed.connect(lambda value: window.set_state(PetState(value)))
controller.said.connect(window.say)
controller.log.connect(_log)
controller.action.connect(window.apply_action) # petctl move/emote/say/...
controller.finished.connect(thread.quit)
window.talk_requested.connect(controller.request_talk_now)
# The window owns the screen list and tells the controller about it, so
# both ends agree on what "monitor 2" means (see monitors.py).
window.monitors_changed.connect(controller.set_monitors)
window.pet_monitor_changed.connect(controller.set_pet_monitor)
# PetWindow publishes once in its constructor, which ran before those
# connections existed — so say it again now that anyone is listening.
window.publish_monitors()
window.copied.connect(lambda text: _log(f"Copied to clipboard: {text[:60]}"))
history_window = HistoryWindow(controller.history)
tuner_window = WakeTunerWindow(
get_threshold=controller.wake_threshold,
set_threshold=controller.set_wake_threshold,
get_stats=controller.wake_stats,
on_reset=controller.reset_wake_stats,
)
def _set_nap(napping: bool) -> None:
# Clicking the tray item pins the state; the schedule takes over again
# only after a restart or a `petctl nap off`.
controller.set_napping(napping)
window.set_napping(napping)
tray = PetTray(
on_talk_now=controller.request_talk_now,
on_toggle_mute=controller.toggle_mute,
on_quit=app.quit,
on_set_wander=window.set_wander_enabled,
wander_enabled=config.PET_WANDER,
on_set_click_through=window.set_click_through,
click_through_enabled=config.PET_CLICK_THROUGH,
on_set_nap=_set_nap,
on_show_history=history_window.show_refreshed,
on_show_wake_tuner=tuner_window.show_refreshed,
on_reset_voice=controller.reset_voice,
)
def _handle_napping(napping: bool) -> None:
window.set_napping(napping)
tray.set_napping(napping)
controller.napping.connect(_handle_napping)
# The server can hand Bolt a different voice mid-conversation (speak_as);
# the tray is where you get his own back.
controller.voice_changed.connect(tray.set_voice)
# Push-to-talk: a global hook, because the pet window never has focus.
# request_talk_now() only sets a threading.Event, so it's safe to call
# from pynput's listener thread.
hotkey = GlobalHotkey(config.PUSH_TO_TALK_HOTKEY, controller.request_talk_now)
problem = hotkey.start()
if problem:
_log(problem)
elif hotkey.running:
_log(f"Push-to-talk: {config.PUSH_TO_TALK_HOTKEY}")
# The updater has already moved the checkout by the time this fires; all
# that's left is to let Qt tear down cleanly (so the mic and the tray
# icon are released) and then exec the new code. Doing the exec after
# app.exec() returns, rather than from the controller thread, is what
# guarantees the audio device is free before the new process opens it.
pending_restart = {"tag": None}
def _handle_restart(tag: str) -> None:
pending_restart["tag"] = tag
app.quit()
controller.restart_requested.connect(_handle_restart)
def _shutdown() -> None:
hotkey.stop()
controller.stop()
thread.quit()
thread.wait(5000)
app.aboutToQuit.connect(_shutdown)
thread.start()
status = app.exec()
if pending_restart["tag"]:
_log(f"Restarting into {pending_restart['tag']}…")
try:
updater.restart() # never returns
except Exception as exc:
_log(f"Couldn't restart automatically ({exc}) — start the pet again by hand.")
return status