feat(pets): make display.pet.scale the single master size knob
One scalar now shrinks every surface together. The desktop canvas already multiplied native pixels by scale; the CLI/TUI now derive their terminal width from it via constants.resolve_cols() instead of a separate pinned unicode_cols (which is now an optional override, default 0 = auto). Two sizing bugs fixed along the way: - kitty placement sized its c×r cell box from a native-aspect column count, so small pets got upscaled ~2× to fill it. It now derives the box from the scaled frame pixels (_cell_box, shared with the half-block frame() path), so kitty tracks scale like the GUI does. - half-blocks can't follow scale all the way down — a cell samples the sprite at 1 horizontal + 2 vertical taps, so a tiny width turns the pet to mush. cols_for_scale() clamps to a legibility floor (UNICODE_MIN_COLS) and only grows above it, while kitty/GUI keep shrinking on true pixels. Default scale lowered 0.7 -> 0.33 (glanceable corner sprite); the 0.7 literal fallbacks in pets.py and the desktop sprite now reference the shared default.
This commit is contained in:
parent
fdcfa44584
commit
c176054c33
@ -24,9 +24,43 @@ FRAMES_PER_STATE = 6
|
||||
# Full-loop duration for one state, milliseconds (petdex default).
|
||||
LOOP_MS = 1100
|
||||
|
||||
# Default on-screen scale relative to native frame size (petdex desktop uses
|
||||
# 0.7). Surfaces may override via ``display.pet.scale``.
|
||||
DEFAULT_SCALE = 0.7
|
||||
# Default on-screen scale relative to native frame size. ``display.pet.scale``
|
||||
# is the single master scalar: the desktop canvas multiplies its native pixels
|
||||
# by it and every terminal surface derives its half-block/kitty column width
|
||||
# from it (see :func:`cols_for_scale`), so one number shrinks all three
|
||||
# interfaces together. (petdex's own clients render at 0.7; we default smaller
|
||||
# so the kitty/GUI mascot stays a glanceable corner sprite. The half-block
|
||||
# fallback can't shrink as far — see ``UNICODE_MIN_COLS`` — and clamps to its
|
||||
# legibility floor instead.)
|
||||
DEFAULT_SCALE = 0.33
|
||||
|
||||
# Terminal cells one native frame spans at ``scale == 1.0``. A cell is ~8px
|
||||
# wide, a frame is ``FRAME_W`` (192) px → 24 cells. This mirrors the kitty
|
||||
# graphics placement (``scaled_px // 8``) so at full scale every renderer agrees.
|
||||
BASE_UNICODE_COLS = FRAME_W // 8
|
||||
|
||||
# Legibility floor for the half-block fallback. A half-block cell samples the
|
||||
# sprite at only 1 horizontal + 2 vertical taps, so below this width a 192×208
|
||||
# pet collapses into an unreadable blob *regardless* of scale. kitty/GUI draw
|
||||
# true pixels and have no such floor — that's why the same ``scale: 0.33`` is
|
||||
# crisp there but mush in half-blocks. ``scale`` shrinks the unicode pet down
|
||||
# TO this floor (and grows it above), instead of past it into noise.
|
||||
UNICODE_MIN_COLS = 16
|
||||
|
||||
|
||||
def cols_for_scale(scale: float) -> int:
|
||||
"""Half-block width implied by *scale*, clamped to the legibility floor.
|
||||
|
||||
Above the floor it tracks the kitty cell box (``scaled_px // 8``) so the two
|
||||
renderers converge at larger sizes; below it the floor keeps the sprite
|
||||
readable rather than letting it devolve into a blob.
|
||||
"""
|
||||
return max(UNICODE_MIN_COLS, round(BASE_UNICODE_COLS * (scale or DEFAULT_SCALE)))
|
||||
|
||||
|
||||
def resolve_cols(scale: float, unicode_cols: int = 0) -> int:
|
||||
"""Resolve terminal width: explicit *unicode_cols* override, else from *scale*."""
|
||||
return int(unicode_cols) if unicode_cols and int(unicode_cols) > 0 else cols_for_scale(scale)
|
||||
|
||||
|
||||
class PetState(str, Enum):
|
||||
|
||||
@ -489,27 +489,29 @@ class PetRenderer:
|
||||
frame = frames[index % len(frames)]
|
||||
return _downscale_cells(frame, target_cols=cols or self.unicode_cols)
|
||||
|
||||
def kitty_cell_rows(self, cols: int) -> int:
|
||||
"""Cell height that mirrors the half-block footprint for *cols* wide.
|
||||
def _cell_box(self, frame) -> tuple[int, int]:
|
||||
"""Terminal cell box for a scaled frame (~8×16 px per cell).
|
||||
|
||||
Keeps the kitty image and the unicode fallback occupying the same area
|
||||
so swapping renderers doesn't shift the layout.
|
||||
Must match :meth:`frame` graphics sizing — kitty stretches the image to
|
||||
fill ``c``×``r`` cells, so these must reflect the scaled pixel
|
||||
dimensions, not a native-aspect column count (that upscales small pets).
|
||||
"""
|
||||
aspect = self.frame_h / max(1, self.frame_w)
|
||||
return max(1, round(cols * aspect * 0.5))
|
||||
return max(1, frame.width // 8), max(1, frame.height // 16)
|
||||
|
||||
def kitty_payload(self, state: PetState | str, *, cols: int, image_id: int) -> dict | None:
|
||||
def kitty_payload(self, state: PetState | str, *, image_id: int) -> dict | None:
|
||||
"""Build the kitty Unicode-placeholder payload for one state.
|
||||
|
||||
Returns ``{cols, rows, placeholder, frames}`` where ``frames`` is a
|
||||
list of transmit escapes (one per animation frame, all reusing
|
||||
``image_id``) and ``placeholder`` is the static text grid Ink paints.
|
||||
``None`` when no frame is available.
|
||||
Placement geometry is derived from the scaled frame pixels (via
|
||||
:meth:`_cell_box`), not ``unicode_cols`` — kitty upscales to fill
|
||||
``c``×``r`` cells. ``None`` when no frame is available.
|
||||
"""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return None
|
||||
rows = self.kitty_cell_rows(cols)
|
||||
cols, rows = self._cell_box(frames[0])
|
||||
return {
|
||||
"cols": cols,
|
||||
"rows": rows,
|
||||
@ -531,11 +533,7 @@ class PetRenderer:
|
||||
if not frames:
|
||||
return ""
|
||||
frame = frames[index % len(frames)]
|
||||
|
||||
# Display box in cells for graphics protocols (≈ scaled px / cell size,
|
||||
# assuming a ~8×16 cell; terminals re-fit anyway).
|
||||
cell_cols = max(1, frame.width // 8)
|
||||
cell_rows = max(1, frame.height // 16)
|
||||
cell_cols, cell_rows = self._cell_box(frame)
|
||||
|
||||
try:
|
||||
if self.mode == "kitty":
|
||||
|
||||
@ -6,6 +6,9 @@ const DEFAULT_FRAME_W = 192
|
||||
const DEFAULT_FRAME_H = 208
|
||||
const DEFAULT_FRAMES = 6
|
||||
const DEFAULT_LOOP_MS = 1100
|
||||
// Mirrors agent.pet.constants.DEFAULT_SCALE — fallback only; the gateway sends
|
||||
// the configured scale.
|
||||
const DEFAULT_SCALE = 0.33
|
||||
const DEFAULT_STATE_ROWS = ['idle', 'wave', 'run', 'failed', 'review', 'jump', 'extra1', 'extra2']
|
||||
|
||||
interface PetSpriteProps {
|
||||
@ -33,7 +36,7 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
|
||||
const frameH = info.frameH ?? DEFAULT_FRAME_H
|
||||
const frames = info.framesPerState ?? DEFAULT_FRAMES
|
||||
const loopMs = info.loopMs ?? DEFAULT_LOOP_MS
|
||||
const scale = (info.scale ?? 0.7) * zoom
|
||||
const scale = (info.scale ?? DEFAULT_SCALE) * zoom
|
||||
const rows = info.stateRows ?? DEFAULT_STATE_ROWS
|
||||
|
||||
const drawW = Math.round(frameW * scale)
|
||||
|
||||
2
cli.py
2
cli.py
@ -4150,8 +4150,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
|
||||
enabled = bool(pet_cfg.get("enabled"))
|
||||
slug = str(pet_cfg.get("slug", "") or "")
|
||||
cols = int(pet_cfg.get("unicode_cols", 18) or 18)
|
||||
scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
|
||||
cols = constants.resolve_cols(scale, pet_cfg.get("unicode_cols", 0))
|
||||
|
||||
if not enabled:
|
||||
with self._pet_lock:
|
||||
|
||||
@ -1531,10 +1531,16 @@ DEFAULT_CONFIG = {
|
||||
# auto — detect kitty/iTerm2/sixel, else unicode half-blocks
|
||||
# kitty | iterm | sixel | unicode | off
|
||||
"render_mode": "auto",
|
||||
# On-screen scale relative to native 192×208 frames (petdex uses 0.7).
|
||||
"scale": 0.7,
|
||||
# Width in terminal columns for the unicode half-block fallback.
|
||||
"unicode_cols": 18,
|
||||
# Master size scalar (relative to native 192×208 frames). One knob
|
||||
# shrinks every surface: the desktop canvas scales its pixels by it
|
||||
# and the CLI/TUI derive their terminal column width from it. The
|
||||
# half-block fallback clamps to a legibility floor (it can't shrink
|
||||
# as far as true-pixel kitty/GUI without turning to mush).
|
||||
"scale": 0.33,
|
||||
# Hard override for terminal column width. 0 = auto (derive from
|
||||
# scale); set a positive int only to pin the half-block/kitty width
|
||||
# independently of scale.
|
||||
"unicode_cols": 0,
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@ -137,7 +137,7 @@ def _cmd_show(args) -> int:
|
||||
import time
|
||||
|
||||
from agent.pet import store
|
||||
from agent.pet.constants import LOOP_MS, STATE_ROWS, PetState
|
||||
from agent.pet.constants import DEFAULT_SCALE, LOOP_MS, STATE_ROWS, PetState, resolve_cols
|
||||
from agent.pet.render import build_renderer
|
||||
|
||||
cfg = _pet_config()
|
||||
@ -148,8 +148,8 @@ def _cmd_show(args) -> int:
|
||||
return 1
|
||||
|
||||
mode_cfg = getattr(args, "mode", None) or str(cfg.get("render_mode", "auto") or "auto")
|
||||
scale = float(getattr(args, "scale", 0) or cfg.get("scale", 0.7) or 0.7)
|
||||
cols = int(cfg.get("unicode_cols", 18) or 18)
|
||||
scale = float(getattr(args, "scale", 0) or cfg.get("scale", DEFAULT_SCALE) or DEFAULT_SCALE)
|
||||
cols = resolve_cols(scale, cfg.get("unicode_cols", 0))
|
||||
|
||||
renderer = build_renderer(
|
||||
pet.spritesheet,
|
||||
|
||||
@ -48,6 +48,26 @@ def test_state_row_index_maps_to_taxonomy():
|
||||
assert constants.state_row_index("nonsense") == 0
|
||||
|
||||
|
||||
def test_cols_for_scale_is_monotonic_and_floored():
|
||||
# scale is the master size knob: smaller scale never yields more columns,
|
||||
# and half-blocks clamp to a legibility floor rather than devolving to mush.
|
||||
sizes = [constants.cols_for_scale(s) for s in (0.1, 0.3, 0.5, 0.7, 1.0, 1.5)]
|
||||
assert sizes == sorted(sizes)
|
||||
assert all(c >= constants.UNICODE_MIN_COLS for c in sizes)
|
||||
# tiny scales pin to the floor; large scales grow past it.
|
||||
assert constants.cols_for_scale(0.05) == constants.UNICODE_MIN_COLS
|
||||
assert constants.cols_for_scale(0.33) == constants.UNICODE_MIN_COLS
|
||||
assert constants.cols_for_scale(2.0) > constants.UNICODE_MIN_COLS
|
||||
|
||||
|
||||
def test_resolve_cols_override_else_scale():
|
||||
# 0 / falsy → derive from scale; a positive int hard-overrides scale.
|
||||
assert constants.resolve_cols(0.7, 0) == constants.cols_for_scale(0.7)
|
||||
assert constants.resolve_cols(0.7, None) == constants.cols_for_scale(0.7)
|
||||
assert constants.resolve_cols(2.0, 12) == 12
|
||||
assert constants.resolve_cols(0.1, -5) == constants.cols_for_scale(0.1)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# synthetic spritesheet fixture
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
@ -185,11 +205,16 @@ def test_kitty_placeholder_rows_grid_contract():
|
||||
def test_kitty_payload_structure(boba_like):
|
||||
sprite = store.load_pet("boba").spritesheet
|
||||
image_id = render.kitty_image_id("boba")
|
||||
r = render.PetRenderer(str(sprite), mode="kitty", scale=0.4, unicode_cols=18)
|
||||
payload = r.kitty_payload("run", cols=18, image_id=image_id)
|
||||
scale = 0.4
|
||||
r = render.PetRenderer(str(sprite), mode="kitty", scale=scale, unicode_cols=18)
|
||||
payload = r.kitty_payload("run", image_id=image_id)
|
||||
assert payload is not None
|
||||
assert payload["cols"] == 18
|
||||
assert payload["rows"] == r.kitty_cell_rows(18) >= 1
|
||||
# placement box must follow scaled pixels, not unicode_cols (kitty upscales to c×r).
|
||||
frames = r._frames("run")
|
||||
expect_cols, expect_rows = r._cell_box(frames[0])
|
||||
assert payload["cols"] == expect_cols
|
||||
assert payload["rows"] == expect_rows
|
||||
assert expect_cols < 18 # 0.4 scale is much smaller than a pinned 18-col box
|
||||
# placeholder grid matches the requested geometry
|
||||
assert len(payload["placeholder"]) == payload["rows"]
|
||||
# one transmit escape per animation frame, each a kitty virtual placement
|
||||
@ -204,7 +229,7 @@ def test_kitty_payload_structure(boba_like):
|
||||
|
||||
def test_kitty_payload_none_when_no_frames(tmp_path):
|
||||
r = render.PetRenderer(str(tmp_path / "missing.webp"), mode="kitty")
|
||||
assert r.kitty_payload("idle", cols=18, image_id=1) is None
|
||||
assert r.kitty_payload("idle", image_id=1) is None
|
||||
|
||||
|
||||
def test_off_mode_and_missing_sheet_degrade(tmp_path):
|
||||
|
||||
@ -4902,8 +4902,8 @@ def _(rid, params: dict) -> dict:
|
||||
return _ok(rid, {"enabled": False})
|
||||
|
||||
state = str(params.get("state") or constants.PetState.IDLE.value)
|
||||
cols = int(params.get("cols") or pet_cfg.get("unicode_cols", 18) or 18)
|
||||
scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
|
||||
cols = int(params.get("cols") or 0) or constants.resolve_cols(scale, pet_cfg.get("unicode_cols", 0))
|
||||
|
||||
# Graphics path: when the TUI is attached to a real TTY (``graphics``)
|
||||
# and the terminal speaks the kitty protocol, return a Unicode-
|
||||
@ -4917,9 +4917,10 @@ def _(rid, params: dict) -> dict:
|
||||
gmode = render.detect_terminal_graphics() if configured in ("", "auto") else configured
|
||||
if gmode == "kitty":
|
||||
image_id = render.kitty_image_id(pet.slug)
|
||||
# kitty sizes from scaled pixels (_cell_box), so unicode_cols is moot here.
|
||||
payload = PetRenderer(
|
||||
str(pet.spritesheet), mode="kitty", scale=scale, unicode_cols=cols
|
||||
).kitty_payload(state, cols=cols, image_id=image_id)
|
||||
str(pet.spritesheet), mode="kitty", scale=scale
|
||||
).kitty_payload(state, image_id=image_id)
|
||||
if payload:
|
||||
kcount = len(payload["frames"]) or 1
|
||||
return _ok(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user