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