"""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, window_state from ..monitors import Monitor from ..state import PetState from .sprite import WALK, 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 # How far the pet travels per walk-cycle frame. The cycle is advanced by # distance rather than by the animation clock so a planted paw tracks backwards # at exactly the speed the window moves forwards — drive it off a timer instead # and the feet skate whenever PET_WANDER_SPEED doesn't happen to match the fps. # Eight frames at 13px is a ~104px stride cycle, a bit under the pet's width. _WALK_PIXELS_PER_FRAME = 13.0 # How long a loudness level stays believable. The audio thread sends one per # ~30ms while a clip plays; if they stop arriving (offline TTS has no envelope, # or playback died) the mouth must not stay frozen mid-syllable, so after this # long the ordinary looping animation takes back over. _MOUTH_STALE_SECONDS = 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 # The screen layout, published *to* the controller (queued, cross-thread). # The window is the only thing allowed to ask Qt about screens, so the # controller and the window can never disagree about what "monitor 2" # means — see monitors.py. monitors_changed = Signal(list) # list[monitors.Monitor] pet_monitor_changed = Signal(int) # 0-based index the pet is standing on 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 # Lip-sync: loudness of what is playing right now, and when it arrived. self._mouth_level: Optional[float] = None self._mouth_at = 0.0 self._bob_phase = 0.0 self._walking = False self._facing = 1 # +1 right, -1 left; the walk art is drawn facing right self._walk_distance = 0.0 self._mirror_cache: dict[int, QPixmap] = {} 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() self._monitors: list[Monitor] = [] self._pet_monitor: Optional[int] = None self._last_published_pos: Optional[QPoint] = None app = QApplication.instance() if app is not None: # Screens come and go — a laptop docking, a TV waking up. Republish # rather than letting Bolt jump to a monitor that's been unplugged. app.screenAdded.connect(lambda _s: self.publish_monitors()) app.screenRemoved.connect(lambda _s: self.publish_monitors()) app.primaryScreenChanged.connect(lambda _s: self.publish_monitors()) self.publish_monitors() # ── 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 x is None and y is None and config.PET_REMEMBER_POSITION: remembered = window_state.load() # Only if it still lands on a screen that exists — the usual reason # a saved position is stale is that the monitor it was on has been # unplugged, and restoring it faithfully would hide the pet. if remembered is not None: rectangles = [ (s.availableGeometry().left(), s.availableGeometry().top(), s.availableGeometry().right(), s.availableGeometry().bottom()) for s in QApplication.screens() ] if window_state.is_visible_on(*remembered, self.width(), rectangles): x, y = remembered 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 == "jump": self.jump_to_monitor(int(action["monitor"])) 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 # ── monitors ───────────────────────────────────────────────────────── def _build_monitors(self) -> list[Monitor]: """Snapshot Qt's screen list as plain dataclasses. Full `geometry()`, not `availableGeometry()`: these coordinates are what a screen grab gets cropped to, and a grab doesn't stop at the taskbar. Placement uses availableGeometry separately. """ primary = QApplication.primaryScreen() out = [] for index, screen in enumerate(QApplication.screens()): geo = screen.geometry() out.append( Monitor( index=index, name=screen.name() or f"screen-{index + 1}", x=geo.x(), y=geo.y(), width=geo.width(), height=geo.height(), primary=screen is primary, ) ) return out def publish_monitors(self, force: bool = True) -> None: """Push the current layout to whoever's listening (the controller). *force* re-emits even when nothing changed, which is what the initial wiring in ui/app.py needs: this window is built before the controller exists, so the constructor's first publish goes to nobody. """ monitors = self._build_monitors() changed = monitors != self._monitors self._monitors = monitors if changed or force: self.monitors_changed.emit(monitors) self._publish_pet_monitor(force=True) def monitors(self) -> list[Monitor]: return list(self._monitors) def current_monitor_index(self) -> Optional[int]: center = self.frameGeometry().center() screens = QApplication.screens() if not screens: return None screen = QApplication.screenAt(center) if screen is not None: try: return screens.index(screen) except ValueError: pass # Straddling a gap or dragged off the desktop entirely — fall back to # whichever screen centre is nearest rather than reporting nothing. best = min( range(len(screens)), key=lambda i: (screens[i].geometry().center() - center).manhattanLength(), ) return best def _publish_pet_monitor(self, force: bool = False) -> None: index = self.current_monitor_index() if index is None: return if force or index != self._pet_monitor: self._pet_monitor = index self.pet_monitor_changed.emit(index) def jump_to_monitor(self, index: int) -> None: """Teleport to *index* (0-based, resolved by the controller) and land with a hop. Instant rather than a stroll — Bolt asked to *jump*, and walking between screens would take the long way across the desktop.""" screens = QApplication.screens() if not (0 <= index < len(screens)): return geo = screens[index].availableGeometry() point = self._clamp_to_screen( QPoint( geo.left() + (geo.width() - self.width()) // 2, geo.top() + (geo.height() - self.height()) // 2, ), geo, ) self._stop_walking() # drop any stroll in flight, or it walks straight back self.move(point) self._schedule_next_wander() self._publish_pet_monitor(force=True) self.start_emote("hop") self.update() 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 and not self._walking: return self._wander_target = None self._commanded_move = False self._bob_phase = 0.0 self._bob_offset = 0 self._walking = False self._walk_distance = 0.0 # Back to a standing frame, so the next stroll starts from a contact # pose instead of mid-stride. self.sprites.get(WALK).reset() 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() # Report crossing a screen boundary — by strolling, by being dragged, # by anything. Guarded on the position actually changing so the common # case (a stationary pet, 30x a second) costs one comparison. position = self.pos() if position != self._last_published_pos: self._last_published_pos = position self._publish_pet_monitor() 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._advance_walk(dx, dy, step) self.update() self._reposition_bubble() def _advance_walk(self, dx: float, dy: float, step: float) -> None: """Drive the walk cycle from distance travelled (see the constant). Falls back to the old bob-in-code if there's no walk art, so a sprite folder without a walk/ directory still looks like it's moving rather than sliding perfectly flat. """ self._walking = True # Only turn on meaningful horizontal travel: a near-vertical stroll # would otherwise flip him back and forth on rounding noise. if abs(dx) > 1.0: self._facing = 1 if dx > 0 else -1 if not self.sprites.has(WALK): self._bob_phase += 0.45 self._bob_offset = int(round(-2.5 * abs(math.sin(self._bob_phase)))) return self._bob_offset = 0 # the walk frames carry their own weight shift self._walk_distance += step while self._walk_distance >= _WALK_PIXELS_PER_FRAME: self._walk_distance -= _WALK_PIXELS_PER_FRAME self.sprites.get(WALK).advance() def _animation_key(self): """Walking overrides the state animation — but only while genuinely idle-and-moving, so he doesn't trot on the spot mid-sentence.""" if self._walking and self.sprites.has(WALK): return WALK return self._current_state def _oriented(self, pixmap: Optional[QPixmap]) -> Optional[QPixmap]: """Mirror the (right-facing) walk art when he's heading left. Cached per source frame — flipping on every paint would be wasteful at 30fps.""" if pixmap is None or self._facing >= 0: return pixmap key = pixmap.cacheKey() mirrored = self._mirror_cache.get(key) if mirrored is None: mirrored = pixmap.transformed(QTransform().scale(-1, 1), Qt.SmoothTransformation) self._mirror_cache[key] = mirrored return mirrored # ── 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 set_mouth(self, level: float) -> None: """How loud the pet is *right now* (0..1), straight off the PCM going to the speakers (audio/tts.level_of). The talking frames are ordered by mouth openness, so this indexes them directly: the mouth moves with the actual waveform instead of flapping on a timer, which is the difference between a talking sprite and a dubbed one.""" self._mouth_level = max(0.0, min(1.0, float(level))) self._mouth_at = time.monotonic() if self._current_state == PetState.TALKING: self.update() def _mouth_frame(self, animation) -> Optional[QPixmap]: """The frame matching the current loudness, or None to use the timer. Falls back the moment the levels go stale — offline TTS has no envelope, and a mouth frozen mid-syllable is worse than a timed loop.""" if self._mouth_level is None or animation is None: return None if time.monotonic() - self._mouth_at > _MOUTH_STALE_SECONDS: return None frames = animation.frames if len(frames) < 2: return None index = int(round(self._mouth_level * (len(frames) - 1))) return frames[max(0, min(len(frames) - 1, index))] def _advance_frame(self) -> None: # While walking the cycle is stepped by _advance_walk from distance # travelled; letting this timer also advance it would double-step it # and put the feet out of sync with the movement. if self._walking and self.sprites.has(WALK): return 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) key = self._animation_key() animation = self.sprites.get(key) pixmap: Optional[QPixmap] = None if key == PetState.TALKING: pixmap = self._mouth_frame(animation) if pixmap is None: pixmap = animation.current() if key == WALK: pixmap = self._oriented(pixmap) 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 not was_click and config.PET_REMEMBER_POSITION: position = self.geometry().topLeft() window_state.save(position.x(), position.y()) if was_click: self.talk_requested.emit() else: self.snap_to_edge() # dropped near an edge -> tuck it flush