136 lines
4.8 KiB
Python
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
|