Download code/moth/pet.py from darioooooo0o/moth-112m: direct link, hf CLI and curl.
- Browser
- Download file 145 kB
-
https://huggingface.co/darioooooo0o/moth-112m/resolve/main/code/moth/pet.py
- Command line
-
hf download hf://darioooooo0o/moth-112m/code/moth/pet.py
-
curl -L -o pet.py https://huggingface.co/darioooooo0o/moth-112m/resolve/main/code/moth/pet.py
145 kB
| """moth as a desktop pet: python -m moth.pet (needs PySide6, which moth itself doesn't: pip install PySide6) | |
| A small moth flies around your desktop and says moth's lines in a speech bubble. It drifts on smoothed noise, is | |
| mildly drawn to the mouse cursor (moths love light), now and then lands on a screen edge to rest, and dozes (wings | |
| folded, "z") after a few idle minutes. A new line from moth makes it flutter. Click it to talk to moth, drag it to | |
| move it, right-click for a menu (open the chat page, mute bubbles, sleep, harness display, skin, updates, quit). | |
| One command: when no moth answers on its URL (GET /api/health, default http://127.0.0.1:4747), the pet starts | |
| `python -m moth.serve` itself (same interpreter, CPU, this install's weights; moth/launch.py) and dozes with "waking | |
| up…" while the model loads. That server is the pet's: quitting the pet (menu, Ctrl-C, SIGTERM/SIGHUP, closing the | |
| window) stops it, and on Linux it also goes if the pet is killed. A moth that was already running is used and never | |
| stopped. Something else on the port, or less than ~3 GB of free memory: nothing is started and the bubble says why. | |
| The server's output goes to ~/.moth/serve.log (live/serve.log in the dev checkout). | |
| It only reads moth and never posts agent turns, so moth never hears a turn twice. Lines come from GET /api/says (token-free, moth's own lines only); when ~/.moth/token is readable it | |
| polls the token-guarded /api/log instead (same indexes) to add a tiny "claude · repo" caption to reactions. | |
| What you type goes to POST /api/chat; the reply comes back through the same poll (the POST's own events are ignored, | |
| or every line would show twice). History is skipped on start; a new log epoch (moth restarted or the chat was reset) | |
| starts over. When moth is down the pet dozes with "zzz… (moth server asleep)" once and retries with backoff. | |
| The window: transparent, frameless, always on top, never takes focus (only the little input box does, when you | |
| click). The moth is real art, not drawn by code: "202206 Silkmoth adult female icon" by DBCLS (Wikimedia Commons, | |
| CC BY 4.0; the original SVG and credits are in moth/assets/). It is a top view with the wings spread. The SVG is | |
| rendered with QtSvg and cut along the body into a left wing, a right wing and the body. The wing frames are those | |
| wings squashed toward the body and turned a little: a 5-frame wingbeat, "resting" (wings narrow and swept back) and | |
| "dozing" (swept back further, plus drifting "z" glyphs). Excited is a faster wingbeat. In flight the moth leans a | |
| little toward where it is going (top view, so it is never mirrored). The chat page's header icon and favicon | |
| (moth/static/index.html) are the same art. | |
| Platforms: X11, macOS, Windows. Wayland doesn't let a client place its own window, so on a Wayland session with | |
| XWayland the pet asks Qt for xcb first (QT_QPA_PLATFORM=xcb;wayland, only when QT_QPA_PLATFORM is unset). Without | |
| XWayland it falls back to native Wayland: it can't fly around, so it sits wherever the compositor puts it (drag | |
| still works). Under XWayland the cursor position only updates while the pointer is over an X window, so the light | |
| attraction fades out when the cursor seems not to move. Window edges aren't detected (no portable way): it lands on | |
| the screen's edges (the available area, so on top of a panel/taskbar). | |
| python -m moth.pet [--size 64] [--no-wander] [--url http://127.0.0.1:4747] [--token-file ~/.moth/token] | |
| [--skin ID|FOLDER|moth] [--no-server] [--model DIR] [--server-arg=--flag ...] [-- moth.serve flags] | |
| --no-server: never start a server, just wait for one. --model: the weights for a server the pet starts. | |
| Flags for that server: --server-arg=--react-every --server-arg=0, or after --: -- --react-every 0 | |
| python -m moth.pet --export-petdex DIR the same moth as a Petdex / Codex pet: DIR/pet.json + spritesheet.png | |
| Petdex (https://petdex.dev, github.com/crafter-station/petdex) pets are a pet.json plus one 1536x1872 spritesheet of | |
| 192x208 cells, 8 columns x 9 rows (idle 6 frames, running-right 8, running-left 8, waving 4, jumping 5, failed 8, | |
| waiting 6, running 6, review 6). The export fills every row from the same wing frames (scaled, tilted and offset; | |
| none drawn new) and writes the art's CC BY credit into pet.json's description and DIR/CREDITS.txt. Codex's TUI | |
| loads custom pets from $CODEX_HOME/pets/<id>/ (codex-rs/tui/src/pets/ | |
| model.rs, which rejects any other sheet size); Hermes from <hermes home>/pets/<slug>/ (agent/pet/store.py); the | |
| Petdex desktop app from ~/.petdex/pets/<slug>/. The export only writes DIR; copy it to one of those yourself. | |
| Skins (--skin ID|FOLDER|moth, or `python -m moth config pet_skin ID`; right-click "skin"): any Petdex pet instead | |
| of the moth. An id is looked up in ~/.petdex/pets/<id>/ (`npx petdex install`), then $CODEX_HOME/pets/<id>/ and | |
| <hermes home>/pets/<id>/; a folder path is used as is. Its pet.json names the sheet (spritesheetPath, inside the | |
| folder), which must be the Petdex 1536x1872 grid. Rows to states: flying = running-right / running-left (the other | |
| one mirrored when a row is empty), resting = idle, dozing = waiting (else idle, slowed), excited = jumping in the air | |
| or waving on the ground, waiting on a reply = review. The cell is scaled to the pet's size; the bubble sits above | |
| the pet's visible top. A missing or bad pet (no pet.json, wrong sheet size, unreadable image, empty idle row) means | |
| moth plus a bubble saying why. moth ships no third-party pets. | |
| Tray icon (where the platform has one; skipped silently otherwise, e.g. some Wayland setups): the same menu as the | |
| right-click one: open chat page, what did i miss, mute bubbles, mute harnesses, quiet hours, sleep, show moth in | |
| harnesses (moth/config.py show_in_harness), skin, stay on this screen, check for updates, quit. | |
| "Your agent is done": a reaction makes the moth hop twice with fluttering wings as its bubble comes up (a skin plays | |
| its jumping row, else waving, else hops), plus a soft chirp with config chirp on (moth/assets/sounds/chirp.wav, | |
| Kenney CC0; QtMultimedia's QSoundEffect if installed, else paplay / pw-play / aplay, afplay, winsound). | |
| "what did i miss" lists moth's last 10 reactions ("claude · moth-repo · 4 min ago"), marking the ones that came | |
| while it was hidden (fullscreen), muted (all bubbles or that harness: config muted_sources) or asleep (quiet_hours, | |
| e.g. 23:00-08:00: no bubbles, no chirp, the moth dozes). The list starts with what the server's log holds when the | |
| pet starts; harness and project come from the token-guarded /api/log (without a readable token: text only). | |
| hide_in_fullscreen (default on): every 1.5 s, from the timer the pet already runs, it asks the window system whether | |
| the active window is fullscreen on the pet's screen: X11 (also XWayland) _NET_ACTIVE_WINDOW + _NET_WM_STATE_FULLSCREEN | |
| (or a managed, unmaximized window covering the screen) through libX11 with ctypes; Windows the foreground window's | |
| rect vs its monitor plus SHQueryUserNotificationState; macOS the frontmost normal window (CGWindowList) vs the | |
| screen. Native Wayland has no way to ask: the pet never hides there (fails open; moth doctor says so). Under | |
| XWayland only X11 apps (games through Wine / Proton) are seen. pet_screen: "stay on this screen" keeps the pet on | |
| the named screen (it starts there, and comes back there when the screen returns); "any" = wherever it is put. | |
| The pet rereads these settings every 30 s, so `python -m moth config ...` applies without a restart. | |
| Updates: once a day (config update_check, default on; last_update_check in ~/.moth/config.json) and on "check for | |
| updates", `python -m moth update --check` runs in a child process off the UI thread. A new version is said in a | |
| bubble ("run `python -m moth update`", `pip install -U moth-companion` for newer pip code, the releases page for | |
| the installers' app); nothing is ever installed by the pet. HF_HUB_OFFLINE=1 or MOTH_OFFLINE=1: no check. | |
| Only stdlib is needed for the logic (Wander, BubbleQueue, Poller, skin lookup and the row mapping); PySide6 is | |
| imported when a window or a sprite sheet is made, so the logic is testable without a display. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import math | |
| import os | |
| import queue | |
| import random | |
| import re | |
| import subprocess | |
| import sys | |
| import textwrap | |
| import threading | |
| import time | |
| import urllib.error | |
| import urllib.request | |
| from dataclasses import dataclass | |
| from urllib.parse import quote | |
| from moth import config # stdlib only: ~/.moth/config.json (pet_skin, update_check, show_in_harness) | |
| from moth import launch # stdlib only: starts moth.serve when nothing answers | |
| from moth.tail import NAMES, clean # stdlib only, same harness names and escape-stripping as moth tail | |
| DEFAULT_URL = os.environ.get("MOTH_URL", "http://127.0.0.1:4747") | |
| TOKEN_HEADER = "X-Moth-Token" | |
| POLL = 1.5 # s between polls while moth answers | |
| MAX_BACKOFF = 30.0 # s between retries at most while moth is down | |
| SKIP_HISTORY = "1000000000" # first poll: "since" past the end = no events, just the current end of the log | |
| WRAP = 32 # bubble line width (characters) | |
| PAGE_LINES = 5 # a longer line is shown as several bubbles, one after another | |
| MAX_PAGES = 3 # ... at most this many per line (the last one ends in "…") | |
| MAX_QUEUED = 4 # bubbles waiting; a burst drops the oldest | |
| DOZE_AFTER = 180.0 # s without a new line or a click before the moth dozes | |
| ASLEEP = "zzz… (moth server asleep)" | |
| WAKING = "waking up…" # while a server the pet started loads the model | |
| DOWNLOADING = "downloading moth…" # a pip install's first run: fetching the weights before the server can start | |
| NOTICE_TTL = 30.0 # s a "can't wake moth" message stays up | |
| CHAT_TIMEOUT = 90.0 # POST /api/chat waits for moth's whole turn (seconds on CPU) | |
| # the page's palette (moth/static/index.html :root) | |
| LAMP = "#e8b765" | |
| NIGHT, NIGHT2, PAPER, INK, DUST, LINE = "#120f0c", "#1a1611", "#f1e6d0", "#2a2118", "#8c7d68", "#2c251d" | |
| _OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) # moth is local: never via a proxy | |
| # --- talking to moth (stdlib) --------------------------------------------------------------------------------- | |
| def token_path(path: str | None = None) -> str: | |
| return os.path.expanduser(path or os.environ.get("MOTH_TOKEN_FILE") or os.path.join("~", ".moth", "token")) | |
| def read_token(path: str) -> str: | |
| try: | |
| with open(path, encoding="utf-8") as f: | |
| return f.read().strip() | |
| except (OSError, UnicodeDecodeError): | |
| return "" | |
| def http_json(url: str, token: str | None = None, body: dict | None = None, timeout: float = 3.0): | |
| headers, data = {}, None | |
| if token: | |
| headers[TOKEN_HEADER] = token | |
| if body is not None: | |
| data = json.dumps(body).encode() | |
| headers["Content-Type"] = "application/json" | |
| req = urllib.request.Request(url, data=data, headers=headers, method="POST" if data is not None else "GET") | |
| with _OPENER.open(req, timeout=timeout) as r: | |
| return json.loads(r.read(4 << 20)) | |
| def caption_for(card: dict) -> str: | |
| """'claude · moth-repo' for an agent card of the log (what moth reacted to).""" | |
| src = card.get("source") | |
| who = NAMES.get(src, src) if isinstance(src, str) and src else "agent" | |
| label = card.get("label") | |
| if isinstance(label, str) and label.strip(): | |
| who += f" · {label.strip()}" | |
| who = " ".join(clean(who).split()) | |
| return who if len(who) <= WRAP else who[:WRAP - 1] + "…" | |
| class Line(tuple): | |
| """One of moth's lines: (text, caption) as poll() has always returned it, plus what the pet's filters and "what | |
| did i miss" need: source (the harness moth answered, None when unknown), label (its project), reply (an answer | |
| to you, not a reaction) and index (its place in the server's log).""" | |
| def __new__(cls, text: str, caption: str | None = None, source: str | None = None, label: str | None = None, | |
| reply: bool = False, index: int | None = None): | |
| self = super().__new__(cls, (text, caption)) | |
| self.source, self.label, self.reply, self.index = source, label, reply, index | |
| return self | |
| def text(self) -> str: | |
| return self[0] | |
| def _plain(v, n: int = 40) -> str | None: | |
| return " ".join(clean(v).split())[:n] or None if isinstance(v, str) and v.strip() else None | |
| class Poller: | |
| """moth's new lines, one poll at a time (the GUI runs it on a thread). poll() never raises. | |
| Returns (lines, status): lines = [Line(text, caption or None)], status = "up" / "down" when that changed, else | |
| None. The first answer only sets the cursor: its events are not shown, but the reactions among them are kept in | |
| self.history (for "what did i miss"). With a readable token it polls /api/log (moth's lines plus the agent | |
| cards, for captions and sources), else /api/says; a token the server refuses is not tried again until the file | |
| changes.""" | |
| def __init__(self, url: str = DEFAULT_URL, token_file: str | None = None): | |
| self.url = url.rstrip("/") | |
| self.token_file = token_path(token_file) | |
| self.cursor: int | None = None | |
| self.epoch: str | None = None | |
| self.up: bool | None = None # None until the first poll answers or fails | |
| self.fails = 0 | |
| self.card: str | None = None # caption of the last agent card moth hasn't answered yet | |
| self.card_src: tuple | None = None # (source, label) of that card | |
| self.reply = False # after something you said, until the next agent card | |
| self.bad_token: str | None = None | |
| self.start: int | None = None # the log's end when the pet started: history() stops there | |
| def interval(self) -> float: | |
| return POLL if not self.fails else min(MAX_BACKOFF, POLL * 2 ** self.fails) | |
| def history(self, keep: int = 10) -> list[Line]: | |
| """The last `keep` reactions the server's log held when the pet started (oldest first), for "what did i | |
| miss"; [] when moth is down, not polled yet, or anything goes wrong. Best effort and separate from poll(), so | |
| a big or broken answer never makes moth look asleep. Call it from the polling thread (it borrows the card | |
| state).""" | |
| if self.start is None: | |
| return [] | |
| saved = self.card, self.card_src, self.reply, self.cursor | |
| try: | |
| self.card, self.card_src, self.reply, self.cursor = None, None, False, 0 | |
| d = self._fetch() | |
| evs, end = d["events"], d["next"] | |
| if not (isinstance(evs, list) and isinstance(end, int)): | |
| return [] | |
| lines = self.lines(evs, end - len(evs)) | |
| return [x for x in lines if not x.reply and (x.index is None or x.index < self.start)][-keep:] | |
| except Exception: # noqa: BLE001 | |
| return [] | |
| finally: | |
| self.card, self.card_src, self.reply, self.cursor = saved | |
| def _fetch(self) -> dict: | |
| tok = read_token(self.token_file) | |
| use_log = bool(tok) and tok != self.bad_token | |
| since = SKIP_HISTORY if self.cursor is None else str(self.cursor) | |
| q = f"since={since}" + (f"&epoch={quote(self.epoch)}" if self.cursor is not None and self.epoch else "") | |
| if use_log: | |
| try: | |
| return http_json(f"{self.url}/api/log?{q}", tok) | |
| except urllib.error.HTTPError as e: | |
| if e.code != 401: | |
| raise | |
| self.bad_token = tok # wrong token: moth's lines without captions | |
| return http_json(f"{self.url}/api/says?{q}") | |
| def poll(self) -> tuple[list[tuple[str, str | None]], str | None]: | |
| try: | |
| d = self._fetch() | |
| if not (isinstance(d, dict) and isinstance(d.get("events"), list) and isinstance(d.get("next"), int) | |
| and isinstance(d.get("epoch"), str)): | |
| raise ValueError("not moth") | |
| except Exception: # noqa: BLE001 down, slow, not moth, garbage: all "asleep" | |
| self.fails += 1 | |
| changed = self.up is not False | |
| self.up = False | |
| return [], "down" if changed else None | |
| status = None if self.up else "up" | |
| self.up, self.fails = True, 0 | |
| first = self.cursor is None | |
| new_epoch = self.epoch is not None and d["epoch"] != self.epoch | |
| self.cursor, self.epoch = d["next"], d["epoch"] | |
| if first: | |
| self.start = d["next"] | |
| return [], status | |
| if new_epoch: | |
| self.card, self.card_src, self.reply = None, None, False | |
| lines = self.lines(d["events"], d["next"] - len(d["events"])) | |
| return (lines[-2:] if new_epoch else lines), status | |
| def lines(self, events: list, first_index: int | None = None) -> list[Line]: | |
| """moth's lines in events; first_index: the log index of events[0] (for Line.index).""" | |
| out = [] | |
| for j, ev in enumerate(events): | |
| if not isinstance(ev, dict): | |
| continue | |
| if "agent" in ev: | |
| if not ev.get("skipped"): | |
| self.card = caption_for(ev) | |
| self.card_src = (_plain(ev.get("source"), 32), _plain(ev.get("label"))) | |
| self.reply = False | |
| elif "you" in ev: | |
| self.card, self.card_src, self.reply = None, None, True # what follows is moth answering you | |
| elif isinstance(ev.get("say"), str) and ev["say"].strip(): | |
| # the session moth answered: the say's own "re" tag (newer servers; a batch's last card may be | |
| # another session's), else the card before it | |
| re_src = _plain((ev.get("re") or {}).get("source") if isinstance(ev.get("re"), dict) else None, 32) | |
| src, label = self.card_src or (None, None) | |
| cap = self.card | |
| if re_src and re_src != src: | |
| src, label, cap = re_src, None, caption_for({"source": re_src}) | |
| out.append(Line(ev["say"], cap, src, label, self.reply and self.card is None, | |
| None if first_index is None else first_index + j)) | |
| self.card, self.card_src = None, None | |
| return out | |
| def send_chat(url: str, token_file: str, text: str) -> str | None: | |
| """POST what the user typed; None when sent, else a short reason (shown in the bubble). The reply's events are | |
| ignored on purpose: the poll shows them.""" | |
| tok = read_token(token_file) | |
| if not tok: | |
| return f"i can't find my token ({token_file}), so i can't hear you" | |
| try: | |
| http_json(f"{url.rstrip('/')}/api/chat", tok, {"text": text.strip()[:500]}, timeout=CHAT_TIMEOUT) | |
| except urllib.error.HTTPError as e: | |
| return "my token doesn't match moth's (~/.moth/token)" if e.code == 401 else f"moth said no ({e.code})" | |
| except Exception: # noqa: BLE001 | |
| return "i couldn't reach moth" | |
| return None | |
| # --- speech bubbles (stdlib) ---------------------------------------------------------------------------------- | |
| def wrap_text(text: str, width: int = WRAP) -> list[str]: | |
| """moth's lowercase, except inside `backticks` (commands and names to copy: `pip install -U ...`).""" | |
| flat = " ".join(clean(text).split()) | |
| flat = re.sub(r"`[^`]*`|[^`]+", lambda m: m.group(0) if m.group(0).startswith("`") else m.group(0).lower(), flat) | |
| return textwrap.wrap(flat, width, break_on_hyphens=False) or ["..."] | |
| def bubble_ttl(lines: list[str]) -> float: | |
| """6 s for a word, 10 s for a full bubble.""" | |
| n = sum(len(x) for x in lines) | |
| return round(min(10.0, max(6.0, 5.0 + n / 30)), 2) | |
| class Bubble: | |
| lines: list[str] | |
| caption: str | None | |
| ttl: float | |
| shown: float | None = None | |
| class BubbleQueue: | |
| """One bubble at a time; the next one waits until the current one has faded. A long line becomes up to | |
| MAX_PAGES bubbles. At most MAX_QUEUED wait (a burst drops the oldest).""" | |
| FADE_IN, FADE_OUT = 0.25, 0.8 | |
| def __init__(self, clock=time.monotonic): | |
| self.clock = clock | |
| self.waiting: list[Bubble] = [] | |
| self.now_showing: Bubble | None = None | |
| def push(self, text: str, caption: str | None = None, ttl: float | None = None): | |
| lines = wrap_text(text) | |
| pages = [lines[i:i + PAGE_LINES] for i in range(0, len(lines), PAGE_LINES)] | |
| if len(pages) > MAX_PAGES: | |
| pages = pages[:MAX_PAGES] | |
| last = pages[-1][-1] | |
| pages[-1][-1] = (last if len(last) < WRAP else last[:WRAP - 1].rstrip()) + "…" | |
| for i, page in enumerate(pages): | |
| self.waiting.append(Bubble(page, caption if i == 0 else None, ttl or bubble_ttl(page))) | |
| del self.waiting[:-MAX_QUEUED] | |
| def fade(self, text: str): | |
| """Let the bubble saying `text` go now (fades out if showing; dropped if waiting).""" | |
| lines = wrap_text(text) | |
| self.waiting = [b for b in self.waiting if b.lines != lines] | |
| b = self.now_showing | |
| if b is not None and b.lines == lines and b.shown is not None: | |
| b.ttl = min(b.ttl, self.clock() - b.shown + self.FADE_OUT) | |
| def clear(self): | |
| self.waiting.clear() | |
| self.now_showing = None | |
| def current(self) -> tuple[Bubble | None, float]: | |
| """(the bubble to draw, its opacity 0..1).""" | |
| now = self.clock() | |
| b = self.now_showing | |
| if b is not None and now - b.shown >= b.ttl: | |
| b = self.now_showing = None | |
| if b is None and self.waiting: | |
| b = self.now_showing = self.waiting.pop(0) | |
| b.shown = now | |
| if b is None: | |
| return None, 0.0 | |
| age = now - b.shown | |
| alpha = min(1.0, age / self.FADE_IN, (b.ttl - age) / self.FADE_OUT) | |
| return b, max(0.0, alpha) | |
| # --- missed reactions, mutes, quiet hours, screens (stdlib) --------------------------------------------------- | |
| MISSED_KEEP = 10 # "what did i miss" shows this many of moth's latest reactions | |
| SETTINGS_EVERY = 30.0 # s between rereading the pet's live settings (config.PET_LIVE) from ~/.moth/config.json | |
| WHY = {"hidden": "while hidden", "muted": "while muted", "asleep": "while asleep"} | |
| def in_quiet(spec, minute: int | None = None) -> bool: | |
| """Whether `minute` of the day (default: now, local time) is inside quiet_hours `spec` ("23:00-08:00" crosses | |
| midnight: 23:00 up to, not including, 08:00). "off" or a bad spec: never.""" | |
| span = config.quiet_span(spec) | |
| if span is None: | |
| return False | |
| if minute is None: | |
| t = time.localtime() | |
| minute = t.tm_hour * 60 + t.tm_min | |
| start, end = span | |
| return start <= minute < end if start < end else (minute >= start or minute < end) | |
| def ago(seconds: float | None) -> str: | |
| """'just now', '4 min ago', '2 h ago', '3 d ago'; None (from the log before the pet started): 'earlier'.""" | |
| if seconds is None: | |
| return "earlier" | |
| s = max(0.0, seconds) | |
| if s < 45: | |
| return "just now" | |
| if s < 3600: | |
| return f"{max(1, round(s / 60))} min ago" | |
| if s < 86400: | |
| return f"{int(s // 3600)} h ago" | |
| return f"{int(s // 86400)} d ago" | |
| def source_muted(source: str | None, muted) -> bool: | |
| """A reaction from `source` is muted (muted_sources); an unknown source (no token, an old server) never is.""" | |
| return bool(source) and source in (muted or ()) | |
| class Missed: | |
| """moth's last MISSED_KEEP reactions for "what did i miss": text, harness, project, wall-clock time (None for | |
| the server's history from before the pet started) and why it wasn't shown (WHY key, None when it was).""" | |
| def __init__(self, keep: int = MISSED_KEEP, clock=time.time): | |
| from collections import deque | |
| self.items: deque = deque(maxlen=keep) | |
| self.clock = clock | |
| def add(self, text: str, source: str | None = None, label: str | None = None, why: str | None = None, | |
| t: float | None = None): | |
| self.items.append({"text": text, "source": source, "label": label, "why": why, | |
| "t": self.clock() if t is None else t}) | |
| def seed(self, lines): | |
| """The server's history (Lines, oldest first) in front of what came since, untimed.""" | |
| old = [{"text": x.text, "source": x.source, "label": x.label, "why": None, "t": None} for x in lines] | |
| keep = self.items.maxlen | |
| merged = (old + list(self.items))[-keep:] | |
| self.items.clear() | |
| self.items.extend(merged) | |
| def rows(self, now: float | None = None) -> list[tuple[str, str, str | None]]: | |
| """Newest first: (head, text, mark) with head 'claude · moth-repo · 4 min ago' and mark 'while hidden' etc.""" | |
| now = self.clock() if now is None else now | |
| out = [] | |
| for it in reversed(self.items): | |
| src = it["source"] | |
| parts = [NAMES.get(src, src)] if src else [] | |
| if it["label"]: | |
| parts.append(it["label"]) | |
| parts.append(ago(None if it["t"] is None else now - it["t"])) | |
| out.append((" · ".join(parts), it["text"], WHY.get(it["why"]))) | |
| return out | |
| def pick_bounds(pin, screens, x: float, y: float): | |
| """(available rect, screen name) for the pet: the pinned screen (pet_screen, if it is connected), else the one | |
| the point is on, else the nearest (a gap between screens of different sizes used to mean the primary). | |
| screens: [(name, (x, y, w, h))], the primary first.""" | |
| if not screens: | |
| return None, None | |
| if pin and pin != "any": | |
| for name, g in screens: | |
| if name == pin: | |
| return g, name | |
| for name, g in screens: | |
| if g[0] <= x < g[0] + g[2] and g[1] <= y < g[1] + g[3]: | |
| return g, name | |
| def dist(g): | |
| dx = max(g[0] - x, 0, x - (g[0] + g[2])) | |
| dy = max(g[1] - y, 0, y - (g[1] + g[3])) | |
| return dx * dx + dy * dy | |
| name, g = min(screens, key=lambda s: dist(s[1])) | |
| return g, name | |
| def fullscreen_on(snap) -> bool: | |
| """Whether a window-system snapshot says another app is fullscreen on the pet's screen. snap (from a probe): | |
| {"rect": the active window (x, y, w, h), "states": {"fullscreen", "maximized", ...}, "types": {"desktop", | |
| "dock", ...}, "managed": a normal app window (geometry alone may decide), "pt": the pet's centre, "screen": the | |
| pet screen's (w, h)}, all in the window system's units; None / {} = can't tell = no.""" | |
| if not snap or not snap.get("rect") or snap.get("pt") is None: | |
| return False | |
| if set(snap.get("types") or ()) & {"desktop", "dock"}: # the desktop itself is screen-sized | |
| return False | |
| x, y, w, h = snap["rect"] | |
| px, py = snap["pt"] | |
| if not (x <= px < x + w and y <= py < y + h): # fullscreen on another screen: the pet stays | |
| return False | |
| states = set(snap.get("states") or ()) | |
| if "fullscreen" in states: | |
| return True | |
| if states & {"maximized", "maximized_vert", "maximized_horz"} or not snap.get("managed"): | |
| return False | |
| sw, sh = snap.get("screen") or (1 << 30, 1 << 30) | |
| return w >= sw and h >= sh # a borderless window covering the whole screen (some games skip the state) | |
| # --- "your agent is done": the hop (and the chirp) --------------------------------------------------------------- | |
| HOP_S = 0.9 # two hops: a big one (0.5 s) and a small bounce (0.4 s), wings fluttering in the air | |
| def hop(t: float): | |
| """(lift in pet sizes, wing frame) t s into the done-signal, or None outside it.""" | |
| if not 0.0 <= t < HOP_S: | |
| return None | |
| if t < 0.5: | |
| k, top = t / 0.5, 0.34 | |
| else: | |
| k, top = (t - 0.5) / 0.4, 0.16 | |
| lift = top * 4 * k * (1 - k) | |
| return lift, (FLAP[int(t / 0.03) % len(FLAP)] if lift > 0.015 else 0) # 0 = OPEN: wings out on landing | |
| CHIRP_WAV = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "sounds", "chirp.wav") | |
| class Chirp: | |
| """The soft done-sound (moth/assets/sounds/chirp.wav: Kenney, CC0; CREDITS.md). QtMultimedia's QSoundEffect | |
| when PySide6 has it (PySide6-Essentials doesn't), else a stdlib way: winsound (Windows), afplay (macOS), paplay | |
| / pw-play / aplay (Linux). Nothing found or anything failing: silent. play() never raises or blocks.""" | |
| def __init__(self, path: str = CHIRP_WAV): | |
| self.path = path | |
| self.how: str | None = None # "qt", "winsound", a player's path, or "" = none | |
| self.effect = None | |
| self.proc = None | |
| def _pick(self) -> str: | |
| try: | |
| from PySide6.QtCore import QUrl | |
| from PySide6.QtMultimedia import QSoundEffect | |
| self.effect = QSoundEffect() | |
| self.effect.setSource(QUrl.fromLocalFile(self.path)) | |
| self.effect.setVolume(0.6) | |
| return "qt" | |
| except Exception: # noqa: BLE001 not in PySide6-Essentials | |
| self.effect = None | |
| if sys.platform.startswith("win"): | |
| return "winsound" | |
| import shutil | |
| for cmd in (("afplay",) if sys.platform == "darwin" else ("paplay", "pw-play", "aplay")): | |
| found = shutil.which(cmd) | |
| if found: | |
| return found | |
| return "" | |
| def play(self) -> bool: | |
| try: | |
| if not os.path.isfile(self.path): | |
| return False | |
| if self.how is None: | |
| self.how = self._pick() | |
| if self.how == "qt": | |
| self.effect.play() | |
| elif self.how == "winsound": | |
| import winsound | |
| winsound.PlaySound(self.path, winsound.SND_FILENAME | winsound.SND_ASYNC | winsound.SND_NODEFAULT) | |
| elif self.how: | |
| if self.proc is not None and self.proc.poll() is None: | |
| return False # still chirping: no pile-up | |
| args = [self.how, "-q", self.path] if self.how.endswith("aplay") else [self.how, self.path] | |
| self.proc = subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL) | |
| else: | |
| return False | |
| return True | |
| except Exception: # noqa: BLE001 a sound is a nicety | |
| return False | |
| # --- is another app fullscreen? (window-system probes, ctypes only) --------------------------------------------- | |
| FS_EVERY = 1.5 # s between asking the window system (from the pet's own tick, no extra timer) | |
| class X11Probe: | |
| """X11 (and XWayland) through libX11 with ctypes: the root's _NET_ACTIVE_WINDOW, its _NET_WM_STATE and | |
| _NET_WM_WINDOW_TYPE, its geometry in root coordinates, and whether it is a managed client (_NET_CLIENT_LIST). | |
| One display connection, from the GUI thread only. A no-op X error handler: the active window may vanish between | |
| two calls, and Xlib's default handler exits the process on BadWindow. Under XWayland a native-Wayland window | |
| is not an X window: the active window is then a property-less placeholder (KWin) or none, so: not fullscreen.""" | |
| STATE_NAMES = {"_NET_WM_STATE_FULLSCREEN": "fullscreen", "_NET_WM_STATE_MAXIMIZED_VERT": "maximized_vert", | |
| "_NET_WM_STATE_MAXIMIZED_HORZ": "maximized_horz", "_NET_WM_STATE_HIDDEN": "hidden"} | |
| TYPE_NAMES = {"_NET_WM_WINDOW_TYPE_DESKTOP": "desktop", "_NET_WM_WINDOW_TYPE_DOCK": "dock", | |
| "_NET_WM_WINDOW_TYPE_NORMAL": "normal"} | |
| def __init__(self): | |
| import ctypes | |
| import ctypes.util | |
| c = ctypes | |
| x = c.CDLL(c.util.find_library("X11") or "libX11.so.6") | |
| vp, ul = c.c_void_p, c.c_ulong | |
| x.XOpenDisplay.restype, x.XOpenDisplay.argtypes = vp, [c.c_char_p] | |
| x.XDefaultRootWindow.restype, x.XDefaultRootWindow.argtypes = ul, [vp] | |
| x.XInternAtom.restype, x.XInternAtom.argtypes = ul, [vp, c.c_char_p, c.c_int] | |
| x.XGetWindowProperty.restype = c.c_int | |
| x.XGetWindowProperty.argtypes = [vp, ul, ul, c.c_long, c.c_long, c.c_int, ul, c.POINTER(ul), | |
| c.POINTER(c.c_int), c.POINTER(ul), c.POINTER(ul), | |
| c.POINTER(c.POINTER(c.c_ubyte))] | |
| x.XFree.restype, x.XFree.argtypes = c.c_int, [vp] | |
| x.XGetGeometry.restype = c.c_int | |
| x.XGetGeometry.argtypes = [vp, ul, c.POINTER(ul), c.POINTER(c.c_int), c.POINTER(c.c_int), | |
| c.POINTER(c.c_uint), c.POINTER(c.c_uint), c.POINTER(c.c_uint), c.POINTER(c.c_uint)] | |
| x.XTranslateCoordinates.restype = c.c_int | |
| x.XTranslateCoordinates.argtypes = [vp, ul, ul, c.c_int, c.c_int, c.POINTER(c.c_int), c.POINTER(c.c_int), | |
| c.POINTER(ul)] | |
| self._handler = c.CFUNCTYPE(c.c_int, vp, vp)(lambda _d, _e: 0) # kept: Xlib holds only the pointer | |
| x.XSetErrorHandler.restype, x.XSetErrorHandler.argtypes = vp, [vp] | |
| x.XSetErrorHandler(c.cast(self._handler, vp)) | |
| self.c, self.x = c, x | |
| self.dpy = x.XOpenDisplay(None) | |
| if not self.dpy: | |
| raise OSError("can't open the X display") | |
| self.root = x.XDefaultRootWindow(self.dpy) | |
| atom = lambda n: x.XInternAtom(self.dpy, n.encode(), 0) # noqa: E731 | |
| self.A = {n: atom(n) for n in ("_NET_ACTIVE_WINDOW", "_NET_WM_STATE", "_NET_WM_WINDOW_TYPE", | |
| "_NET_CLIENT_LIST", *self.STATE_NAMES, *self.TYPE_NAMES)} | |
| self.names = {self.A[n]: v for n, v in {**self.STATE_NAMES, **self.TYPE_NAMES}.items()} | |
| def _prop(self, win: int, name: str, kind: int, n: int = 64) -> list[int]: | |
| """A format-32 property as ints (Xlib hands those back as C longs, whatever their width).""" | |
| c = self.c | |
| t, f, nitems, after = c.c_ulong(), c.c_int(), c.c_ulong(), c.c_ulong() | |
| data = c.POINTER(c.c_ubyte)() | |
| ok = self.x.XGetWindowProperty(self.dpy, win, self.A[name], 0, n, 0, kind, c.byref(t), c.byref(f), | |
| c.byref(nitems), c.byref(after), c.byref(data)) | |
| try: | |
| if ok != 0 or f.value != 32 or not data: | |
| return [] | |
| return list(c.cast(data, c.POINTER(c.c_ulong))[:nitems.value]) | |
| finally: | |
| if data: | |
| self.x.XFree(data) | |
| def snapshot(self, ctx: dict) -> dict | None: | |
| c = self.c | |
| active = self._prop(self.root, "_NET_ACTIVE_WINDOW", 33) # XA_WINDOW | |
| if not active or not active[0]: | |
| return None | |
| win = active[0] | |
| r, gx, gy, w, h, bw, depth = c.c_ulong(), c.c_int(), c.c_int(), c.c_uint(), c.c_uint(), c.c_uint(), c.c_uint() | |
| if not self.x.XGetGeometry(self.dpy, win, c.byref(r), c.byref(gx), c.byref(gy), c.byref(w), c.byref(h), | |
| c.byref(bw), c.byref(depth)): | |
| return None | |
| ax, ay, child = c.c_int(), c.c_int(), c.c_ulong() | |
| if not self.x.XTranslateCoordinates(self.dpy, win, self.root, 0, 0, c.byref(ax), c.byref(ay), c.byref(child)): | |
| return None | |
| states = {self.names.get(a) for a in self._prop(win, "_NET_WM_STATE", 4)} - {None} # XA_ATOM | |
| types = {self.names.get(a) for a in self._prop(win, "_NET_WM_WINDOW_TYPE", 4)} - {None} | |
| # only the geometry fallback needs to know it's a real app window (not a placeholder or the desktop) | |
| managed = "fullscreen" not in states and win in self._prop(self.root, "_NET_CLIENT_LIST", 33, 4096) | |
| if "maximized_vert" in states and "maximized_horz" in states: | |
| states.add("maximized") | |
| return {"rect": (ax.value, ay.value, w.value, h.value), "states": states, "types": types, | |
| "managed": managed, "pt": ctx.get("pt"), "screen": ctx.get("screen")} | |
| class WinProbe: | |
| """Windows through ctypes (user32 / shell32): the foreground window's rect against the monitor it is on (it must | |
| be the pet's monitor, through MonitorFromWindow on both), SHQueryUserNotificationState (3 = a D3D fullscreen | |
| app, 4 = presentation mode) and IsZoomed. The shell's desktop (GetShellWindow, Progman / WorkerW) never counts.""" | |
| def __init__(self): | |
| import ctypes | |
| from ctypes import wintypes | |
| self.c, self.wt = ctypes, wintypes | |
| self.user32, self.shell32 = ctypes.WinDLL("user32"), ctypes.WinDLL("shell32") | |
| u = self.user32 | |
| u.GetForegroundWindow.restype = wintypes.HWND | |
| u.GetShellWindow.restype = wintypes.HWND | |
| u.MonitorFromWindow.restype, u.MonitorFromWindow.argtypes = wintypes.HMONITOR, [wintypes.HWND, wintypes.DWORD] | |
| u.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.RECT)] | |
| u.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] | |
| u.IsZoomed.argtypes = [wintypes.HWND] | |
| class MONITORINFO(ctypes.Structure): | |
| _fields_ = [("cbSize", wintypes.DWORD), ("rcMonitor", wintypes.RECT), ("rcWork", wintypes.RECT), | |
| ("dwFlags", wintypes.DWORD)] | |
| self.MONITORINFO = MONITORINFO | |
| u.GetMonitorInfoW.argtypes = [wintypes.HMONITOR, ctypes.POINTER(MONITORINFO)] | |
| def snapshot(self, ctx: dict) -> dict | None: | |
| c, wt, u = self.c, self.wt, self.user32 | |
| fg = u.GetForegroundWindow() | |
| pet = ctx.get("hwnd") | |
| if not fg or not pet: | |
| return None | |
| cls = c.create_unicode_buffer(64) | |
| u.GetClassNameW(fg, cls, 64) | |
| types = {"desktop"} if fg == u.GetShellWindow() or cls.value in ("Progman", "WorkerW") else set() | |
| mon = u.MonitorFromWindow(fg, 2) # MONITOR_DEFAULTTONEAREST | |
| if not mon or mon != u.MonitorFromWindow(pet, 2): | |
| return None # fullscreen on another monitor: the pet stays | |
| mi = self.MONITORINFO() | |
| mi.cbSize = c.sizeof(mi) | |
| r = wt.RECT() | |
| if not u.GetMonitorInfoW(mon, c.byref(mi)) or not u.GetWindowRect(fg, c.byref(r)): | |
| return None | |
| m = mi.rcMonitor | |
| states = set() | |
| q = c.c_int(0) | |
| try: | |
| if self.shell32.SHQueryUserNotificationState(c.byref(q)) == 0 and q.value in (3, 4): | |
| states.add("fullscreen") | |
| except Exception: # noqa: BLE001 | |
| pass | |
| if u.IsZoomed(fg): | |
| states.add("maximized") | |
| return {"rect": (r.left, r.top, r.right - r.left, r.bottom - r.top), "states": states, "types": types, | |
| "managed": True, "pt": ((m.left + m.right) // 2, (m.top + m.bottom) // 2), | |
| "screen": (m.right - m.left, m.bottom - m.top)} | |
| class MacProbe: | |
| """macOS through ctypes (CoreGraphics / CoreFoundation): the frontmost normal window (layer 0, not this | |
| process: CGWindowListCopyWindowInfo lists on-screen windows front to back) against the pet's screen, in the same | |
| points Qt uses. A fullscreen app covers the menu bar too; a zoomed window doesn't. Bounds and layer need no | |
| Screen Recording permission (window names would).""" | |
| def __init__(self): | |
| import ctypes | |
| import ctypes.util | |
| c = ctypes | |
| self.c = c | |
| self.cg = c.CDLL(c.util.find_library("CoreGraphics") | |
| or "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics") | |
| self.cf = c.CDLL(c.util.find_library("CoreFoundation") | |
| or "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation") | |
| vp = c.c_void_p | |
| self.cg.CGWindowListCopyWindowInfo.restype = vp | |
| self.cg.CGWindowListCopyWindowInfo.argtypes = [c.c_uint32, c.c_uint32] | |
| class CGRect(c.Structure): | |
| _fields_ = [("x", c.c_double), ("y", c.c_double), ("w", c.c_double), ("h", c.c_double)] | |
| self.CGRect = CGRect | |
| self.cg.CGRectMakeWithDictionaryRepresentation.restype = c.c_bool | |
| self.cg.CGRectMakeWithDictionaryRepresentation.argtypes = [vp, c.POINTER(CGRect)] | |
| cf = self.cf | |
| cf.CFArrayGetCount.restype, cf.CFArrayGetCount.argtypes = c.c_long, [vp] | |
| cf.CFArrayGetValueAtIndex.restype, cf.CFArrayGetValueAtIndex.argtypes = vp, [vp, c.c_long] | |
| cf.CFDictionaryGetValue.restype, cf.CFDictionaryGetValue.argtypes = vp, [vp, vp] | |
| cf.CFNumberGetValue.restype, cf.CFNumberGetValue.argtypes = c.c_bool, [vp, c.c_int, vp] | |
| cf.CFStringCreateWithCString.restype = vp | |
| cf.CFStringCreateWithCString.argtypes = [vp, c.c_char_p, c.c_uint32] | |
| cf.CFRelease.argtypes = [vp] | |
| key = lambda s: cf.CFStringCreateWithCString(None, s, 0x08000100) # noqa: E731 UTF-8; kept for good | |
| self.k_layer, self.k_bounds, self.k_pid = key(b"kCGWindowLayer"), key(b"kCGWindowBounds"), key(b"kCGWindowOwnerPID") | |
| self.pid = os.getpid() | |
| def _int(self, d, k) -> int | None: | |
| v = self.cf.CFDictionaryGetValue(d, k) | |
| out = self.c.c_int() | |
| return out.value if v and self.cf.CFNumberGetValue(v, 9, self.c.byref(out)) else None # kCFNumberIntType | |
| def snapshot(self, ctx: dict) -> dict | None: | |
| arr = self.cg.CGWindowListCopyWindowInfo(1 | 16, 0) # on screen only, no desktop elements | |
| if not arr: | |
| return None | |
| try: | |
| for i in range(self.cf.CFArrayGetCount(arr)): | |
| d = self.cf.CFArrayGetValueAtIndex(arr, i) | |
| if self._int(d, self.k_layer) != 0 or self._int(d, self.k_pid) == self.pid: | |
| continue | |
| r = self.CGRect() | |
| b = self.cf.CFDictionaryGetValue(d, self.k_bounds) | |
| if not b or not self.cg.CGRectMakeWithDictionaryRepresentation(b, self.c.byref(r)): | |
| return None | |
| return {"rect": (r.x, r.y, r.w, r.h), "states": set(), "types": set(), "managed": True, | |
| "pt": ctx.get("lpt"), "screen": ctx.get("lscreen")} | |
| return None | |
| finally: | |
| self.cf.CFRelease(arr) | |
| def make_probe(platform_name: str): | |
| """The fullscreen probe for Qt's platform plugin, or None (native Wayland, offscreen, a missing library: | |
| the pet then never hides).""" | |
| kind = {"xcb": X11Probe, "windows": WinProbe, "cocoa": MacProbe}.get(platform_name) | |
| if kind is None: | |
| return None | |
| try: | |
| return kind() | |
| except Exception: # noqa: BLE001 | |
| return None | |
| # --- flight (stdlib) ------------------------------------------------------------------------------------------ | |
| FLY, LAND, REST, DOZE, HELD = "fly", "land", "rest", "doze", "held" | |
| class Noise: | |
| """Smooth 1-D noise in about [-1, 1]: three sines at unrelated frequencies with random phases.""" | |
| def __init__(self, rng: random.Random, base: float): | |
| self.parts = [(base * m, rng.uniform(0, math.tau), w) for m, w in ((1.0, .55), (2.31, .3), (4.77, .15))] | |
| def __call__(self, t: float) -> float: | |
| return sum(w * math.sin(f * t + p) for f, p, w in self.parts) | |
| class Wander: | |
| """Where the moth is and what it is doing. Positions are the moth's centre in screen pixels; bounds is the | |
| available area (x, y, w, h) of the screen it is on. step(dt, now, cursor) moves it on. | |
| fly: drifts on smoothed noise, steers away from the edges, is drawn to the cursor (orbits it rather than sitting | |
| on it) while the cursor moves. After 25-70 s it picks a spot on an edge (land) and rests there 10-40 s. No new | |
| line and no click for DOZE_AFTER s, or asleep (menu / moth down): lands and dozes. excite(): flutters for a | |
| moment (and takes off). wander=False: sits in the bottom-right corner and only flutters there.""" | |
| SPEED = 70.0 | |
| def __init__(self, bounds, size: int = 56, wander: bool = True, rng: random.Random | None = None, | |
| now: float = 0.0): | |
| self.rng = rng or random.Random() | |
| self.size, self.wander = size, wander | |
| self.bounds = tuple(bounds) | |
| self.turn, self.pace = Noise(self.rng, 0.35), Noise(self.rng, 0.21) | |
| self.heading = self.rng.uniform(0, math.tau) | |
| self.vx = self.vy = 0.0 | |
| self.state, self.since = REST, now | |
| self.spot: tuple[float, float] | None = None | |
| self.doze_on_landing = False | |
| self.rest_for = self.rng.uniform(4, 10) | |
| self.fly_for = 0.0 | |
| self.excited_until = -1.0 | |
| self.idle_since = now | |
| self.asleep = False # menu "sleep" or moth down: dozes until woken | |
| self.cursor = None | |
| self.cursor_seen = -1e9 | |
| self.attend: tuple[float, float] | None = None # hover near this point (the input box is open) | |
| self.x, self.y = self.home() | |
| # -- places | |
| def home(self) -> tuple[float, float]: | |
| bx, by, bw, bh = self.bounds | |
| return bx + bw - self.size * 1.2, by + bh - self.size * 0.38 | |
| def landing_spot(self) -> tuple[float, float]: | |
| bx, by, bw, bh = self.bounds | |
| s, r = self.size, self.rng.random() | |
| if r < 0.45: # on top of the panel / bottom edge | |
| return self.rng.uniform(bx + s, bx + bw - s), by + bh - s * 0.38 | |
| if r < 0.85: # clinging to a side, head up (a moth on a wall seen from the front is the resting delta) | |
| x = bx + s * 0.44 if self.rng.random() < 0.5 else bx + bw - s * 0.44 | |
| return x, self.rng.uniform(by + bh * 0.25, by + bh - s) | |
| return self.rng.uniform(bx + s, bx + bw - s), by + s * 0.42 # hanging from the top edge | |
| def nearest_floor(self) -> tuple[float, float]: | |
| bx, by, bw, bh = self.bounds | |
| return min(max(self.x, bx + self.size), bx + bw - self.size), by + bh - self.size * 0.38 | |
| # -- events | |
| def excited(self) -> bool: | |
| return self._now < self.excited_until | |
| _now = 0.0 | |
| def excite(self, now: float, seconds: float = 2.5): | |
| self._now = now | |
| self.idle_since = now | |
| self.excited_until = now + seconds | |
| if self.state in (REST, DOZE) and not self.asleep and self.wander: | |
| self.take_off(now) | |
| elif self.state == DOZE and not self.asleep: | |
| self.state, self.since = REST, now | |
| def poke(self, now: float): | |
| """A click / the user did something: wakes it (unless put to sleep from the menu).""" | |
| self.idle_since = now | |
| if self.state == DOZE and not self.asleep: | |
| self.state, self.since = REST, now | |
| self.rest_for = self.rng.uniform(2, 6) | |
| def sleep(self, now: float, on: bool = True): | |
| self.asleep = on | |
| self.idle_since = now | |
| if on and self.state in (FLY, LAND): | |
| self.land(now, self.nearest_floor() if self.wander else self.home(), doze=True) | |
| elif on and self.state == REST: | |
| self.state, self.since = DOZE, now | |
| elif not on and self.state == DOZE: | |
| self.state, self.since = REST, now | |
| self.rest_for = self.rng.uniform(1, 4) | |
| def grab(self, now: float): | |
| self.state, self.since = HELD, now | |
| self.idle_since = now | |
| def drop(self, now: float, x: float, y: float): | |
| self.x, self.y = x, y | |
| self.idle_since = now | |
| bx, by, bw, bh = self.bounds | |
| near = min(x - bx, bx + bw - x, y - by, by + bh - y) < self.size * 0.8 | |
| if not self.wander or near or self.asleep: | |
| self.state, self.since = (DOZE if self.asleep else REST), now | |
| self.rest_for = self.rng.uniform(10, 30) | |
| else: | |
| self.take_off(now) | |
| def take_off(self, now: float): | |
| self.state, self.since = FLY, now | |
| self.fly_for = self.rng.uniform(25, 70) | |
| bx, by, bw, bh = self.bounds | |
| self.heading = math.atan2((by + bh / 2) - self.y, (bx + bw / 2) - self.x) + self.rng.uniform(-.8, .8) | |
| self.vx, self.vy = math.cos(self.heading) * 60, math.sin(self.heading) * 60 - 40 | |
| def land(self, now: float, spot, doze: bool = False): | |
| self.state, self.since, self.spot, self.doze_on_landing = LAND, now, spot, doze | |
| def move_to(self, bounds, now: float): | |
| """Onto another screen (the pinned one, pet_screen): it shows up in that screen's home corner and rests.""" | |
| self.bounds = tuple(bounds) | |
| self.x, self.y = self.home() | |
| self.vx = self.vy = 0.0 | |
| if self.state != HELD: | |
| self.state, self.since = (DOZE if self.asleep else REST), now | |
| self.rest_for = self.rng.uniform(2, 6) | |
| def see_cursor(self, now: float, cursor): | |
| if cursor is not None and cursor != self.cursor: | |
| self.cursor, self.cursor_seen = cursor, now | |
| # -- the step | |
| def step(self, dt: float, now: float, cursor=None, bounds=None): | |
| self._now = now | |
| dt = min(max(dt, 0.0), 0.25) # a stall (suspend, drag) must not fling the moth across the screen | |
| if bounds is not None: | |
| self.bounds = tuple(bounds) | |
| self.see_cursor(now, cursor) | |
| idle = now - self.idle_since >= DOZE_AFTER | |
| st, t_in = self.state, now - self.since | |
| if st == HELD: | |
| return | |
| if st == REST: | |
| if self.asleep or (idle and not self.excited): | |
| self.state, self.since = DOZE, now | |
| elif self.wander and (self.excited or t_in > self.rest_for) and self.attend is None: | |
| self.take_off(now) | |
| elif self.attend is not None and self.wander: | |
| self.state, self.since = FLY, now | |
| return | |
| if st == DOZE: | |
| return | |
| if not self.wander and st == FLY: | |
| st = self.state = LAND | |
| self.spot = self.home() | |
| if st == FLY: | |
| if self.asleep or (idle and not self.excited and self.attend is None): | |
| self.land(now, self.nearest_floor(), doze=True) | |
| elif t_in > self.fly_for and not self.excited and self.attend is None: | |
| self.land(now, self.landing_spot()) | |
| self._fly(dt, now) | |
| self._clamp() | |
| def _fly(self, dt: float, now: float): | |
| bx, by, bw, bh = self.bounds | |
| ex = self.excited | |
| if self.state == LAND or self.attend is not None: | |
| tx, ty = self.spot if self.state == LAND else self.attend | |
| dx, dy = tx - self.x, ty - self.y | |
| dist = math.hypot(dx, dy) | |
| if self.state == LAND and dist < 3.0: | |
| self.x, self.y, self.vx, self.vy = tx, ty, 0.0, 0.0 | |
| self.state, self.since = (DOZE if self.doze_on_landing or self.asleep else REST), now | |
| self.rest_for = self.rng.uniform(10, 40) | |
| return | |
| speed = min(self.SPEED * 1.3, dist * 1.6) + (0 if self.state == LAND else 12 * self.turn(now * 3)) | |
| want = (dx / (dist or 1) * speed, dy / (dist or 1) * speed) | |
| if self.attend is not None and self.state != LAND: # hover: a lazy figure-eight around the point | |
| want = (want[0] + 25 * math.sin(now * 1.7), want[1] + 14 * math.sin(now * 3.4)) | |
| k = 3.0 | |
| else: | |
| self.heading += self.turn(now) * (4.0 if ex else 1.6) * dt | |
| speed = self.SPEED * (1.0 + 0.35 * self.pace(now)) * (1.8 if ex else 1.0) | |
| wx, wy = math.cos(self.heading) * speed, math.sin(self.heading) * speed | |
| # moths love light: drawn to the cursor while it moves, circling it instead of sitting on it | |
| c, w = self.cursor, math.exp(-(now - self.cursor_seen) / 8.0) | |
| if c is not None and w > 0.02: | |
| dx, dy = c[0] - self.x, c[1] - self.y | |
| r = math.hypot(dx, dy) or 1.0 | |
| pull = 0.45 * w * min(1.0, max(-0.6, (r - 120) / 260)) | |
| wx += (dx / r * pull + (-dy / r) * 0.35 * w * (r < 300)) * speed | |
| wy += (dy / r * pull + (dx / r) * 0.35 * w * (r < 300)) * speed | |
| # soft walls: steer back in near the edges | |
| m = self.size * 1.5 | |
| for d, ax, ay in ((self.x - bx, 1, 0), (bx + bw - self.x, -1, 0), (self.y - by, 0, 1), | |
| (by + bh - self.y, 0, -1)): | |
| if d < m: | |
| push = (1 - max(d, 0) / m) ** 2 * speed * 2.2 | |
| wx, wy = wx + ax * push, wy + ay * push | |
| self.heading = math.atan2(wy, wx) if (wx or wy) else self.heading | |
| want, k = (wx, wy), 2.0 | |
| a = min(1.0, dt * k) | |
| self.vx += (want[0] - self.vx) * a | |
| self.vy += (want[1] - self.vy) * a | |
| self.x += self.vx * dt | |
| self.y += self.vy * dt | |
| def _clamp(self): | |
| bx, by, bw, bh = self.bounds | |
| s = self.size | |
| self.x = min(max(self.x, bx + s * 0.4), bx + bw - s * 0.4) | |
| self.y = min(max(self.y, by + s * 0.4), by + bh - s * 0.38) | |
| # -- what the painter and the timer want to know | |
| def flying(self) -> bool: | |
| return self.state in (FLY, LAND, HELD) or (self.excited and self.state == REST) | |
| def fps(self) -> int: | |
| return 30 if self.flying() else (12 if self.state == REST else 8) | |
| # --- the sprite (Qt) ------------------------------------------------------------------------------------------ | |
| # The moth is real art, not drawn here: "202206 Silkmoth adult female icon" by DataBase Center for Life Science | |
| # (DBCLS), https://commons.wikimedia.org/wiki/File:202206_Silkmoth_adult_female_icon.svg, used under CC BY 4.0. | |
| # moth/assets/ holds the original SVG (unmodified) and CREDITS.md. Its wings and body are one outline path, so the | |
| # render is cut along the body instead: BODY_LEFT is the left half of the body's outline (head, the ring of dots | |
| # round the thorax, the abdomen) in the SVG's 1000x1000 units, mirrored for the right half. Each wing is the art on | |
| # its side of the centre line minus the body; the body layer is the art inside the outline plus the antennae (which | |
| # the wing layers leave out). A frame squashes each wing toward its root (PIVOT, at the body's side) and turns it | |
| # a little, then draws the body on top. Nothing is drawn new; only scaled, turned, cut and layered. | |
| ASSETS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets") | |
| ART_SVG = os.path.join(ASSETS, "silkmoth_adult_female_icon.svg") | |
| ART_BOX = (0.0, 80.0, 1000.0, 780.0) # the part of the 1000x1000 canvas the art fills (its outline: x 8-992, y 90-838) | |
| ART_ASPECT = ART_BOX[3] / ART_BOX[2] # height / width | |
| BODY_LEFT = [(500, 366), (452, 370), (440, 384), (436, 404), (424, 412), (404, 432), (394, 458), (396, 490), | |
| (408, 516), (410, 530), (404, 560), (402, 610), (406, 690), (416, 730), (426, 770), (440, 810), | |
| (456, 842), (476, 852), (500, 852)] | |
| PIVOT = (410.0, 470.0) # the left wing's root; the right wing's is mirrored | |
| # frames: (wing width as a fraction of the open wing, turn in degrees; negative sweeps the tips back) | |
| WINGS = [(1.0, 0), (0.8, -2), (0.58, -4), (0.38, -5), (0.24, -5), # 0-4: open .. up (a wingbeat) | |
| (0.62, -20), # 5: resting, wings narrow and swept back | |
| (0.5, -30)] # 6: dozing, folded down further | |
| OPEN, UP, RESTING, DOZING = 0, 4, 5, 6 | |
| FLAP = (0, 1, 2, 3, 4, 3, 2, 1) # one wingbeat | |
| FRAME_S = 0.05 # s a frame: a wingbeat in 0.4 s | |
| SHIVER_S = 0.36 # a resting wing flick: 6 frames of 0.06 s (pose_for) | |
| STILL_MS = 250 # ms between ticks while the moth sits still (no flick, fade or flight due) | |
| DOZE_FPS = 4 # frames per second of the doze z's (the moth; a skin keeps its own rate) | |
| MAX_TILT = 14.0 # degrees: leaning toward where it flies | |
| ART_CREDIT = ("moth art: \"202206 Silkmoth adult female icon\" by DBCLS (Wikimedia Commons), CC BY 4.0, " | |
| "modified (wing frames)") | |
| class Pose: | |
| """How the moth looks in one frame: which wing frame (WINGS), the lean in degrees (clockwise, toward the | |
| right), and z: the doze animation phase (None = no z's).""" | |
| frame: int = RESTING | |
| tilt: float = 0.0 | |
| z: float | None = None | |
| _ART: dict = {} | |
| def _svg_parts() -> tuple[bytes, bytes]: | |
| """(the art without its antennae, only the antennae) as SVG documents.""" | |
| if "svg" not in _ART: | |
| try: | |
| with open(ART_SVG, encoding="utf-8") as f: | |
| src = f.read() | |
| except OSError as e: | |
| raise FileNotFoundError(f"moth's art is missing: {ART_SVG}") from e | |
| antennae = re.findall(r'<path class="st3"[^>]*/>', src, re.DOTALL) | |
| if '<path' not in src or len(antennae) != 2: | |
| raise FileNotFoundError(f"moth's art isn't the expected silkmoth SVG: {ART_SVG}") | |
| wings = src | |
| for a in antennae: | |
| wings = wings.replace(a, "") | |
| only = re.sub(r'<path(?! class="st3")[^>]*/>', "", src, flags=re.DOTALL) | |
| _ART["svg"] = (wings.encode(), only.encode()) | |
| return _ART["svg"] | |
| def _body_path(half: str | None = None): | |
| """The body outline as a QPainterPath (SVG units); "left"/"right": that side of the canvas minus the body.""" | |
| from PySide6.QtCore import QPointF, QRectF | |
| from PySide6.QtGui import QPainterPath, QPolygonF | |
| body = QPainterPath() | |
| body.addPolygon(QPolygonF([QPointF(x, y) for x, y in BODY_LEFT + [(1000 - x, y) for x, y in reversed(BODY_LEFT)]])) | |
| body.closeSubpath() | |
| if half is None: | |
| return body | |
| side = QPainterPath() | |
| side.addRect(QRectF(0, 0, 500, 1000) if half == "left" else QRectF(500, 0, 500, 1000)) | |
| return side.subtracted(body) | |
| def _layer(svg: bytes, pw: int, clip=None): | |
| """The art (or part of it) rendered pw px wide, cropped to ART_BOX, clipped to `clip` (SVG units).""" | |
| from PySide6.QtCore import QByteArray, QRectF | |
| from PySide6.QtGui import QImage, QPainter | |
| from PySide6.QtSvg import QSvgRenderer | |
| r = QSvgRenderer(QByteArray(svg)) | |
| if not r.isValid(): | |
| raise FileNotFoundError(f"moth's art can't be read: {ART_SVG}") | |
| s = pw / ART_BOX[2] | |
| img = QImage(pw, round(ART_BOX[3] * s), QImage.Format.Format_ARGB32_Premultiplied) | |
| img.fill(0) | |
| p = QPainter(img) | |
| p.setRenderHint(QPainter.RenderHint.Antialiasing) | |
| p.scale(s, s) | |
| p.translate(-ART_BOX[0], -ART_BOX[1]) | |
| if clip is not None: | |
| p.setClipPath(clip) | |
| r.render(p, QRectF(0, 0, 1000, 1000)) | |
| p.end() | |
| return img | |
| def load_art(): | |
| """Check the art is there and readable (raises FileNotFoundError); renders it once, small.""" | |
| sprites(16) | |
| def sprites(pw: int) -> list: | |
| """All WINGS frames as QImages pw px wide (ART_ASPECT high). Composed at 2x from the cut layers, then scaled | |
| down smoothly. Cached per width.""" | |
| if pw not in _ART: | |
| from PySide6.QtCore import Qt | |
| from PySide6.QtGui import QImage, QPainter, QTransform | |
| big = pw * 2 | |
| wings_svg, antennae_svg = _svg_parts() | |
| left, right = _layer(wings_svg, big, _body_path("left")), _layer(wings_svg, big, _body_path("right")) | |
| body = _layer(wings_svg, big, _body_path()) | |
| p = QPainter(body) | |
| p.drawImage(0, 0, _layer(antennae_svg, big)) | |
| p.end() | |
| s = big / ART_BOX[2] | |
| out = [] | |
| for sx, turn in WINGS: | |
| img = QImage(body.size(), QImage.Format.Format_ARGB32_Premultiplied) | |
| img.fill(0) | |
| p = QPainter(img) | |
| p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) | |
| p.setRenderHint(QPainter.RenderHint.Antialiasing) | |
| for layer, side in ((left, 1), (right, -1)): | |
| px = PIVOT[0] if side == 1 else 1000 - PIVOT[0] | |
| x, y = (px - ART_BOX[0]) * s, (PIVOT[1] - ART_BOX[1]) * s | |
| t = QTransform() | |
| t.translate(x, y) | |
| t.rotate(turn * side) | |
| t.scale(sx, 1) | |
| t.translate(-x, -y) | |
| p.setTransform(t) | |
| p.drawImage(0, 0, layer) | |
| p.resetTransform() | |
| p.drawImage(0, 0, body) | |
| p.end() | |
| out.append(img.scaled(pw, round(pw * ART_ASPECT), Qt.AspectRatioMode.IgnoreAspectRatio, | |
| Qt.TransformationMode.SmoothTransformation)) | |
| _ART[pw] = out | |
| return _ART[pw] | |
| def paint_moth(p, cx: float, cy: float, width: float, pose: Pose): | |
| """Draw the moth `width` px wide centred on (cx, cy) with QPainter p, leaning pose.tilt degrees (plus the doze | |
| z's).""" | |
| from PySide6.QtCore import QPointF | |
| dpr = p.device().devicePixelRatioF() or 1.0 # HiDPI / fractional scaling: render at device pixels | |
| pw = max(8, round(width * dpr)) | |
| img = sprites(pw)[pose.frame] | |
| img.setDevicePixelRatio(pw / width) | |
| w, h = width, width * ART_ASPECT | |
| p.save() | |
| p.setRenderHint(p.RenderHint.SmoothPixmapTransform, True) | |
| p.translate(cx, cy) | |
| if pose.tilt: | |
| p.rotate(pose.tilt) | |
| p.drawImage(QPointF(-w / 2, -h / 2), img) | |
| p.restore() | |
| if pose.z is not None: | |
| paint_z(p, cx, cy, w, h, pose.z) | |
| _Z_GLYPHS: dict = {} # pixel size -> the "z" outline at (0, 0); building one runs the font's hinter, so it's cached | |
| def z_glyph(px: int): | |
| zp = _Z_GLYPHS.get(px) | |
| if zp is None: | |
| from PySide6.QtCore import QPointF | |
| from PySide6.QtGui import QFont, QPainterPath | |
| f = QFont() | |
| f.setFamilies(["Fraunces", "Georgia", "DejaVu Serif", "serif"]) | |
| f.setItalic(True) | |
| f.setWeight(QFont.Weight.DemiBold) | |
| f.setPixelSize(px) | |
| zp = _Z_GLYPHS[px] = QPainterPath() | |
| zp.addText(QPointF(0, 0), f, "z") | |
| return zp | |
| def paint_z(p, cx: float, cy: float, w: float, h: float, phase: float): | |
| """The doze z's (a font glyph, not art): they drift up and to the right from the head (cx, cy - 0.36 h), fading.""" | |
| from PySide6.QtGui import QColor, QPen | |
| p.save() | |
| p.setRenderHint(p.RenderHint.Antialiasing, True) | |
| for i in range(3): | |
| ph = (phase * 0.45 + i / 3) % 1.0 | |
| al = max(0.0, min(1.0, ph * 5)) * min(1.0, (1 - ph) * 2.2) | |
| # addText(point) puts the glyph at the point cut to 1/64 px (QFixed::fromReal); the same cut keeps the pixels | |
| x, y = cx + w * (0.06 + 0.22 * ph), cy - h * (0.36 + 0.55 * ph) | |
| zp = z_glyph(max(9, int(h * (0.24 + 0.18 * ph)))).translated(int(x * 64) / 64, int(y * 64) / 64) | |
| c, e = QColor(LAMP), QColor(INK) | |
| c.setAlphaF(al) | |
| e.setAlphaF(al * 0.8) | |
| p.setPen(QPen(e, 0.8)) | |
| p.setBrush(c) | |
| p.drawPath(zp) | |
| p.restore() | |
| def pose_for(w: Wander, now: float, anim: dict) -> Pose: | |
| """The frame for Wander's state. anim keeps what carries over between frames (lean, wingbeat, shivers).""" | |
| dt = min(0.25, max(0.0, now - anim.get("t", now))) | |
| anim["t"] = now | |
| want = 0.0 | |
| if w.flying() and w.state != HELD: # lean toward where it flies (left/right speed only: it's a top view) | |
| want = max(-1.0, min(1.0, w.vx / Wander.SPEED)) * MAX_TILT | |
| anim["tilt"] = anim.get("tilt", 0.0) + (want - anim.get("tilt", 0.0)) * min(1.0, dt * 5) | |
| tilt = anim["tilt"] if abs(anim["tilt"]) > 0.2 else 0.0 | |
| if w.state == DOZE: | |
| return Pose(DOZING, tilt, z=now) | |
| if w.flying(): | |
| per = 0.032 if (w.excited or w.state == HELD) else FRAME_S # excited / held: a faster wingbeat | |
| anim["beat"] = anim.get("beat", 0.0) + dt / per | |
| return Pose(FLAP[int(anim["beat"]) % len(FLAP)], tilt) | |
| if anim.get("thinking"): # waiting on moth's reply: slow wingbeats where it sits | |
| return Pose(FLAP[int(now / 0.09) % len(FLAP)], tilt) | |
| if now > anim.get("shiver_next", 0): # resting: now and then the wings flick open once | |
| anim["shiver_at"] = now | |
| anim["shiver_next"] = now + w.rng.uniform(4, 11) | |
| k = int((now - anim.get("shiver_at", -9)) / 0.06) | |
| return Pose((2, 1, OPEN, OPEN, 1, 2)[k] if 0 <= k < 6 else RESTING, tilt) | |
| def bob(w: Wander, now: float) -> float: | |
| return math.sin(now * math.tau * 1.2) * w.size * 0.035 if w.flying() and w.state != HELD else 0.0 | |
| # --- Petdex / Codex sprite sheet -------------------------------------------------------------------------------- | |
| CELL_W, CELL_H, COLS = 192, 208, 8 | |
| PETDEX_ROWS = [("idle", 6), ("running-right", 8), ("running-left", 8), ("waving", 4), ("jumping", 5), | |
| ("failed", 8), ("waiting", 6), ("running", 6), ("review", 6)] | |
| PETDEX_W = 160 # moth width in the 192-wide cell: room for the lean (a 14-degree lean is ~185 px wide) | |
| def petdex_frame(state: str, i: int, n: int) -> tuple[Pose, float, float]: | |
| """(pose, dx, dy) of frame i of n for a Petdex row; dx/dy offset the moth in the cell (px). Every row reuses | |
| the WINGS frames (leaning / offset); none is drawn new.""" | |
| ph = i / n * math.tau | |
| if state == "idle": # resting, the wings flick open once | |
| return Pose((RESTING, RESTING, 2, OPEN, 2, RESTING)[i % 6]), 0, 0 | |
| if state in ("running-right", "running-left"): # flying, leaning into it | |
| lean = MAX_TILT if state == "running-right" else -MAX_TILT | |
| return Pose(FLAP[i % 8], lean), 0, -8 * math.sin(ph * 2) | |
| if state == "waving": # a wingbeat with a bob | |
| return Pose((OPEN, 2, UP, 2)[i % 4]), 0, -6 * math.sin(ph) | |
| if state == "jumping": # wings up, a hop, wings down | |
| hop = (0.0, -0.6, -1.0, -0.6, 0.0)[i % 5] | |
| return Pose((RESTING, UP, 2, OPEN, RESTING)[i % 5]), 0, 26 * hop | |
| if state == "failed": # wings fold down, it shudders, droops and sinks | |
| k = i / (n - 1) | |
| return Pose(RESTING if i < 2 else DOZING, 6 * k + 3 * (1 if i % 2 else -1) * (1 - k)), 0, 12 * k | |
| if state == "waiting": # resting and still, breathing a little | |
| return Pose(RESTING), 0, 2 * math.sin(ph) | |
| if state == "running": # a wingbeat, bobbing | |
| return Pose((OPEN, 1, 3, UP, 3, 1)[i % 6]), 0, -6 * math.sin(ph * 2) | |
| if state == "review": # looks one way, then the other | |
| return Pose(RESTING, (0, -10, -10, 0, 10, 10)[i % 6]), 0, 0 | |
| return Pose(), 0, 0 | |
| def render_petdex_sheet(): | |
| """The 1536x1872 sheet as a QImage (needs a QGuiApplication).""" | |
| from PySide6.QtCore import Qt | |
| from PySide6.QtGui import QImage, QPainter | |
| img = QImage(CELL_W * COLS, CELL_H * len(PETDEX_ROWS), QImage.Format.Format_ARGB32_Premultiplied) | |
| img.fill(Qt.GlobalColor.transparent) | |
| p = QPainter(img) | |
| for row, (state, n) in enumerate(PETDEX_ROWS): | |
| for i in range(n): | |
| pose, dx, dy = petdex_frame(state, i, n) | |
| cx = i * CELL_W + CELL_W / 2 + dx | |
| cy = row * CELL_H + CELL_H / 2 + 10 + dy # a little low: room for hops above | |
| p.save() | |
| p.setClipRect(i * CELL_W, row * CELL_H, CELL_W, CELL_H) | |
| paint_moth(p, cx, cy, PETDEX_W, pose) | |
| p.restore() | |
| p.end() | |
| return img | |
| def export_petdex(out_dir: str) -> list[str]: | |
| """Write out_dir/pet.json + out_dir/spritesheet.png (and CREDITS.txt: the art's CC BY 4.0 attribution travels | |
| with the pet); returns the two pet paths.""" | |
| from PySide6.QtWidgets import QApplication | |
| _app = QApplication.instance() or QApplication(sys.argv[:1]) | |
| os.makedirs(out_dir, exist_ok=True) | |
| sheet = os.path.join(out_dir, "spritesheet.png") | |
| if not render_petdex_sheet().save(sheet, "PNG"): | |
| raise OSError(f"could not write {sheet}") | |
| meta = {"id": "moth", "displayName": "moth", | |
| "description": f"a silly, cute, slightly dim moth that lives on a graphics card. {ART_CREDIT}", | |
| "spritesheetPath": "spritesheet.png"} | |
| pj = os.path.join(out_dir, "pet.json") | |
| with open(pj, "w", encoding="utf-8") as f: | |
| json.dump(meta, f, indent=2) | |
| f.write("\n") | |
| with open(os.path.join(out_dir, "CREDITS.txt"), "w", encoding="utf-8") as f: | |
| f.write(PETDEX_CREDITS) | |
| return [pj, sheet] | |
| PETDEX_CREDITS = """\ | |
| spritesheet.png: "202206 Silkmoth adult female icon" by DataBase Center for Life Science (DBCLS) | |
| https://commons.wikimedia.org/wiki/File:202206_Silkmoth_adult_female_icon.svg (doi:10.7875/togopic.2022.231) | |
| License: CC BY 4.0, https://creativecommons.org/licenses/by/4.0/ | |
| Changes by moth (https://huggingface.co/darioooooo0o/moth-112m): the art was cut along the body into two wings | |
| and the body; wing frames were made by squashing the wings toward the body and turning them a little; the frames | |
| were scaled, leaned and offset into the Petdex 8x9 grid. Nothing was drawn new. | |
| """ | |
| # --- skins: an installed Petdex pet instead of the moth --------------------------------------------------------- | |
| SHEET_W, SHEET_H = CELL_W * COLS, CELL_H * len(PETDEX_ROWS) # 1536x1872: the only size Petdex / Codex accept | |
| MAX_SHEET_BYTES = 32 << 20 # a 1536x1872 PNG/WebP is a few MB at most | |
| SKIN_H = 1.3 # a skin cell is drawn this many pet sizes high (the window is 1.75 sizes square) | |
| SKIN_FPS = {"idle": 6, "running-right": 10, "running-left": 10, "waving": 8, "jumping": 10, "failed": 8, | |
| "waiting": 4, "running": 10, "review": 6} | |
| _ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}") | |
| class SkinError(Exception): | |
| """Why a skin can't be worn (shown in the bubble; the pet is moth instead).""" | |
| def pet_dirs() -> list[str]: | |
| """Where installed Petdex pets live, in lookup order: the Petdex app, Codex, Hermes.""" | |
| home = os.path.expanduser("~") | |
| return [os.path.join(home, ".petdex", "pets"), | |
| os.path.join(os.environ.get("CODEX_HOME") or os.path.join(home, ".codex"), "pets"), | |
| os.path.join(os.environ.get("HERMES_HOME") or os.path.join(home, ".hermes"), "pets")] | |
| def _is_id(spec: str) -> bool: | |
| return bool(_ID.fullmatch(spec)) and ".." not in spec | |
| def find_skin(spec: str | None) -> str | None: | |
| """The folder of a skin (it has a pet.json), or None: moth itself, or nothing found. A spec that looks like a path | |
| (has a slash, starts with ~ or .) is only a folder; an id is looked up in pet_dirs(), then as a folder here.""" | |
| spec = (spec or "").strip() | |
| if not spec or spec == "moth": | |
| return None | |
| folder = os.path.abspath(os.path.expanduser(spec)) | |
| if "/" in spec or os.sep in spec or spec.startswith(("~", ".")): | |
| return folder if os.path.isfile(os.path.join(folder, "pet.json")) else None | |
| if _is_id(spec): | |
| for d in pet_dirs(): | |
| f = os.path.join(d, spec) | |
| if os.path.isfile(os.path.join(f, "pet.json")): | |
| return f | |
| return folder if os.path.isfile(os.path.join(folder, "pet.json")) else None | |
| def _display_name(meta: dict, fallback: str) -> str: | |
| for k in ("displayName", "id"): | |
| v = meta.get(k) | |
| if isinstance(v, str) and v.strip(): | |
| name = " ".join(clean(v).split()) | |
| return name if len(name) <= 40 else name[:39] + "…" | |
| return fallback | |
| def installed_skins() -> list[tuple[str, str, str]]: | |
| """[(id, folder, display name)] of the installed Petdex pets, first found wins per id, sorted by id. An installed | |
| "moth" (the export) is left out: the built-in moth is always there.""" | |
| seen: dict[str, tuple[str, str, str]] = {} | |
| for d in pet_dirs(): | |
| try: | |
| names = os.listdir(d) | |
| except OSError: | |
| continue | |
| for sid in names: | |
| f = os.path.join(d, sid) | |
| if sid in seen or sid == "moth" or not _is_id(sid) or not os.path.isfile(os.path.join(f, "pet.json")): | |
| continue | |
| try: | |
| with open(os.path.join(f, "pet.json"), encoding="utf-8") as fh: | |
| meta = json.load(fh) | |
| except (OSError, ValueError): | |
| meta = {} | |
| seen[sid] = (sid, f, _display_name(meta if isinstance(meta, dict) else {}, sid)) | |
| return [seen[k] for k in sorted(seen)] | |
| def read_pet(folder: str) -> tuple[str, str]: | |
| """(display name, sheet path) from folder/pet.json; SkinError when it isn't a usable Petdex pet. The sheet must | |
| be a file inside the folder (spritesheetPath; spritesheet.png / .webp when the key is missing).""" | |
| try: | |
| with open(os.path.join(folder, "pet.json"), encoding="utf-8") as f: | |
| meta = json.load(f) | |
| except FileNotFoundError: | |
| raise SkinError("it has no pet.json") from None | |
| except (OSError, ValueError) as e: | |
| raise SkinError(f"its pet.json can't be read ({type(e).__name__})") from None | |
| if not isinstance(meta, dict): | |
| raise SkinError("its pet.json isn't a JSON object") | |
| rel = meta.get("spritesheetPath") | |
| if rel is None: | |
| rel = next((n for n in ("spritesheet.png", "spritesheet.webp") if os.path.isfile(os.path.join(folder, n))), | |
| "spritesheet.png") | |
| if not isinstance(rel, str) or not rel.strip() or os.path.isabs(rel): | |
| raise SkinError("its pet.json's spritesheetPath isn't a file in the pet's folder") | |
| base = os.path.realpath(folder) | |
| sheet = os.path.realpath(os.path.join(base, rel)) | |
| if os.path.commonpath([sheet, base]) != base: | |
| raise SkinError("its pet.json's spritesheetPath points outside the pet's folder") | |
| if not os.path.isfile(sheet): | |
| raise SkinError(f"its sprite sheet is missing ({rel})") | |
| if os.path.getsize(sheet) > MAX_SHEET_BYTES: | |
| raise SkinError(f"its sprite sheet is too big ({os.path.getsize(sheet) >> 20} MB)") | |
| return _display_name(meta, os.path.basename(base)), sheet | |
| def skin_row(state: str, flying: bool, excited: bool, vx: float, thinking: bool, rows) -> tuple[str, bool, float]: | |
| """(Petdex row, mirrored, frames per second) for what the pet is doing; rows = the rows the sheet has frames in | |
| (idle always). Flying: running-right / running-left by direction, the other row mirrored if one is empty, else | |
| "running"; excited: jumping in the air, waving on the ground; resting: idle (review while waiting on a reply); | |
| dozing: waiting, else idle at a third of its speed.""" | |
| def pick(*names): | |
| return next((n for n in names if n in rows), "idle") | |
| if state == DOZE: | |
| return ("waiting", False, SKIN_FPS["waiting"]) if "waiting" in rows else ("idle", False, SKIN_FPS["idle"] / 3) | |
| if state == HELD: | |
| r = pick("waving", "jumping") | |
| return r, False, SKIN_FPS[r] | |
| if flying: | |
| if excited and "jumping" in rows: | |
| return "jumping", False, SKIN_FPS["jumping"] | |
| want, other = ("running-right", "running-left") if vx >= 0 else ("running-left", "running-right") | |
| if want in rows: | |
| return want, False, SKIN_FPS[want] | |
| if other in rows: | |
| return other, True, SKIN_FPS[other] | |
| r = pick("running") | |
| return r, False, SKIN_FPS[r] | |
| if excited: | |
| r = pick("waving", "jumping") | |
| return r, False, SKIN_FPS[r] | |
| if thinking and "review" in rows: | |
| return "review", False, SKIN_FPS["review"] | |
| return "idle", False, SKIN_FPS["idle"] | |
| class Skin: | |
| """A Petdex pet cut from its sheet: rows[name] = its non-empty 192x208 cells (QImages); top / bottom: the | |
| highest and lowest opaque pixel row over all frames (cell px), to place the bubble on the visible pet.""" | |
| def __init__(self, sid: str, name: str, folder: str, rows: dict, top: int, bottom: int): | |
| self.id, self.name, self.folder, self.rows, self.top, self.bottom = sid, name, folder, rows, top, bottom | |
| self._scaled: dict = {} | |
| def size(self, pet_size: float) -> tuple[float, float]: | |
| """(width, height) a cell is drawn at for a pet `pet_size` px wide.""" | |
| h = pet_size * SKIN_H | |
| return h * CELL_W / CELL_H, h | |
| def frames(self, row: str, mirrored: bool, ph: int) -> list: | |
| """The row's frames scaled to ph device pixels high (cached).""" | |
| key = (row, mirrored, ph) | |
| if key not in self._scaled: | |
| from PySide6.QtCore import Qt | |
| pw = max(1, round(ph * CELL_W / CELL_H)) | |
| out = [] | |
| for img in self.rows.get(row) or self.rows["idle"]: | |
| if mirrored: | |
| img = img.flipped(Qt.Orientation.Horizontal) if hasattr(img, "flipped") else img.mirrored(True, False) | |
| out.append(img.scaled(pw, ph, Qt.AspectRatioMode.IgnoreAspectRatio, | |
| Qt.TransformationMode.SmoothTransformation)) | |
| if len(self._scaled) > 24: | |
| self._scaled.clear() | |
| self._scaled[key] = out | |
| return self._scaled[key] | |
| def icon_image(self, px: int = 64): | |
| """The first idle frame centred on a transparent square (the tray icon).""" | |
| from PySide6.QtCore import Qt | |
| from PySide6.QtGui import QImage, QPainter | |
| img = QImage(px, px, QImage.Format.Format_ARGB32_Premultiplied) | |
| img.fill(Qt.GlobalColor.transparent) | |
| f = self.rows["idle"][0].copy(0, self.top, CELL_W, max(1, self.bottom - self.top + 1)) | |
| f = f.scaled(px, px, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) | |
| p = QPainter(img) | |
| p.drawImage((px - f.width()) // 2, (px - f.height()) // 2, f) | |
| p.end() | |
| return img | |
| def load_skin(spec: str) -> Skin: | |
| """The Petdex pet `spec` (id or folder) as a Skin; SkinError (why) when it can't be worn. Needs PySide6 (QImage; | |
| no application object).""" | |
| folder = find_skin(spec) | |
| if folder is None: | |
| raise SkinError(f"there's no petdex pet {spec!r} installed" if spec and spec != "moth" else "moth is built in") | |
| name, sheet = read_pet(folder) | |
| from PySide6.QtGui import QImage, QImageReader | |
| reader = QImageReader(sheet) | |
| dims = reader.size() # from the header, before decoding anything | |
| if dims.isValid() and (dims.width(), dims.height()) != (SHEET_W, SHEET_H): | |
| raise SkinError(f"its sheet is {dims.width()}x{dims.height()}, a petdex sheet is {SHEET_W}x{SHEET_H}") | |
| img = reader.read() | |
| if img.isNull(): | |
| raise SkinError(f"its sheet can't be read ({reader.errorString() or 'not an image'})") | |
| if (img.width(), img.height()) != (SHEET_W, SHEET_H): | |
| raise SkinError(f"its sheet is {img.width()}x{img.height()}, a petdex sheet is {SHEET_W}x{SHEET_H}") | |
| img = img.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied) | |
| alpha = img.convertToFormat(QImage.Format.Format_Alpha8) | |
| bpl = alpha.bytesPerLine() | |
| buf = bytes(alpha.constBits())[:bpl * SHEET_H].translate(bytes([0] * 16 + list(range(16, 256)))) # ignore haze | |
| rows: dict = {} | |
| extent: dict = {} # row -> (top, bottom) of its opaque pixels | |
| for r, (row, n) in enumerate(PETDEX_ROWS): | |
| frames, top, bottom = [], CELL_H, -1 | |
| for i in range(n): | |
| x0, y0 = i * CELL_W, r * CELL_H | |
| ys = [y for y in range(CELL_H) if buf[(y0 + y) * bpl + x0:(y0 + y) * bpl + x0 + CELL_W].strip(b"\0")] | |
| if not ys: | |
| continue # an empty cell: not a frame | |
| frames.append(img.copy(x0, y0, CELL_W, CELL_H)) | |
| top, bottom = min(top, ys[0]), max(bottom, ys[-1]) | |
| if frames: | |
| rows[row], extent[row] = frames, (top, bottom) | |
| if "idle" not in rows: | |
| raise SkinError("its idle row is empty") | |
| # the bubble follows the everyday rows (a jump may poke into it for a moment) | |
| common = [extent[r] for r in ("idle", "running-right", "running-left", "waiting") if r in extent] | |
| top, bottom = min(t for t, _ in common), max(b for _, b in common) | |
| return Skin(os.path.basename(os.path.normpath(folder)), name, folder, rows, top, bottom) | |
| def wear(spec: str | None) -> tuple[Skin | None, str | None]: | |
| """(the skin, None) or (None, why not) for spec; ("moth" or nothing: (None, None), the built-in moth).""" | |
| spec = (spec or "moth").strip() or "moth" | |
| if spec == "moth": | |
| return None, None | |
| try: | |
| return load_skin(spec), None | |
| except SkinError as e: | |
| return None, str(e) | |
| except Exception as e: # noqa: BLE001 a broken pet never stops the moth | |
| return None, f"it broke ({type(e).__name__})" | |
| def paint_skin(p, skin: Skin, cx: float, cy: float, pet_size: float, row: str, mirrored: bool, i: int): | |
| """Draw frame i (wrapping) of a skin row centred on (cx, cy).""" | |
| from PySide6.QtCore import QPointF | |
| dpr = p.device().devicePixelRatioF() or 1.0 | |
| w, h = skin.size(pet_size) | |
| ph = max(8, round(h * dpr)) | |
| frames = skin.frames(row, mirrored, ph) | |
| img = frames[i % len(frames)] | |
| img.setDevicePixelRatio(ph / h) | |
| p.save() | |
| p.setRenderHint(p.RenderHint.SmoothPixmapTransform, True) | |
| p.drawImage(QPointF(cx - w / 2, cy - h / 2), img) | |
| p.restore() | |
| # --- updates ---------------------------------------------------------------------------------------------------- | |
| UPDATE_EVERY = 24 * 3600.0 # s between automatic checks (wall clock, kept in config.json across restarts) | |
| UPDATE_EVAL = 600.0 # s between looking whether one is due (the pet runs for days) | |
| UPDATE_TIMEOUT = 180.0 | |
| PIP_HINT = "pip install -U moth-companion" | |
| def update_due(enabled: bool, offline: bool, last: float, now: float) -> bool: | |
| return enabled and not offline and (now - last >= UPDATE_EVERY or last > now + 3600) # clock went back: check | |
| def check_for_update(timeout: float = UPDATE_TIMEOUT) -> tuple[str, str]: | |
| """("update", what to run) / ("none", "") / ("error", why). Runs `python -m moth update --check` (exit 10 = | |
| update available) as a child process: the network calls and huggingface_hub stay out of the pet, and its | |
| printing can't mix with the pet's. Blocking: call it from a thread.""" | |
| code_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # the folder holding moth/: same code | |
| try: | |
| p = subprocess.run([sys.executable, "-m", "moth", "update", "--check"], cwd=code_dir, capture_output=True, | |
| text=True, timeout=timeout, stdin=subprocess.DEVNULL) | |
| except subprocess.TimeoutExpired: | |
| return "error", "the check took too long" | |
| except OSError as e: | |
| return "error", str(e) | |
| out = p.stdout or "" | |
| if p.returncode == 10: | |
| if "newer code:" in out: # pip / the installers' app: moth update can't bring code | |
| from moth.backends import compat # stdlib only | |
| return "update", compat.RELEASES if compat.install_kind() == "app" else PIP_HINT | |
| return "update", "moth update" if launch.weights.mode() == "pip" else "python -m moth update" | |
| if p.returncode == 0: | |
| return "none", "" | |
| last = [x for x in (out + "\n" + (p.stderr or "")).splitlines() if x.strip()] | |
| return "error", (last[-1].strip() if last else f"exit {p.returncode}")[:160] | |
| def update_line(hint: str) -> str: | |
| if hint.startswith("https://"): # the installers' app: a new installer | |
| return f"there's a new me! get it at {hint}" | |
| return f"there's a new me! `{hint}`" if hint == PIP_HINT else f"there's a new me! run `{hint}`" | |
| # --- platform ------------------------------------------------------------------------------------------------- | |
| APP_NAME = "moth" | |
| def macos_bundle_name(name: str = APP_NAME) -> bool: | |
| """macOS: the Dock, the menu bar and the app switcher take the app's name from the main bundle's CFBundleName, | |
| which for a pet started by `python -m moth.pet` is Python.app's ("Python"). Overwrite it in the main bundle's | |
| info dictionary (in memory only) before QApplication starts Cocoa: the CFBundleName trick, done through the | |
| Objective-C runtime with ctypes, so no PyObjC needed. Returns whether it was set; never raises (not macOS, no | |
| bundle, a dictionary that can't be changed: nothing happens).""" | |
| if sys.platform != "darwin": | |
| return False | |
| try: | |
| import ctypes | |
| import ctypes.util | |
| objc = ctypes.cdll.LoadLibrary(ctypes.util.find_library("objc") or "/usr/lib/libobjc.A.dylib") | |
| ctypes.cdll.LoadLibrary(ctypes.util.find_library("Foundation") | |
| or "/System/Library/Frameworks/Foundation.framework/Foundation") | |
| vp = ctypes.c_void_p | |
| objc.objc_getClass.restype, objc.objc_getClass.argtypes = vp, [ctypes.c_char_p] | |
| objc.sel_registerName.restype, objc.sel_registerName.argtypes = vp, [ctypes.c_char_p] | |
| send = ctypes.cast(objc.objc_msgSend, vp).value # one prototype per call shape (arm64 needs exact ones) | |
| call0 = ctypes.CFUNCTYPE(vp, vp, vp)(send) | |
| call_str = ctypes.CFUNCTYPE(vp, vp, vp, ctypes.c_char_p)(send) | |
| call_bool = ctypes.CFUNCTYPE(ctypes.c_bool, vp, vp, vp)(send) | |
| call_set = ctypes.CFUNCTYPE(None, vp, vp, vp, vp)(send) | |
| sel = objc.sel_registerName | |
| bundle = call0(objc.objc_getClass(b"NSBundle"), sel(b"mainBundle")) | |
| info = call0(bundle, sel(b"infoDictionary")) if bundle else None | |
| setter = sel(b"setObject:forKey:") | |
| if not info or not call_bool(info, sel(b"respondsToSelector:"), setter): # immutable: an exception, so ask | |
| return False | |
| ns = objc.objc_getClass(b"NSString") | |
| utf8 = sel(b"stringWithUTF8String:") | |
| for key in (b"CFBundleName", b"CFBundleDisplayName"): | |
| call_set(info, setter, call_str(ns, utf8, name.encode()), call_str(ns, utf8, key)) | |
| return True | |
| except Exception: # noqa: BLE001 a name is a nicety | |
| return False | |
| def set_app_name(app, name: str = APP_NAME) -> None: | |
| """Qt's own name for the app (menus, notifications, window titles where shown): "moth". Never raises.""" | |
| for setter in ("setApplicationName", "setApplicationDisplayName"): | |
| try: | |
| getattr(app, setter)(name) | |
| except Exception: # noqa: BLE001 | |
| pass | |
| def choose_platform(env=None, platform: str | None = None) -> str: | |
| """Before QApplication: on a Wayland session ask for XWayland first (a Wayland client can't place its own | |
| windows). Respects an explicit QT_QPA_PLATFORM. Returns what was decided: "set", "xwayland", "wayland", "native".""" | |
| env = os.environ if env is None else env | |
| platform = platform or sys.platform | |
| if env.get("QT_QPA_PLATFORM"): | |
| return "set" | |
| if not platform.startswith("linux") or not (env.get("WAYLAND_DISPLAY") or env.get("XDG_SESSION_TYPE") == "wayland"): | |
| return "native" | |
| if env.get("DISPLAY"): | |
| env["QT_QPA_PLATFORM"] = "xcb;wayland" # Qt 6 tries these in order (xcb needs libxcb-cursor0) | |
| return "xwayland" | |
| return "wayland" | |
| def _config(getter, default): | |
| """A config.py value, or default when the file can't be read (the pet never fails over a setting).""" | |
| try: | |
| return getter() | |
| except Exception: # noqa: BLE001 | |
| return default | |
| # --- the windows (Qt) ----------------------------------------------------------------------------------------- | |
| def run(a, on_ready=None) -> int: | |
| """The pet's windows until quit. on_ready(pet): called once they exist (tests / offscreen renders).""" | |
| decided = choose_platform() | |
| try: | |
| import PySide6.QtWidgets # noqa: F401 | |
| except ImportError: | |
| print("the desktop pet needs PySide6 (moth itself doesn't): pip install PySide6", file=sys.stderr) | |
| return 2 | |
| # Ctrl+C, kill, the terminal closing: quit and stop a server the pet started. Before Qt starts that is an | |
| # exception through the finally below; once the windows exist, Pet.quit (the Qt timer lets Python run the | |
| # handler). In between (QApplication and the windows being built) an exception would unwind through | |
| # half-made Qt objects, and PySide6 can abort at exit over those (exit -6, seen on macOS): so a signal there is | |
| # only noted (quit_now holds "qt" / "pending") and the pet quits through Qt as soon as it exists (_run). | |
| import signal | |
| quit_now: list = [] | |
| def on_signal(_sig, _frame): | |
| if quit_now and callable(quit_now[-1]): | |
| quit_now[-1]() | |
| elif "qt" in quit_now: | |
| if "pending" not in quit_now: | |
| quit_now.append("pending") | |
| else: | |
| raise KeyboardInterrupt | |
| old = {} | |
| for name in ("SIGINT", "SIGTERM", "SIGHUP"): | |
| sig = getattr(signal, name, None) | |
| if sig is not None: | |
| try: | |
| old[sig] = signal.signal(sig, on_signal) | |
| except (ValueError, OSError): # not the main thread (tests): the caller handles signals | |
| pass | |
| try: | |
| return _boot_and_run(a, on_ready, decided, quit_now) | |
| except KeyboardInterrupt: | |
| return 130 | |
| finally: | |
| for sig, h in old.items(): | |
| try: | |
| signal.signal(sig, h) | |
| except (ValueError, OSError, TypeError): | |
| pass | |
| def _boot_and_run(a, on_ready, decided: str, quit_now: list) -> int: | |
| # before QApplication and any thread: the server (if the pet starts one) is spawned from this thread | |
| boot = launch.prepare(a.url, no_server=getattr(a, "no_server", False), model=getattr(a, "model", None), | |
| extra=list(getattr(a, "server_arg", None) or []) + list(getattr(a, "passthrough", None) or []), | |
| token_file=a.token_file) | |
| if boot.message: | |
| print(f"moth pet: {boot.message}", file=sys.stderr, flush=True) | |
| try: | |
| return _run(a, on_ready, boot, decided, quit_now) | |
| finally: | |
| if boot.server: | |
| boot.server.stop() # also on aboutToQuit and atexit; stop() runs once | |
| def _run(a, on_ready, boot, decided: str, quit_now: list) -> int: | |
| """The windows and the Qt loop (run() has checked PySide6 and handled the server).""" | |
| from PySide6.QtCore import QPoint, QPointF, QRectF, Qt, QTimer, QUrl | |
| from PySide6.QtGui import ( | |
| QActionGroup, | |
| QColor, | |
| QCursor, | |
| QDesktopServices, | |
| QFont, | |
| QFontMetrics, | |
| QGuiApplication, | |
| QIcon, | |
| QImage, | |
| QPainter, | |
| QPainterPath, | |
| QPen, | |
| QPixmap, | |
| QRegion, | |
| ) | |
| from PySide6.QtWidgets import QApplication, QLineEdit, QMenu, QSystemTrayIcon, QVBoxLayout, QWidget | |
| if QApplication.instance() is None: | |
| macos_bundle_name() # before Cocoa starts: the Dock and menu bar say "moth", not "Python" | |
| quit_now.append("qt") # from here a signal is noted, not raised (run()), until Pet.quit takes over | |
| app = QApplication.instance() or QApplication(sys.argv[:1]) | |
| if not isinstance(app, QApplication): # a QGuiApplication made elsewhere: widgets would abort the process | |
| print("moth pet: needs a QApplication, found a QGuiApplication", file=sys.stderr) | |
| return 1 | |
| set_app_name(app) | |
| app.setQuitOnLastWindowClosed(False) | |
| plat = QGuiApplication.platformName() | |
| native_wayland = plat.startswith("wayland") | |
| if native_wayland: | |
| print("moth pet: native Wayland (no XWayland): the compositor places the window, so moth sits still where " | |
| "it is put (drag works).", file=sys.stderr) | |
| tiling = any(x in (os.environ.get("XDG_CURRENT_DESKTOP", "") + os.environ.get("DESKTOP_SESSION", "")).lower() | |
| for x in ("hyprland", "sway", "niri", "river", "i3")) | |
| bypass = plat == "xcb" and decided == "xwayland" and tiling # unmanaged: never tiled, never focused | |
| size = int(min(160, max(32, a.size))) | |
| W = H = int(size * 1.75) | |
| try: | |
| load_art() | |
| except (FileNotFoundError, ImportError) as e: # a pet without its art would only show its bubble | |
| print(f"moth pet: {e}", file=sys.stderr) | |
| return 1 | |
| url, tfile = a.url.rstrip("/"), token_path(a.token_file) | |
| base = (Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool | |
| | Qt.WindowType.WindowDoesNotAcceptFocus) | |
| if bypass: | |
| base |= Qt.WindowType.X11BypassWindowManagerHint | |
| def float_window(wd: QWidget, flags): | |
| wd.setWindowFlags(flags) | |
| wd.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) | |
| wd.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating, True) | |
| if sys.platform == "darwin": | |
| wd.setAttribute(Qt.WidgetAttribute.WA_MacAlwaysShowToolWindow, True) | |
| def screen_name(s) -> str: | |
| """What pet_screen saves: the screen's name (DP-1, HDMI-A-1, \\\\.\\DISPLAY2, "Built-in Retina Display"), | |
| or its place and size when it has none.""" | |
| g = s.geometry() | |
| return s.name() or f"{g.x()},{g.y()} {g.width()}x{g.height()}" | |
| def screens(): | |
| """[(name, available rect)] with the primary first, and {name: QScreen}.""" | |
| prim = QGuiApplication.primaryScreen() | |
| out, by_name = [], {} | |
| for s in sorted(QGuiApplication.screens(), key=lambda s: s is not prim): | |
| g, name = s.availableGeometry(), screen_name(s) | |
| out.append((name, (g.x(), g.y(), g.width(), g.height()))) | |
| by_name.setdefault(name, s) | |
| return out, by_name | |
| def screen_bounds(x, y, pin=None): | |
| """The available rect the pet flies in: the pinned screen (pet_screen) if it is connected, else the screen | |
| at (x, y), else the nearest one.""" | |
| g, _name = pick_bounds(pin, screens()[0], x, y) | |
| if g is None: | |
| r = QGuiApplication.primaryScreen().availableGeometry() | |
| g = (r.x(), r.y(), r.width(), r.height()) | |
| return g | |
| def screen_of(x, y): | |
| """The QScreen the pet is on (the nearest one in a gap).""" | |
| lst, by_name = screens() | |
| _g, name = pick_bounds(None, lst, x, y) | |
| return by_name.get(name) or QGuiApplication.primaryScreen() | |
| serif = QFont() | |
| serif.setFamilies(["Fraunces", "Georgia", "DejaVu Serif", "serif"]) | |
| serif.setPixelSize(14) | |
| mono = QFont() | |
| mono.setFamilies(["JetBrains Mono", "DejaVu Sans Mono", "Menlo", "Consolas", "monospace"]) | |
| mono.setPixelSize(10) | |
| class BubbleWin(QWidget): | |
| """The speech bubble: its own window, transparent for input, so it never catches a click.""" | |
| PAD, TAIL, RAD = 11, 9, 12 | |
| def __init__(self): | |
| super().__init__() | |
| float_window(self, base | Qt.WindowType.WindowTransparentForInput) | |
| self.bubble, self.alpha, self.below, self.tail_x = None, 0.0, False, 0.5 | |
| def set_bubble(self, b, alpha): | |
| changed = b is not self.bubble | |
| if changed: | |
| self.bubble = b | |
| fm, fc = QFontMetrics(serif), QFontMetrics(mono) | |
| tw = max(fm.horizontalAdvance(x) for x in b.lines) | |
| if b.caption: | |
| tw = max(tw, fc.horizontalAdvance(b.caption)) | |
| th = fm.lineSpacing() * len(b.lines) + (fc.lineSpacing() + 2 if b.caption else 0) | |
| self.resize(int(tw + 2 * self.PAD + 2), int(th + 2 * self.PAD + self.TAIL + 2)) | |
| if changed or abs(alpha - self.alpha) > 0.02: | |
| self.alpha = alpha | |
| self.update() | |
| def place(self, mx, my, bounds, up=None, down=None): | |
| """Above the pet (up px over its centre, the sprite's visible top), or below (down px) at the top.""" | |
| bx, by, bw, _bh = bounds | |
| w, h = self.width(), self.height() | |
| top = my - (size * 0.62 if up is None else up) - h | |
| self.below = top < by | |
| y = my + (size * 0.55 if down is None else down) if self.below else top | |
| x = min(max(mx - w * 0.35, bx + 4), bx + bw - w - 4) | |
| self.tail_x = min(max((mx - x) / w, 0.15), 0.85) | |
| self.move(int(x), int(y)) | |
| def paintEvent(self, _e): | |
| b = self.bubble | |
| if not b: | |
| return | |
| p = QPainter(self) | |
| p.setRenderHint(QPainter.RenderHint.Antialiasing) | |
| p.setOpacity(self.alpha) | |
| w, h, T = self.width(), self.height(), self.TAIL | |
| body = QRectF(1, (T if self.below else 0) + 1, w - 2, h - T - 2) | |
| path = QPainterPath() | |
| path.addRoundedRect(body, self.RAD, self.RAD) | |
| tx = w * self.tail_x | |
| tail = QPainterPath() | |
| if self.below: | |
| tail.moveTo(tx - 7, body.top() + 1) | |
| tail.quadTo(tx - 1, body.top() - 2, tx - 3, 1) | |
| tail.quadTo(tx + 2, body.top() - 1, tx + 7, body.top() + 1) | |
| else: | |
| tail.moveTo(tx - 7, body.bottom() - 1) | |
| tail.quadTo(tx - 1, body.bottom() + 2, tx - 3, h - 1) | |
| tail.quadTo(tx + 2, body.bottom() + 1, tx + 7, body.bottom() - 1) | |
| path = path.united(tail) | |
| fill = QColor(NIGHT2) | |
| fill.setAlphaF(0.95) | |
| p.setPen(QPen(QColor(LAMP), 1.2)) | |
| p.setBrush(fill) | |
| p.drawPath(path) | |
| y = body.top() + self.PAD | |
| if b.caption: | |
| p.setFont(mono) | |
| p.setPen(QColor(DUST)) | |
| fc = QFontMetrics(mono) | |
| p.drawText(QPointF(self.PAD + 1, y + fc.ascent()), b.caption) | |
| y += fc.lineSpacing() + 2 | |
| p.setFont(serif) | |
| p.setPen(QColor(PAPER)) | |
| fm = QFontMetrics(serif) | |
| for line in b.lines: | |
| p.drawText(QPointF(self.PAD + 1, y + fm.ascent()), line) | |
| y += fm.lineSpacing() | |
| p.end() | |
| class InputWin(QWidget): | |
| """Click the moth: a small box; Enter sends to moth, Esc or clicking elsewhere closes it.""" | |
| def __init__(self, on_send): | |
| super().__init__() | |
| flags = Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool | |
| self.setWindowFlags(flags) | |
| self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) | |
| self.on_send = on_send | |
| lay = QVBoxLayout(self) | |
| lay.setContentsMargins(4, 4, 4, 4) | |
| self.edit = QLineEdit(self) | |
| self.edit.setMaxLength(500) | |
| self.edit.setPlaceholderText("talk to moth…") | |
| self.edit.setFont(serif) | |
| self.edit.setStyleSheet( | |
| f"QLineEdit {{ background: {NIGHT2}; color: {PAPER}; border: 1px solid {LAMP}; border-radius: 10px;" | |
| f" padding: 7px 11px; selection-background-color: {LAMP}; selection-color: {INK}; }}") | |
| lay.addWidget(self.edit) | |
| self.setFixedWidth(270) | |
| self.edit.returnPressed.connect(self.send) | |
| app.focusChanged.connect(self._focus) | |
| def open_at(self, mx, my, bounds): | |
| bx, by, bw, bh = bounds | |
| self.adjustSize() | |
| x = min(max(mx - self.width() / 2, bx + 4), bx + bw - self.width() - 4) | |
| y = my + size * 0.6 | |
| if y + self.height() > by + bh: | |
| y = my - size * 0.7 - self.height() | |
| self.move(int(x), int(y)) | |
| self.show() | |
| self.raise_() | |
| self.activateWindow() | |
| self.edit.setFocus() | |
| def send(self): | |
| text = self.edit.text().strip() | |
| self.edit.clear() | |
| self.hide() | |
| if text: | |
| self.on_send(text) | |
| def keyPressEvent(self, e): | |
| if e.key() == Qt.Key.Key_Escape: | |
| self.edit.clear() | |
| self.hide() | |
| else: | |
| super().keyPressEvent(e) | |
| def _focus(self, _old, new): | |
| if self.isVisible() and new is None: | |
| QTimer.singleShot(150, lambda: self.hide() if not self.isActiveWindow() else None) | |
| small = QFont(serif) | |
| small.setPixelSize(13) | |
| class MissedWin(QWidget): | |
| """ "what did i miss": moth's last reactions in a popup that looks like its bubble (night fill, lamp edge, | |
| the caption font for "claude · repo · 4 min ago", the lamp colour for "while hidden"). A click anywhere or | |
| Esc closes it.""" | |
| PAD, RAD, GAP, WRAP_AT, MAX_LINES = 12, 12, 9, 40, 2 | |
| TITLE = "what i said lately" | |
| EMPTY = "nothing yet. it's been quiet" | |
| def __init__(self): | |
| super().__init__() | |
| self.setWindowFlags(Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint) | |
| self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) | |
| self.entries: list = [] # (head, mark or None, [text lines]) | |
| def set_rows(self, rows): | |
| self.entries = [] | |
| for head, text, mark in rows: | |
| lines = wrap_text(text, self.WRAP_AT) | |
| if len(lines) > self.MAX_LINES: | |
| lines = lines[:self.MAX_LINES] | |
| lines[-1] = lines[-1][:self.WRAP_AT - 1].rstrip() + "…" | |
| self.entries.append((head, mark, lines)) | |
| fm, fc = QFontMetrics(small), QFontMetrics(mono) | |
| tw = max(fc.horizontalAdvance(self.TITLE), fm.horizontalAdvance(self.EMPTY) if not rows else 0) | |
| th = fc.lineSpacing() + self.GAP | |
| for head, mark, lines in self.entries: | |
| tw = max(tw, fc.horizontalAdvance(head + (f" {mark}" if mark else "")), | |
| *(fm.horizontalAdvance(x) for x in lines)) | |
| th += fc.lineSpacing() + 2 + fm.lineSpacing() * len(lines) + self.GAP | |
| if not rows: | |
| th += fm.lineSpacing() + self.GAP | |
| self.resize(int(min(420, max(200, tw)) + 2 * self.PAD + 2), int(th + 2 * self.PAD - self.GAP + 2)) | |
| self.update() | |
| def open_near(self, mx, my, bounds, above=True): | |
| bx, by, bw, bh = bounds | |
| w, h = self.width(), self.height() | |
| y = my - size * 0.62 - h if above else my - h / 2 | |
| if y < by + 4: | |
| y = my + size * 0.55 if above else by + 4 | |
| x = min(max(mx - w * 0.35, bx + 4), bx + bw - w - 4) | |
| y = min(max(y, by + 4), by + bh - h - 4) | |
| self.move(int(x), int(y)) | |
| self.show() | |
| self.raise_() | |
| def paintEvent(self, _e): | |
| p = QPainter(self) | |
| p.setRenderHint(QPainter.RenderHint.Antialiasing) | |
| body = QRectF(1, 1, self.width() - 2, self.height() - 2) | |
| fill = QColor(NIGHT2) | |
| fill.setAlphaF(0.97) | |
| p.setPen(QPen(QColor(LAMP), 1.2)) | |
| p.setBrush(fill) | |
| p.drawRoundedRect(body, self.RAD, self.RAD) | |
| fm, fc = QFontMetrics(small), QFontMetrics(mono) | |
| x, y = self.PAD + 1, self.PAD + 1 | |
| p.setFont(mono) | |
| p.setPen(QColor(LAMP)) | |
| p.drawText(QPointF(x, y + fc.ascent()), self.TITLE) | |
| y += fc.lineSpacing() + self.GAP | |
| if not self.entries: | |
| p.setFont(small) | |
| p.setPen(QColor(PAPER)) | |
| p.drawText(QPointF(x, y + fm.ascent()), self.EMPTY) | |
| for i, (head, mark, lines) in enumerate(self.entries): | |
| if i: | |
| p.setPen(QPen(QColor(LINE), 1)) | |
| p.drawLine(QPointF(x, y - self.GAP / 2), QPointF(self.width() - self.PAD - 1, y - self.GAP / 2)) | |
| p.setFont(mono) | |
| p.setPen(QColor(DUST)) | |
| p.drawText(QPointF(x, y + fc.ascent()), head) | |
| if mark: | |
| p.setPen(QColor(LAMP)) | |
| p.drawText(QPointF(x + fc.horizontalAdvance(head + " "), y + fc.ascent()), mark) | |
| y += fc.lineSpacing() + 2 | |
| p.setFont(small) | |
| p.setPen(QColor(PAPER)) | |
| for line in lines: | |
| p.drawText(QPointF(x, y + fm.ascent()), line) | |
| y += fm.lineSpacing() | |
| y += self.GAP | |
| p.end() | |
| def mousePressEvent(self, _e): | |
| self.hide() | |
| def keyPressEvent(self, e): | |
| if e.key() == Qt.Key.Key_Escape: | |
| self.hide() | |
| else: | |
| super().keyPressEvent(e) | |
| class MothWin(QWidget): | |
| def __init__(self): | |
| super().__init__() | |
| float_window(self, base) | |
| self.setFixedSize(W, H) | |
| self.setCursor(Qt.CursorShape.PointingHandCursor) | |
| self.apply_mask(None) | |
| self.pose, self.dy = Pose(), 0.0 | |
| self.look = None # a skin's frame: (skin, row, mirrored, frame index, doze phase or None) | |
| self.press, self.dragging, self.grab_off = None, False, QPoint() | |
| def apply_mask(self, skin): | |
| """Click-through outside the sprite (+ bob) and the z's band above it. A static mask (no per-frame | |
| XShape), set again when the skin changes: a Petdex cell is taller than the moth.""" | |
| m = 4 + int(size * 0.04) | |
| if skin is None: | |
| t = math.radians(MAX_TILT) # the art's box at its biggest lean, plus the bob | |
| SW = int(size * (math.cos(t) + ART_ASPECT * math.sin(t))) + 2 | |
| SH = int(size * (math.sin(t) + ART_ASPECT * math.cos(t)) + size * 0.07) + 2 | |
| else: | |
| sw, sh = skin.size(size) | |
| SW, SH = int(sw) + 2, int(sh) + 2 | |
| self.setMask(QRegion((W - SW) // 2 - m, (H - SH) // 2 - m, SW + 2 * m, SH + 2 * m) | |
| .united(QRegion(0, 0, W, int(H * 0.4)))) | |
| def paintEvent(self, _e): | |
| p = QPainter(self) | |
| if self.look is None: | |
| paint_moth(p, W / 2, H / 2 + self.dy, size, self.pose) | |
| else: | |
| skin, row, mirrored, i, z = self.look | |
| paint_skin(p, skin, W / 2, H / 2 + self.dy, size, row, mirrored, i) # dy: the done-hop only | |
| if z is not None: # dozing: the moth's z's, from the pet's visible top | |
| sw, sh = skin.size(size) | |
| zh = size * ART_ASPECT | |
| paint_z(p, W / 2 + sw * 0.12, H / 2 - sh / 2 + skin.top * sh / CELL_H + zh * 0.6, size, zh, z) | |
| p.end() | |
| def mousePressEvent(self, e): | |
| if e.button() == Qt.MouseButton.LeftButton: | |
| self.press = e.globalPosition().toPoint() | |
| self.grab_off = self.press - self.pos() | |
| self.dragging = False | |
| elif e.button() == Qt.MouseButton.RightButton: | |
| pet.menu(e.globalPosition().toPoint()) | |
| def mouseMoveEvent(self, e): | |
| if self.press is None: | |
| return | |
| g = e.globalPosition().toPoint() | |
| if not self.dragging and (g - self.press).manhattanLength() > 5: | |
| self.dragging = True | |
| pet.grab() | |
| if native_wayland and self.windowHandle() and self.windowHandle().startSystemMove(): | |
| self.press = None | |
| return | |
| if self.dragging: | |
| self.move(g - self.grab_off) | |
| def mouseReleaseEvent(self, e): | |
| if e.button() != Qt.MouseButton.LeftButton or self.press is None: | |
| return | |
| self.press = None | |
| if self.dragging: | |
| pet.drop() | |
| else: | |
| pet.clicked() | |
| def closeEvent(self, e): # the window closed from outside (WM close, Alt-F4): quit like the menu does | |
| e.accept() | |
| pet.quit() | |
| class Pet: | |
| def __init__(self): | |
| scr = QGuiApplication.primaryScreen().availableGeometry() # or the pinned screen (pet_screen) | |
| bounds = screen_bounds(scr.x() + scr.width() / 2, scr.y() + scr.height() / 2, | |
| _config(config.pet_screen, "any")) | |
| now = time.monotonic() | |
| self.w = Wander(bounds, size, wander=not a.no_wander and not native_wayland, now=now) | |
| self.anim: dict = {} | |
| self.bubbles = BubbleQueue() | |
| self.muted = False | |
| self.thinking_until = 0.0 | |
| self.events: queue.Queue = queue.Queue() | |
| self.stop = threading.Event() | |
| self.win, self.bub = MothWin(), BubbleWin() | |
| self.inp = InputWin(self.send) | |
| self.last = now | |
| self.last_raise = now | |
| self.complained = False | |
| self.win.move(int(self.w.x - W / 2), int(self.w.y - H / 2)) | |
| self.win.show() | |
| self.timer = QTimer() | |
| self.timer.timeout.connect(self.tick) | |
| self.timer.start(33) | |
| self.poller = Poller(url, a.token_file) | |
| self.boot = boot | |
| self.booting = boot.state == "starting" | |
| self.downloading = boot.state == "downloading" | |
| self.started = threading.Event() # set by the GUI once it has started the server after a download | |
| self.quiet_down = boot.state == "error" # the bubble already says why; no "zzz" on top until moth is up | |
| if boot.state in ("starting", "downloading", "error"): | |
| self.server_down = True | |
| self.w.sleep(now, True) | |
| if self.booting: | |
| self.say(WAKING, ttl=launch.READY_TIMEOUT) | |
| elif self.downloading: | |
| self.say(DOWNLOADING, ttl=1e9) # no time limit: it goes when the download is done | |
| else: | |
| self.say(boot.message, ttl=NOTICE_TTL) | |
| # the look: --skin beats config.json's pet_skin; a skin that can't be worn = moth, and the bubble says why | |
| spec = getattr(a, "skin", None) or _config(config.pet_skin, "moth") | |
| self.skin, why = wear(spec) | |
| self.skin_anim: dict = {"row": None, "since": now} | |
| if why: | |
| self.say(f"i can't wear {spec!r}: {why}. i'm moth for now", ttl=NOTICE_TTL) | |
| self.win.apply_mask(self.skin) | |
| # tray icon where there is a tray (the same menu as the right-click one); none: nothing, silently | |
| self.tray_menu = self.make_menu() | |
| self.fill_menu(self.tray_menu) | |
| self.tray_menu.aboutToShow.connect(lambda: self.fill_menu(self.tray_menu)) | |
| self.tray = None | |
| self.tray_until = now + 120.0 # started at login, the panel's tray may come up after the pet: retry | |
| self.next_tray = now | |
| self.make_tray(now) | |
| self.update_running = False | |
| self.next_update_eval = now + 30.0 # the first daily check: once moth is up and settled | |
| # "what did i miss", the done-hop and its chirp, mutes, quiet hours, fullscreen, the pinned screen | |
| self.missed = Missed() | |
| self.chirper = Chirp() | |
| self.hop_at, self.hop_dy = -1e9, 0.0 | |
| self.hidden = False # another app is fullscreen on the pet's screen: the windows are hidden | |
| self.quiet = False # inside quiet_hours | |
| self.missed_win = None | |
| self.probe = make_probe(plat) # None: can't tell (native Wayland, offscreen): never hides | |
| self.next_fs = now + 1.0 | |
| self.read_settings(now) | |
| threading.Thread(target=self.bg, daemon=True, name="moth-pet-poll").start() | |
| # threads: never touch Qt, only the queue | |
| def bg(self): | |
| if self.downloading: # pip install, first run: fetch the weights, then the GUI thread starts the server | |
| err = self.boot.fetch() | |
| if self.stop.is_set(): | |
| return | |
| self.events.put(("downloaded", err)) | |
| if err: | |
| print(f"moth pet: {err}", file=sys.stderr, flush=True) | |
| else: | |
| while not self.started.wait(0.2): | |
| if self.stop.is_set(): | |
| return | |
| if self.booting: # our server is loading the model: wait for it, then poll as usual | |
| ok, why = self.boot.server.wait_ready(self.stop) | |
| if self.stop.is_set(): # quit while it loaded: aboutToQuit stops it | |
| return | |
| self.events.put(("boot", (ok, why))) | |
| print(f"moth pet: {why or 'moth is awake'}", file=sys.stderr, flush=True) | |
| self.poll_loop() | |
| def poll_loop(self): | |
| history = False | |
| while not self.stop.is_set(): | |
| lines, status = self.poller.poll() | |
| if status: | |
| self.events.put(("status", status)) | |
| if lines: | |
| self.events.put(("lines", lines)) | |
| if not history and self.poller.start is not None: # once moth answers: its log's last reactions | |
| history = True | |
| old = self.poller.history(MISSED_KEEP) | |
| if old: | |
| self.events.put(("history", old)) | |
| try: # the character the model plays (moth.json; older servers don't say: stays "moth") | |
| _, health = launch.probe(self.poller.url) | |
| name = health.get("character") if isinstance(health, dict) else None | |
| if isinstance(name, str) and 0 < len(name.strip()) <= 40: | |
| self.events.put(("character", name.strip())) | |
| except Exception: # noqa: BLE001 a name is a nicety | |
| pass | |
| self.stop.wait(self.poller.interval()) | |
| def send(self, text: str): | |
| self.thinking_until = time.monotonic() + CHAT_TIMEOUT | |
| self.w.attend = None | |
| def go(): | |
| err = send_chat(url, tfile, text) | |
| self.events.put(("sent", err)) | |
| threading.Thread(target=go, daemon=True, name="moth-pet-chat").start() | |
| # GUI | |
| def say(self, text, caption=None, ttl=None, force=False): | |
| """A bubble, unless bubbles are muted. Hidden (fullscreen) or in quiet hours only what answers you | |
| (force: chat replies, menu actions) still shows; the rest is dropped, not saved up for later.""" | |
| if self.muted or ((self.hidden or self.quiet) and not force): | |
| return | |
| self.bubbles.push(text, caption, ttl) | |
| def react(self, ln) -> str | None: | |
| """One of moth's lines from the poll: "reaction" / "reply" when it got a bubble, else None. Every | |
| reaction goes into "what did i miss", marked with why it got no bubble (WHY).""" | |
| text, cap = ln[0], ln[1] | |
| if getattr(ln, "reply", False): # moth answering you: as before, never filtered | |
| self.say(text, cap, force=True) | |
| return None if self.muted else "reply" | |
| src = getattr(ln, "source", None) | |
| why = ("hidden" if self.hidden else | |
| "muted" if self.muted or source_muted(src, self.settings.get("muted_sources")) else | |
| "asleep" if self.quiet else None) | |
| self.missed.add(text, src, getattr(ln, "label", None), why) | |
| if why: | |
| return None | |
| self.say(text, cap) | |
| return "reaction" | |
| def done_signal(self, now): | |
| """ "your agent is done": the hop (and the chirp, with config chirp on).""" | |
| self.hop_at = now | |
| if self.settings.get("chirp"): | |
| self.chirper.play() | |
| def read_settings(self, now): | |
| """The live settings (config.PET_LIVE), reread every SETTINGS_EVERY s and after the menu changes one.""" | |
| s = {} | |
| for k in config.PET_LIVE: | |
| s[k] = _config(lambda k=k: config.lookup(k)[0], config.DEFAULTS[k]) | |
| self.settings = s | |
| self.next_settings = now + SETTINGS_EVERY | |
| self.set_quiet(in_quiet(s["quiet_hours"]), now) | |
| if self.hidden and not s["hide_in_fullscreen"]: | |
| self.set_hidden(False, now) | |
| def save_setting(self, key, value, say_error=True) -> bool: | |
| try: | |
| config.write(key, value) | |
| except OSError as e: | |
| if say_error: | |
| self.say(f"i couldn't save that ({e})", ttl=NOTICE_TTL, force=True) | |
| return False | |
| self.read_settings(time.monotonic()) | |
| return True | |
| def want_sleep(self) -> bool: | |
| return self.menu_sleep or getattr(self, "server_down", False) or self.quiet | |
| def apply_sleep(self, now): | |
| if self.want_sleep() != self.w.asleep: | |
| self.w.sleep(now, self.want_sleep()) | |
| def set_quiet(self, on, now): | |
| if on == self.quiet: | |
| return | |
| self.quiet = on | |
| if on: | |
| self.bubbles.clear() | |
| self.apply_sleep(now) | |
| def set_hidden(self, on, now): | |
| """Another app went fullscreen on the pet's screen (or stopped being): hide / show the windows.""" | |
| self.hidden = on | |
| if on: | |
| self.bubbles.clear() | |
| for wd in (self.win, self.bub, self.inp): | |
| wd.hide() | |
| else: | |
| self.win.show() | |
| self.drawn = None # repaint | |
| self.kick() | |
| def fullscreen_now(self) -> bool: | |
| w = self.w | |
| try: | |
| scr = screen_of(w.x, w.y) | |
| g, dpr = scr.geometry(), scr.devicePixelRatio() or 1.0 | |
| # X11 / Windows work in device pixels; Qt keeps a screen's top-left and scales from there | |
| ctx = {"pt": (g.x() + (w.x - g.x()) * dpr, g.y() + (w.y - g.y()) * dpr), | |
| "screen": (g.width() * dpr, g.height() * dpr), "lpt": (w.x, w.y), | |
| "lscreen": (g.width(), g.height()), | |
| "hwnd": int(self.win.winId()) if sys.platform.startswith("win") else None} | |
| return fullscreen_on(self.probe.snapshot(ctx)) | |
| except Exception: # noqa: BLE001 can't tell: don't hide | |
| return False | |
| def check_fullscreen(self, now): | |
| if self.probe is None or now < self.next_fs: | |
| return | |
| self.next_fs = now + FS_EVERY | |
| fs = bool(self.settings.get("hide_in_fullscreen", True)) and self.fullscreen_now() | |
| if fs != self.hidden: | |
| self.set_hidden(fs, now) | |
| def pin(self): | |
| return self.settings.get("pet_screen", "any") if hasattr(self, "settings") else "any" | |
| def make_tray(self, now): | |
| """The tray icon, if there is a tray (tried every 10 s for the first 2 minutes); else nothing, silently.""" | |
| if self.tray is not None or now > self.tray_until or now < self.next_tray: | |
| return | |
| self.next_tray = now + 10.0 | |
| try: | |
| if QSystemTrayIcon.isSystemTrayAvailable(): | |
| self.tray = QSystemTrayIcon(self.icon(), app) | |
| self.tray.setToolTip(getattr(self, "character", "moth")) | |
| self.tray.setContextMenu(self.tray_menu) | |
| self.tray.show() | |
| except Exception: # noqa: BLE001 a tray is a nicety | |
| self.tray = None | |
| def notify(self, text, ttl=None, force=False): | |
| """say(), or the tray's own message when bubbles are muted (an update notice shouldn't vanish).""" | |
| if self.muted and self.tray is not None: | |
| if force or not (self.hidden or self.quiet): | |
| self.tray.showMessage(getattr(self, "character", "moth"), text, self.icon(), 10000) | |
| else: | |
| self.say(text, ttl=ttl, force=force) | |
| def quit(self): # once: a second app.quit() would stay queued and end the next exec() at once (tests) | |
| if not self.stop.is_set(): | |
| self.stop.set() | |
| if getattr(self, "tray", None) is not None: | |
| self.tray.hide() | |
| app.quit() | |
| def handle(self, now): | |
| while True: | |
| try: | |
| kind, val = self.events.get_nowait() | |
| except queue.Empty: | |
| return | |
| if kind == "lines": | |
| self.thinking_until = 0.0 | |
| got = [self.react(ln) for ln in val] | |
| if any(got): | |
| self.w.excite(now) | |
| if "reaction" in got: # an agent finished and moth said so: hop (and chirp) | |
| self.done_signal(now) | |
| elif kind == "history": | |
| self.missed.seed(val) | |
| elif kind == "character": | |
| self.character = val | |
| if self.tray is not None: | |
| self.tray.setToolTip(val) | |
| elif kind == "downloaded": # in the GUI thread: start the server here (PR_SET_PDEATHSIG) | |
| self.downloading = False | |
| self.bubbles.fade(DOWNLOADING) | |
| err = val | |
| if not err: | |
| try: | |
| self.boot.server.start() | |
| self.booting = True | |
| self.say(WAKING, ttl=launch.READY_TIMEOUT) | |
| except OSError as e: | |
| err = f"can't wake moth: {e}" | |
| if err: | |
| self.quiet_down = True | |
| self.say(err, ttl=NOTICE_TTL) | |
| self.started.set() | |
| elif kind == "boot": | |
| ok, why = val | |
| self.booting = False | |
| self.bubbles.fade(WAKING) | |
| if not ok: # it died or never answered: say so once, then poll quietly | |
| self.quiet_down = True | |
| self.say(why, ttl=NOTICE_TTL) | |
| elif kind == "status" and val == "down": | |
| self.w.sleep(now, True) | |
| self.server_down = True | |
| srv = self.boot.server | |
| if self.booting or self.quiet_down: | |
| pass | |
| elif srv and srv.exited() and not srv.stopped: | |
| self.say(f"moth's server stopped (see {srv.log})", ttl=NOTICE_TTL) | |
| else: | |
| self.say(ASLEEP) | |
| elif kind == "status" and val == "up": | |
| self.quiet_down = False | |
| was_down, self.server_down = getattr(self, "server_down", False), False | |
| if was_down and not self.want_sleep(): # not slept from the menu, not quiet hours | |
| self.w.sleep(now, False) | |
| self.w.excite(now, 1.5) | |
| elif kind == "sent" and val: | |
| self.thinking_until = 0.0 | |
| self.say(val, force=True) | |
| elif kind == "update": | |
| self.update_running = False | |
| res, info, manual = val | |
| if res == "update": | |
| self.notify(update_line(info), ttl=20, force=manual) | |
| self.w.excite(now, 1.5) | |
| elif manual and res == "none": | |
| self.notify("i'm the newest me already", ttl=8, force=True) | |
| elif manual: | |
| self.notify(f"i couldn't look for a new me: {info}", ttl=NOTICE_TTL, force=True) | |
| menu_sleep = False | |
| drawn = None # (pose, dy, look) last handed to the window | |
| cursor_at = -1e9 # when the cursor was last read | |
| settings: dict = {} # config.PET_LIVE values (read_settings replaces it, never changes it in place) | |
| hidden = quiet = False | |
| HIDDEN_MS = 1000 # ms between ticks while hidden: events and the fullscreen check, nothing drawn | |
| def tick(self): | |
| if self.stop.is_set(): # quit: a pet that's done never acts again (its timer may outlive app.exec()) | |
| return | |
| try: | |
| self._tick() | |
| except Exception as e: # noqa: BLE001 a pet never crashes; say it once and keep going | |
| if not self.complained: | |
| self.complained = True | |
| print(f"moth pet: {type(e).__name__}: {e}", file=sys.stderr) | |
| def _tick(self): | |
| now = time.monotonic() | |
| dt, self.last = now - self.last, now | |
| self.handle(now) | |
| if self.tray is None: | |
| self.make_tray(now) | |
| if now >= self.next_update_eval: | |
| self.next_update_eval = now + UPDATE_EVAL | |
| # not while hidden or in quiet hours: its notice would be dropped and the next check is a day later | |
| if not (self.booting or self.downloading or self.hidden or self.quiet): | |
| self.maybe_check_updates() | |
| if now >= self.next_settings: | |
| self.read_settings(now) | |
| self.check_fullscreen(now) | |
| if self.hidden: # nothing to draw or move until the fullscreen app is gone | |
| if self.timer.interval() != self.HIDDEN_MS: | |
| self.timer.setInterval(self.HIDDEN_MS) | |
| return | |
| w = self.w | |
| if w.attend is not None and not self.inp.isVisible(): | |
| w.attend = None # the input box closed (Esc / clicked elsewhere): back to wandering | |
| if w.state == HELD: | |
| pos = self.win.pos() | |
| w.x, w.y = pos.x() + W / 2, pos.y() + H / 2 | |
| if self.win.press is None and not (QGuiApplication.mouseButtons() & Qt.MouseButton.LeftButton): | |
| self.drop() # a compositor-driven move (Wayland) ends without a release event | |
| else: | |
| cursor = None # QCursor.pos() is a round trip to the X server: every frame only while flying | |
| if w.state in (FLY, LAND) or now - self.cursor_at >= 1.0: | |
| c, self.cursor_at = QCursor.pos(), now | |
| cursor = (c.x(), c.y()) | |
| pin = self.pin() | |
| bounds = screen_bounds(w.x, w.y, pin) | |
| bx, by, bw, bh = bounds | |
| if pin != "any" and not (bx <= w.x < bx + bw and by <= w.y < by + bh): # pinned elsewhere: go | |
| w.move_to(bounds, now) | |
| w.step(dt, now, cursor=cursor, bounds=bounds) | |
| self.anim["thinking"] = now < self.thinking_until | |
| pose = pose_for(w, now, self.anim) | |
| hp = hop(now - self.hop_at) # the done-signal: two hops, wings fluttering | |
| self.hop_dy = -hp[0] * size if hp else 0.0 | |
| if hp and self.skin is None: | |
| pose = Pose(hp[1], pose.tilt, pose.z if w.state == DOZE else None) | |
| self.win.pose, self.win.dy = pose, bob(w, now) + self.hop_dy | |
| if self.skin is not None: | |
| row, mirrored, rate = skin_row(w.state, w.flying(), w.excited, w.vx, self.anim["thinking"], | |
| self.skin.rows) | |
| if hp: # the skin's own jump if it has one (no extra lift), else waving or what it does, lifted | |
| r = next((x for x in ("jumping", "waving") if x in self.skin.rows), None) | |
| if r: | |
| row, mirrored, rate = r, False, SKIN_FPS[r] | |
| self.hop_dy = 0.0 if r == "jumping" else self.hop_dy * 0.6 # a cell has less room above | |
| if self.skin_anim["row"] != (row, mirrored): | |
| self.skin_anim.update(row=(row, mirrored), since=now) | |
| i = int((now - self.skin_anim["since"]) * rate) | |
| self.win.look = (self.skin, row, mirrored, i, now if w.state == DOZE else None) | |
| self.win.dy = self.hop_dy | |
| else: | |
| self.win.look = None | |
| if w.state != HELD and not native_wayland: | |
| tx, ty = round(w.x - W / 2), round(w.y - H / 2) | |
| if (tx, ty) != (self.win.x(), self.win.y()): | |
| self.win.move(tx, ty) | |
| drawn = (self.win.pose, self.win.dy, self.win.look) | |
| if drawn != self.drawn: # the same frame again (a resting moth is a still image): no repaint | |
| self.drawn = drawn | |
| self.win.update() | |
| if bypass and now - self.last_raise > 3: # unmanaged windows don't stay on top by themselves | |
| self.last_raise = now | |
| self.win.raise_() | |
| self.bub.raise_() | |
| b, alpha = self.bubbles.current() | |
| if b is None or self.muted: | |
| if self.bub.isVisible(): | |
| self.bub.hide() | |
| else: | |
| self.bub.set_bubble(b, alpha) | |
| g = self.win.geometry() | |
| up, down = self.bubble_gaps() | |
| self.bub.place(g.center().x(), g.center().y() + self.win.dy - self.hop_dy, # not hopping along | |
| screen_bounds(w.x, w.y, self.pin()), up, down) | |
| if not self.bub.isVisible(): | |
| self.bub.show() | |
| fps = 30 if ((b is not None and alpha < 1) or hp) else w.fps() | |
| iv = int(1000 / fps) | |
| slow, due = None, now + 3600.0 | |
| if (fps == w.fps() and w.state == REST and not w.excited and self.skin is None and not self.anim["thinking"] | |
| and now - self.anim.get("shiver_at", -9.0) > SHIVER_S): | |
| # sitting still: a few ticks a second, but on time for the next wing flick (then 12 fps through it) | |
| slow, due = STILL_MS, self.anim.get("shiver_next", now) | |
| elif fps == w.fps() and w.state == DOZE and self.skin is None: | |
| slow = 1000 // DOZE_FPS # asleep (all day, mostly): the z's drift on at a lower frame rate | |
| if slow: | |
| if b is not None and not self.muted: # ... and on time for the bubble's fade-out | |
| due = min(due, b.shown + b.ttl - self.bubbles.FADE_OUT) | |
| iv = max(iv, min(slow, int((due - now) * 1000) + 1)) | |
| if self.timer.interval() != iv: | |
| self.timer.setInterval(iv) | |
| def kick(self): | |
| """Tick now (a click / a drag starts): don't wait out a still moth's slow tick.""" | |
| self.timer.start(0) | |
| def grab(self): | |
| self.w.grab(time.monotonic()) | |
| self.kick() | |
| def drop(self): | |
| pos = self.win.pos() | |
| cx, cy = pos.x() + W / 2, pos.y() + H / 2 | |
| if self.pin() != "any": # carried to another screen while pinned: it stays on that one from now on | |
| name = screen_name(screen_of(cx, cy)) | |
| if name != self.pin(): | |
| self.save_setting("pet_screen", name) | |
| sb = screen_bounds(cx, cy) | |
| self.w.bounds = sb | |
| self.w.drop(time.monotonic(), cx, cy) | |
| self.kick() | |
| def clicked(self): | |
| now = time.monotonic() | |
| self.w.poke(now) | |
| self.kick() | |
| if self.inp.isVisible(): # a second click closes the box (if the box never got focus, Esc can't) | |
| self.inp.hide() | |
| return | |
| g = self.win.geometry() | |
| self.inp.open_at(g.center().x(), g.center().y(), screen_bounds(self.w.x, self.w.y)) | |
| if self.w.wander and not self.w.asleep: | |
| self.w.attend = (self.w.x, self.w.y) | |
| def bubble_gaps(self): | |
| """(px from the window centre up to the sprite's visible top, down to its bottom) plus a little air; | |
| (None, None) for the moth (BubbleWin's own numbers).""" | |
| if self.skin is None: | |
| return None, None | |
| _sw, sh = self.skin.size(size) | |
| k = sh / CELL_H | |
| return sh / 2 - self.skin.top * k + 4, self.skin.bottom * k - sh / 2 + 4 | |
| def icon(self): | |
| """The tray icon: the skin's idle frame, or the moth with its wings open, on a square.""" | |
| if self.skin is not None: | |
| return QIcon(QPixmap.fromImage(self.skin.icon_image(64))) | |
| img = QImage(64, 64, QImage.Format.Format_ARGB32_Premultiplied) | |
| img.fill(Qt.GlobalColor.transparent) | |
| art = sprites(64)[OPEN] | |
| p = QPainter(img) | |
| p.drawImage(0, (64 - art.height()) // 2, art) | |
| p.end() | |
| return QIcon(QPixmap.fromImage(img)) | |
| # -- the menu (right-click on the moth, and the tray's) | |
| def make_menu(self): | |
| m = QMenu() | |
| m.setStyleSheet(f"QMenu {{ background: {NIGHT2}; color: {PAPER}; border: 1px solid {LINE}; padding: 4px; }}" | |
| f"QMenu::item {{ padding: 5px 18px; }} QMenu::item:selected {{ background: {LINE};" | |
| f" color: {LAMP}; }} QMenu::item:disabled {{ color: {DUST}; }}" | |
| f" QMenu::separator {{ height: 1px; background: {LINE}; margin: 4px; }}") | |
| m.setFont(serif) | |
| return m | |
| def fill_menu(self, m): | |
| """(Re)build m from the current state: labels, checks and the installed skins.""" | |
| for sub in getattr(m, "_moth_subs", ()): | |
| sub.deleteLater() | |
| m.clear() | |
| m.addAction("open chat page").triggered.connect(self.open_chat) | |
| m.addAction("what did i miss").triggered.connect(lambda *_: QTimer.singleShot(0, self.show_missed)) | |
| m.addSeparator() | |
| m.addAction("unmute bubbles" if self.muted else "mute bubbles").triggered.connect(self.toggle_mute) | |
| mutes = self.make_menu() | |
| mutes.setTitle("mute harnesses") | |
| m.addMenu(mutes) | |
| self.fill_mutes(mutes) | |
| quiet = self.make_menu() | |
| quiet.setTitle("quiet hours") | |
| m.addMenu(quiet) | |
| self.fill_quiet(quiet) | |
| m.addAction("wake up" if self.menu_sleep else "sleep").triggered.connect(self.toggle_sleep) | |
| m.addSeparator() | |
| h = m.addAction("show moth in harnesses") | |
| h.setCheckable(True) | |
| h.setChecked(bool(_config(config.show_in_harness, False))) | |
| h.triggered.connect(self.set_harness) | |
| sub = self.make_menu() | |
| sub.setTitle("skin") | |
| m.addMenu(sub) | |
| self.fill_skins(sub) | |
| m._moth_subs = [sub, mutes, quiet] | |
| st = m.addAction("stay on this screen") | |
| st.setCheckable(True) | |
| st.setChecked(self.pin() != "any") | |
| st.triggered.connect(self.toggle_pin) | |
| m.addAction("check for updates").triggered.connect(lambda *_: self.check_updates(manual=True)) | |
| m.addSeparator() | |
| m.addAction("quit").triggered.connect(lambda *_: QTimer.singleShot(0, self.quit)) | |
| def fill_skins(self, sub): | |
| group = QActionGroup(sub) | |
| group.setExclusive(True) | |
| mine = os.path.realpath(self.skin.folder) if self.skin is not None else None | |
| entries = [("moth", "moth", self.skin is None)] | |
| for sid, folder, name in installed_skins(): | |
| entries.append((sid, name if name.lower() == sid.lower() else f"{name} ({sid})", | |
| os.path.realpath(folder) == mine)) | |
| if mine is not None and not any(on for *_, on in entries): # worn from a folder outside the pet dirs | |
| entries.append((self.skin.folder, self.skin.name, True)) | |
| for spec, label, on in entries: | |
| act = sub.addAction(label) | |
| act.setCheckable(True) | |
| act.setChecked(on) | |
| group.addAction(act) | |
| act.triggered.connect(lambda *_, s=spec: self.choose_skin(s)) | |
| if len(entries) == 1: | |
| sub.addSeparator() | |
| sub.addAction("more: npx petdex install <pet>").setEnabled(False) | |
| def fill_mutes(self, sub): | |
| """One checkbox per harness: checked = its reactions pop no bubble and no hop (muted_sources).""" | |
| muted = self.settings.get("muted_sources") or [] | |
| for src in config.SOURCES: | |
| name = NAMES.get(src, src) | |
| act = sub.addAction(name if name == src or src == "claude-code" else f"{name} ({src})") | |
| act.setCheckable(True) | |
| act.setChecked(src in muted) | |
| act.triggered.connect(lambda *_, s=src: self.toggle_source(s)) | |
| QUIET_PRESETS = ("off", "22:00-07:00", "23:00-08:00", "00:00-09:00") | |
| def fill_quiet(self, sub): | |
| group = QActionGroup(sub) | |
| group.setExclusive(True) | |
| cur = self.settings.get("quiet_hours", "off") | |
| for spec in self.QUIET_PRESETS + (() if cur in self.QUIET_PRESETS else (cur,)): | |
| act = sub.addAction(spec.replace("-", " – ") if spec != "off" else "off") | |
| act.setCheckable(True) | |
| act.setChecked(spec == cur) | |
| group.addAction(act) | |
| act.triggered.connect(lambda *_, s=spec: self.set_quiet_hours(s)) | |
| sub.addSeparator() | |
| sub.addAction("other: moth config quiet_hours").setEnabled(False) | |
| def menu(self, at): | |
| m = self.make_menu() | |
| self.fill_menu(m) | |
| m.exec(at) | |
| def show_missed(self, *_): | |
| """ "what did i miss": next to the moth, or at the cursor while it's hidden (opened from the tray).""" | |
| if self.missed_win is None: | |
| self.missed_win = MissedWin() | |
| self.missed_win.set_rows(self.missed.rows()) | |
| if self.hidden or not self.win.isVisible(): | |
| c = QCursor.pos() | |
| self.missed_win.open_near(c.x(), c.y(), screen_bounds(c.x(), c.y()), above=False) | |
| else: | |
| g = self.win.geometry() | |
| self.missed_win.open_near(g.center().x(), g.center().y(), screen_bounds(self.w.x, self.w.y, self.pin())) | |
| def toggle_source(self, src, *_): | |
| muted = set(self.settings.get("muted_sources") or []) | |
| muted ^= {src} | |
| self.save_setting("muted_sources", sorted(muted)) | |
| def set_quiet_hours(self, spec, *_): | |
| if self.save_setting("quiet_hours", spec) and spec != "off": | |
| self.say(f"quiet from {spec.replace('-', ' to ')}. i'll nap then", ttl=8, force=True) | |
| def toggle_pin(self, *_): | |
| if self.pin() != "any": | |
| self.save_setting("pet_screen", "any") | |
| else: | |
| self.save_setting("pet_screen", screen_name(screen_of(self.w.x, self.w.y))) | |
| # -- what the menu does | |
| def open_chat(self, *_): | |
| QDesktopServices.openUrl(QUrl(url)) | |
| def toggle_mute(self, *_): | |
| self.muted = not self.muted | |
| if self.muted: | |
| self.bubbles.clear() | |
| def toggle_sleep(self, *_): | |
| self.menu_sleep = not self.menu_sleep | |
| self.w.sleep(time.monotonic(), self.want_sleep()) | |
| def set_harness(self, on=None, *_): | |
| on = (not _config(config.show_in_harness, False)) if on is None else bool(on) | |
| try: | |
| config.write("show_in_harness", on) | |
| except OSError as e: | |
| self.say(f"i couldn't save that ({e})", ttl=NOTICE_TTL, force=True) | |
| return | |
| msg = "i'll show up inside your harnesses" if on else "i'll stay out of your harnesses" | |
| if config.lookup("show_in_harness")[1] == "env": | |
| msg += f" (but `{config.ENV['show_in_harness']}` is set and wins in this shell)" | |
| self.say(msg, ttl=8, force=True) | |
| def choose_skin(self, spec): | |
| skin, why = wear(spec) | |
| if why: | |
| self.say(f"i can't wear {spec!r}: {why}", ttl=NOTICE_TTL, force=True) | |
| return | |
| self.set_skin(skin) | |
| try: | |
| config.write("pet_skin", spec) | |
| except OSError as e: | |
| self.say(f"i couldn't save my new look ({e})", ttl=NOTICE_TTL, force=True) | |
| def set_skin(self, skin): | |
| self.skin = skin | |
| self.skin_anim.update(row=None, since=time.monotonic()) | |
| self.win.apply_mask(skin) | |
| self.win.look = None | |
| if self.tray is not None: | |
| self.tray.setIcon(self.icon()) | |
| self.w.excite(time.monotonic(), 1.5) | |
| self.win.update() | |
| # -- updates: never installs anything, only says so | |
| def maybe_check_updates(self): | |
| due = update_due(bool(_config(config.update_check, True)), config.offline(), | |
| _config(config.last_update_check, 0.0), time.time()) | |
| if due: | |
| self.check_updates(manual=False) | |
| def check_updates(self, manual=False): | |
| if self.update_running: | |
| if manual: | |
| self.say("i'm already looking", ttl=6, force=True) | |
| return | |
| if config.offline(): | |
| if manual: | |
| self.say("i'm offline (`HF_HUB_OFFLINE` / `MOTH_OFFLINE`), so i won't look for a new me", ttl=10, | |
| force=True) | |
| return | |
| self.update_running = True | |
| try: | |
| config.write(config.LAST_UPDATE_CHECK, round(time.time())) | |
| except OSError: | |
| pass | |
| if manual: | |
| self.say("looking for a new me…", ttl=6, force=True) | |
| def go(): | |
| res, info = check_for_update() | |
| if not self.stop.is_set(): | |
| self.events.put(("update", (res, info, manual))) | |
| threading.Thread(target=go, daemon=True, name="moth-pet-update").start() | |
| def __del__(self): | |
| self.stop.set() | |
| pet = Pet() | |
| if boot.server: | |
| app.aboutToQuit.connect(boot.server.stop) # menu quit, Ctrl-C, SIGTERM/SIGHUP, window closed | |
| quit_now.append(pet.quit) # signals from here on quit through Qt | |
| if "pending" in quit_now: # a signal came while the windows were being built: quit once the loop runs | |
| QTimer.singleShot(0, pet.quit) | |
| if on_ready: | |
| on_ready(pet) | |
| try: | |
| return app.exec() | |
| finally: | |
| # The QTimer outlives exec() (Qt signal connections keep the Pet alive), and a later event loop in this | |
| # process would tick a finished pet: with tests running pets one after another, an old pet's daily update | |
| # check wrote last_update_check into the next test's config, so that pet saw no check due (seen on Windows). | |
| pet.stop.set() | |
| pet.timer.stop() | |
| def build_parser() -> argparse.ArgumentParser: | |
| ap = argparse.ArgumentParser(description="moth as a desktop pet (needs: pip install PySide6)") | |
| ap.add_argument("--size", type=int, default=64, help="moth width in px (32-160, default 64)") | |
| ap.add_argument("--no-wander", action="store_true", help="sit in the bottom-right corner instead of flying around") | |
| ap.add_argument("--url", default=DEFAULT_URL, help="moth server (default $MOTH_URL or http://127.0.0.1:4747)") | |
| ap.add_argument("--token-file", default=None, help="default $MOTH_TOKEN_FILE or ~/.moth/token") | |
| ap.add_argument("--skin", metavar="ID|FOLDER", default=None, | |
| help="look like an installed Petdex pet (~/.petdex/pets/<id>, $CODEX_HOME/pets, <hermes home>/pets) " | |
| "or a pet folder; 'moth' for moth. Default: pet_skin in ~/.moth/config.json (moth)") | |
| ap.add_argument("--export-petdex", metavar="DIR", default=None, | |
| help="write the moth as a Petdex/Codex pet (DIR/pet.json + spritesheet.png) and exit") | |
| ap.add_argument("--no-server", action="store_true", | |
| help="never start a moth server, just wait for one (by default the pet starts one when none " | |
| "answers, and stops it when the pet quits)") | |
| ap.add_argument("--model", metavar="DIR", default=None, | |
| help="weights for a server the pet starts (default: this install's, see moth/launch.py)") | |
| ap.add_argument("--server-arg", metavar="ARG", action="append", default=[], | |
| help="one flag for a server the pet starts, repeatable: --server-arg=--react-every " | |
| "--server-arg=0 (or put moth.serve flags after --: -- --react-every 0)") | |
| return ap | |
| def parse_args(argv=None) -> argparse.Namespace: | |
| """The pet's flags; everything after a bare -- goes to the server the pet starts (a.passthrough).""" | |
| argv = list(sys.argv[1:] if argv is None else argv) | |
| rest: list[str] = [] | |
| if "--" in argv: | |
| i = argv.index("--") | |
| argv, rest = argv[:i], argv[i + 1:] | |
| a = build_parser().parse_args(argv) | |
| a.passthrough = rest | |
| return a | |
| def main(argv=None) -> int: | |
| a = parse_args(argv) | |
| if a.export_petdex: | |
| os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") # no window needed to draw a sheet | |
| try: | |
| paths = export_petdex(a.export_petdex) | |
| except ImportError: | |
| print("the export needs PySide6: pip install PySide6", file=sys.stderr) | |
| return 2 | |
| print("\n".join(paths)) | |
| print("use it: copy the folder to $CODEX_HOME/pets/moth (Codex: /pets), <hermes home>/pets/moth " | |
| "(hermes pets select moth) or ~/.petdex/pets/moth (Petdex desktop)") | |
| return 0 | |
| return run(a) | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |