Files
Bolt-Pet/bolt_pet/ui/pet_window.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

583 lines
24 KiB
Python

"""The pet itself: a frameless, translucent, always-on-top window that
renders the current sprite animation, wanders the desktop on its own while
idle, walks/emotes on command from the server (see pet_actions.py), can be
dragged around, dims when napping, and turns a plain (non-drag) click into a
"talk now" request.
"""
from __future__ import annotations
import math
import random
import time
from pathlib import Path
from typing import Optional
from PySide6.QtCore import QPoint, Qt, QTimer, Signal
from PySide6.QtGui import (
QColor, QCursor, QFont, QFontMetrics, QPainter, QPainterPath, QPixmap, QRegion, QTransform,
)
from PySide6.QtWidgets import QApplication, QWidget
from .. import config
from ..state import PetState
from .sprite import SpriteSet
_DRAG_THRESHOLD_PX = 4
# Movement runs on its own ~30fps timer, independent of the (slower) sprite
# animation timer, so a stroll looks smooth even at IDLE_ANIMATION_FPS=6.
_WANDER_TICK_MS = 33
_EMOTE_TICKS = 36 # ~1.2s per emote at the tick rate above
_NAP_OPACITY = 0.35
def emote_transform(emote: str, progress: float) -> tuple[float, float, float, float]:
"""(dx, dy, rotation_degrees, scale) for an emote at *progress* 0..1.
Pure maths, deliberately separate from paintEvent so the motion curves can
be unit tested (and so adding an emote doesn't mean touching painting
code). Every emote must return to (0, 0, 0, 1) at progress 1.0, otherwise
the pet ends up permanently askew.
"""
progress = min(max(progress, 0.0), 1.0)
fade = math.sin(math.pi * progress) # 0 -> 1 -> 0, so it always lands home
tau = 2 * math.pi
if emote == "wave":
return 0.0, 0.0, 14.0 * fade * math.sin(tau * 2 * progress), 1.0
if emote in ("hop", "bounce"):
hops = 2 if emote == "hop" else 3
return 0.0, -22.0 * fade * abs(math.sin(math.pi * hops * progress)), 0.0, 1.0
if emote == "spin":
return 0.0, 0.0, 360.0 * progress % 360.0, 1.0
if emote == "nod":
return 0.0, 10.0 * fade * math.sin(tau * 2 * progress), 0.0, 1.0 - 0.05 * fade
if emote in ("shake", "wiggle"):
return 14.0 * fade * math.sin(tau * 3 * progress), 0.0, 0.0, 1.0
return 0.0, 0.0, 0.0, 1.0
class SpeechBubble(QWidget):
"""Small translucent word-bubble shown above the pet while it talks."""
_MAX_WIDTH = 260
_PADDING = 10
copied = Signal(str)
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent, Qt.FramelessWindowHint | Qt.Tool)
self.setAttribute(Qt.WA_TranslucentBackground)
self.setAttribute(Qt.WA_ShowWithoutActivating)
self._text = ""
self._font = QFont()
self._font.setPointSize(10)
self._flash = "" # transient overlay ("Copied") drawn over the text
self._hide_timer = QTimer(self)
self._hide_timer.setSingleShot(True)
self._hide_timer.timeout.connect(self.hide)
self._flash_timer = QTimer(self)
self._flash_timer.setSingleShot(True)
self._flash_timer.timeout.connect(self._clear_flash)
self.setToolTip("Click to copy")
self.setCursor(Qt.PointingHandCursor)
self.hide()
@property
def text(self) -> str:
return self._text
def show_text(self, text: str, duration_ms: int = 6000) -> None:
text = (text or "").strip()
if not text:
self.hide()
return
self._text = text
self._relayout()
self.show()
self.raise_()
self._hide_timer.start(duration_ms)
# ── click to copy ────────────────────────────────────────────────────
# The bubble hides itself after a few seconds, which is fine for chat and
# awful for anything you needed to keep (a path, a number, a command's
# output). One click puts it on the clipboard; the tray's History window
# has the rest.
def mousePressEvent(self, event) -> None:
if event.button() != Qt.LeftButton or not self._text:
return
QApplication.clipboard().setText(self._text)
self.copied.emit(self._text)
self._flash = "Copied to clipboard"
self.update()
self._flash_timer.start(900)
self._hide_timer.start(2500) # linger a moment so the flash is visible
def _clear_flash(self) -> None:
self._flash = ""
self.update()
def _wrapped_lines(self) -> list[str]:
metrics = QFontMetrics(self._font)
words = self._text.split()
lines: list[str] = []
current = ""
max_text_width = self._MAX_WIDTH - 2 * self._PADDING
for word in words:
candidate = f"{current} {word}".strip()
if metrics.horizontalAdvance(candidate) <= max_text_width or not current:
current = candidate
else:
lines.append(current)
current = word
if current:
lines.append(current)
return lines[:6] # don't let a huge reply turn into a wall of bubble
def _relayout(self) -> None:
metrics = QFontMetrics(self._font)
lines = self._wrapped_lines()
text_width = max((metrics.horizontalAdvance(line) for line in lines), default=0)
width = min(self._MAX_WIDTH, text_width + 2 * self._PADDING)
height = metrics.height() * len(lines) + 2 * self._PADDING
self.resize(width, height)
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
path = QPainterPath()
path.addRoundedRect(0, 0, self.width(), self.height(), 10, 10)
painter.fillPath(path, QColor(30, 30, 35, 230))
painter.setFont(self._font)
metrics = QFontMetrics(self._font)
if self._flash:
painter.setPen(QColor(150, 230, 170))
painter.drawText(self.rect(), Qt.AlignCenter, self._flash)
return
painter.setPen(QColor(240, 240, 245))
y = self._PADDING + metrics.ascent()
for line in self._wrapped_lines():
painter.drawText(self._PADDING, y, line)
y += metrics.height()
class PetWindow(QWidget):
talk_requested = Signal()
copied = Signal(str) # bubble text the user just put on the clipboard
def __init__(self, sprite_dir: Optional[Path] = None, size: Optional[int] = None):
super().__init__()
flags = Qt.FramelessWindowHint | Qt.Tool
if config.PET_ALWAYS_ON_TOP:
flags |= Qt.WindowStaysOnTopHint
self.setWindowFlags(flags)
self.setAttribute(Qt.WA_TranslucentBackground)
self.sprites = SpriteSet(sprite_dir or (Path(__file__).resolve().parent.parent / "assets" / "sprites"),
size or config.PET_SIZE)
self.resize(self.sprites.size, self.sprites.size)
self._current_state = PetState.IDLE
self._drag_offset: Optional[QPoint] = None
self._press_pos: Optional[QPoint] = None
self._dragged = False
self._bubble = SpeechBubble()
self._bubble.copied.connect(self.copied)
self._napping = False
self._emote: Optional[str] = None
self._emote_tick = 0
self._mask_key = None
self._anim_timer = QTimer(self)
self._anim_timer.timeout.connect(self._advance_frame)
fps = max(1.0, config.IDLE_ANIMATION_FPS)
self._anim_timer.start(int(1000 / fps))
self._wander_enabled = config.PET_WANDER
self._wander_target: Optional[QPoint] = None
self._commanded_move = False # a petctl move — happens even mid-conversation
self._next_wander_at = 0.0
self._bob_offset = 0
self._bob_phase = 0.0
self._schedule_next_wander()
self._wander_timer = QTimer(self)
self._wander_timer.timeout.connect(self._movement_tick)
self._wander_timer.start(_WANDER_TICK_MS)
self._click_through = False
self.set_click_through(config.PET_CLICK_THROUGH)
self._place_start_position()
# ── placement ────────────────────────────────────────────────────────
def _place_start_position(self) -> None:
screen = QApplication.primaryScreen()
geo = screen.availableGeometry() if screen else None
try:
x = int(config.PET_START_X) if config.PET_START_X else None
y = int(config.PET_START_Y) if config.PET_START_Y else None
except ValueError:
x = y = None
if geo is not None:
x = geo.right() - self.width() - 40 if x is None else x
y = geo.bottom() - self.height() - 60 if y is None else y
self.move(x or 0, y or 0)
self._reposition_bubble()
def _reposition_bubble(self) -> None:
top_left = self.geometry().topLeft()
self._bubble.move(
top_left.x() + self.width() // 2 - self._bubble.width() // 2,
top_left.y() - self._bubble.height() - 8,
)
# ── server-driven actions (petctl) ───────────────────────────────────
def apply_action(self, action: dict) -> None:
"""Perform one parsed petctl action (see pet_actions.py). Called on
the UI thread via a queued signal from the controller."""
kind = action.get("action")
if kind == "move":
target = self._resolve_move_target(action)
if target is not None:
self._wander_target = target
self._commanded_move = True # overrides the idle-only rule
elif kind == "emote":
self.start_emote(action["emote"])
elif kind == "say":
self.say(action["text"])
elif kind == "wander":
self.set_wander_enabled(bool(action["enabled"]))
elif kind == "nap":
self.set_napping(bool(action["enabled"]))
def _resolve_move_target(self, action: dict) -> Optional[QPoint]:
geo = self._screen_geometry()
if "x" in action and "y" in action:
point = QPoint(int(action["x"]), int(action["y"]))
return self._clamp_to_screen(point, geo)
anchor = action.get("anchor")
if anchor == "cursor":
cursor = QCursor.pos()
return self._clamp_to_screen(
QPoint(cursor.x() - self.width() // 2, cursor.y() - self.height() // 2), geo
)
if geo is None:
return None
if anchor == "random":
return self._pick_wander_target()
margin = config.PET_WANDER_MARGIN
left, right = geo.left() + margin, geo.right() - self.width() - margin
top, bottom = geo.top() + margin, geo.bottom() - self.height() - margin
middle_x = geo.left() + (geo.width() - self.width()) // 2
middle_y = geo.top() + (geo.height() - self.height()) // 2
positions = {
"top-left": (left, top), "top": (middle_x, top), "top-right": (right, top),
"left": (left, middle_y), "center": (middle_x, middle_y), "right": (right, middle_y),
"bottom-left": (left, bottom), "bottom": (middle_x, bottom), "bottom-right": (right, bottom),
}
if anchor not in positions:
return None
return QPoint(*positions[anchor])
def _clamp_to_screen(self, point: QPoint, geo) -> QPoint:
if geo is None:
return point
x = min(max(point.x(), geo.left()), max(geo.left(), geo.right() - self.width()))
y = min(max(point.y(), geo.top()), max(geo.top(), geo.bottom() - self.height()))
return QPoint(x, y)
# ── emotes ───────────────────────────────────────────────────────────
def start_emote(self, emote: str) -> None:
self._emote = emote
self._emote_tick = 0
self.update()
def _advance_emote(self) -> None:
if self._emote is None:
return
self._emote_tick += 1
if self._emote_tick > _EMOTE_TICKS:
self._emote = None
self._emote_tick = 0
self.update()
def _emote_transform(self) -> tuple[float, float, float, float]:
if self._emote is None:
return 0.0, 0.0, 0.0, 1.0
return emote_transform(self._emote, self._emote_tick / _EMOTE_TICKS)
# ── napping (quiet hours / do-not-disturb) ───────────────────────────
def set_napping(self, napping: bool) -> None:
"""Dim and stand still. Purely cosmetic here — the controller is what
actually suppresses proactive speech."""
if napping == self._napping:
return
self._napping = napping
self.setWindowOpacity(_NAP_OPACITY if napping else 1.0)
if napping:
self._stop_walking()
self.update()
@property
def napping(self) -> bool:
return self._napping
# ── mouse transparency ───────────────────────────────────────────────
def set_click_through(self, enabled: bool) -> None:
"""When on, the pet ignores the mouse entirely (tray-only control) —
for when it's parked over something you need to click a lot."""
self._click_through = enabled
self.setAttribute(Qt.WA_TransparentForMouseEvents, enabled)
if enabled:
self.clearMask()
self._mask_key = None
else:
self._mask_key = None # force the shaped mask to be rebuilt
@property
def click_through(self) -> bool:
return self._click_through
def _apply_input_mask(self, pixmap: Optional[QPixmap], x: int, y: int) -> None:
"""Restrict the window to the sprite's opaque pixels, so the square
window's transparent corners stop swallowing clicks meant for what's
underneath. Rebuilt only when the frame actually changes — the mask
is derived from the pixmap's alpha, which isn't free."""
if self._click_through or not config.PET_SHAPED_INPUT:
return
if pixmap is None:
if self._mask_key is not None:
self.clearMask()
self._mask_key = None
return
key = (pixmap.cacheKey(), x, y)
if key == self._mask_key:
return
self._mask_key = key
try:
region = QRegion(pixmap.mask())
region.translate(x, y)
self.setMask(region)
except Exception:
self.clearMask() # a sprite without an alpha channel — never mind
# ── wandering ────────────────────────────────────────────────────────
def set_wander_enabled(self, enabled: bool) -> None:
self._wander_enabled = enabled
if not enabled:
self._stop_walking()
def wander_now(self) -> None:
"""Stroll immediately (tray menu / anything that wants a nudge)."""
self._next_wander_at = 0.0
def _screen_geometry(self):
# screenAt() so a multi-monitor setup keeps the pet on the screen
# it's currently standing on rather than yanking it to the primary.
screen = QApplication.screenAt(self.frameGeometry().center()) or QApplication.primaryScreen()
return screen.availableGeometry() if screen else None
def _schedule_next_wander(self) -> None:
base = max(1.0, config.PET_WANDER_INTERVAL_SECONDS)
self._next_wander_at = time.monotonic() + random.uniform(0.5 * base, 1.5 * base)
def _stop_walking(self) -> None:
if self._wander_target is None and not self._bob_offset:
return
self._wander_target = None
self._commanded_move = False
self._bob_phase = 0.0
self._bob_offset = 0
self.update()
def snap_to_edge(self) -> bool:
"""If the pet has come to rest near a screen edge, tuck it flush
against it — a desktop pet parked 11px off the taskbar looks like a
bug. Returns True if it moved."""
geo = self._screen_geometry()
if geo is None or not config.PET_EDGE_SNAP:
return False
margin = config.PET_SNAP_MARGIN
here = self.pos()
x, y = here.x(), here.y()
if abs(x - geo.left()) <= margin:
x = geo.left()
elif abs(geo.right() - (x + self.width())) <= margin:
x = geo.right() - self.width() + 1
if abs(y - geo.top()) <= margin:
y = geo.top()
elif abs(geo.bottom() - (y + self.height())) <= margin:
y = geo.bottom() - self.height() + 1
if (x, y) == (here.x(), here.y()):
return False
self.move(x, y)
self._reposition_bubble()
return True
def _pick_wander_target(self) -> Optional[QPoint]:
geo = self._screen_geometry()
if geo is None:
return None
margin = config.PET_WANDER_MARGIN
min_x, max_x = geo.left() + margin, geo.right() - self.width() - margin
min_y, max_y = geo.top() + margin, geo.bottom() - self.height() - margin
if max_x <= min_x or max_y <= min_y: # pet bigger than the screen
return None
here = self.pos()
target = QPoint(random.randint(min_x, max_x), random.randint(min_y, max_y))
dx, dy = target.x() - here.x(), target.y() - here.y()
distance = math.hypot(dx, dy)
limit = max(1.0, config.PET_WANDER_MAX_DISTANCE)
if distance > limit: # shorten the trip rather than sprinting the diagonal
scale = limit / distance
target = QPoint(round(here.x() + dx * scale), round(here.y() + dy * scale))
elif distance < 8: # already there — not worth a stroll
return None
return target
def _movement_tick(self) -> None:
"""One timer, two jobs — emotes play whatever the pet is doing, while
wandering only happens when it's otherwise unoccupied."""
self._advance_emote()
self._wander_tick()
def _wander_tick(self) -> None:
# Only stroll while genuinely idle: not mid-drag, not napping, not
# talking/listening, and not while a speech bubble is up (it would walk
# out from under it). A commanded `petctl move` ignores all of that
# except the drag — if Bolt says go, it goes.
busy = (
not self._wander_enabled
or self._napping
or self._current_state != PetState.IDLE
or self._bubble.isVisible()
)
if self._drag_offset is not None or (busy and not self._commanded_move):
self._stop_walking()
self._schedule_next_wander() # settle first, then wander
return
if self._wander_target is None:
if time.monotonic() < self._next_wander_at:
return
self._wander_target = self._pick_wander_target()
if self._wander_target is None:
self._schedule_next_wander()
return
here = self.pos()
dx = self._wander_target.x() - here.x()
dy = self._wander_target.y() - here.y()
distance = math.hypot(dx, dy)
step = max(1.0, config.PET_WANDER_SPEED * _WANDER_TICK_MS / 1000.0)
if distance <= step:
self.move(self._wander_target)
self._stop_walking()
self._schedule_next_wander()
self.snap_to_edge()
else:
self.move(round(here.x() + dx / distance * step), round(here.y() + dy / distance * step))
self._bob_phase += 0.45 # little walk-cycle hop
self._bob_offset = int(round(-2.5 * abs(math.sin(self._bob_phase))))
self.update()
self._reposition_bubble()
# ── state / speech ──────────────────────────────────────────────────
def set_state(self, state: PetState) -> None:
if state == self._current_state:
return
self._current_state = state
self.sprites.get(state).reset()
if state != PetState.IDLE and not self._commanded_move:
# Stand still while listening/thinking/talking — but not if Bolt
# just told it to walk somewhere: that command arrives mid-turn,
# and the reply (-> TALKING) lands a moment later.
self._stop_walking()
self.update()
def say(self, text: str, duration_ms: int = 6000) -> None:
self._reposition_bubble()
self._bubble.show_text(text, duration_ms)
def closeEvent(self, event) -> None:
self._bubble.close()
super().closeEvent(event)
# ── animation ────────────────────────────────────────────────────────
def _advance_frame(self) -> None:
self.sprites.get(self._current_state).advance()
self.update()
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
painter.setRenderHint(QPainter.SmoothPixmapTransform)
pixmap: Optional[QPixmap] = self.sprites.get(self._current_state).current()
if pixmap is None:
self._apply_input_mask(None, 0, 0)
return
# Non-square source art (e.g. the Kenney robot sprites) keeps its
# aspect ratio when scaled in SpriteSet, so it may be narrower or
# shorter than the (square) window — center it either way.
x = (self.width() - pixmap.width()) // 2
y = (self.height() - pixmap.height()) // 2 + self._bob_offset
# The input mask tracks the resting position, not the emote/bob
# offset: rebuilding it every frame of a spin would be both expensive
# and visibly janky, and the offsets are only a few pixels.
self._apply_input_mask(pixmap, x, (self.height() - pixmap.height()) // 2)
dx, dy, angle, scale = self._emote_transform()
if (dx, dy, angle, scale) == (0.0, 0.0, 0.0, 1.0):
painter.drawPixmap(x, y, pixmap)
return
# Rotate/scale about the sprite's own center so a spin doesn't orbit
# the window's corner.
center_x = x + pixmap.width() / 2
center_y = y + pixmap.height() / 2
transform = QTransform()
transform.translate(center_x + dx, center_y + dy)
transform.rotate(angle)
transform.scale(scale, scale)
transform.translate(-pixmap.width() / 2, -pixmap.height() / 2)
painter.setTransform(transform)
painter.drawPixmap(0, 0, pixmap)
# ── drag / click-to-talk ─────────────────────────────────────────────
def mousePressEvent(self, event) -> None:
if event.button() == Qt.LeftButton:
global_pos = event.globalPosition().toPoint()
self._drag_offset = global_pos - self.frameGeometry().topLeft()
self._press_pos = global_pos
self._dragged = False
self._stop_walking() # grabbing it interrupts a stroll at once
def mouseMoveEvent(self, event) -> None:
if self._drag_offset is None:
return
global_pos = event.globalPosition().toPoint()
self.move(global_pos - self._drag_offset)
self._reposition_bubble()
if (global_pos - self._press_pos).manhattanLength() > _DRAG_THRESHOLD_PX:
self._dragged = True
def mouseReleaseEvent(self, event) -> None:
if event.button() != Qt.LeftButton:
return
was_click = not self._dragged
self._drag_offset = None
self._press_pos = None
if was_click:
self.talk_requested.emit()
else:
self.snap_to_edge() # dropped near an edge -> tuck it flush