From de08231cc03ca06153a0e557fa8d993047ac56ad Mon Sep 17 00:00:00 2001 From: Zach Guo Date: Fri, 7 Aug 2026 19:20:03 +0800 Subject: [PATCH 1/2] feat(tui): the raster figure, ring starfield, sampled palette [spec 10] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third section-6.1 by-eye round, driven by "match the concept's fineness": - The whisper-figure is machine-derived now: the designer's own figure extracted at its true 2px mesh by proper-pixel-art into a 42x44 two-tone .pix grid (cream fill, warm outline, embers as overlays). - On kitty-graphics terminals (Ghostty/kitty) the figure renders as a runtime-encoded PNG placed over the sky — integer nearest-neighbour scale from the tty's real cell pixel size, pose frames streamed under one image id, doze fading toward the ground. figurePen decides; MURMUR_TUI_FIGURE=image|sprite overrides; character terminals and the narrow band keep the text-sprite path. - The starfield is concentric ripple rings off the wave's circle — quasi-regular arc spacing, jitter, gap-broken, near-dark — plus a thin free scatter; only warm accents may be 2x2. - The palette is sampled from the mockup: warm QUIET greys (the old blue COOL is gone, name and value), peach EMBER, sage user ink, periwinkle for the listener's channel (input + note), #090e17 ground. - A near-covered two-ink cell keeps both colors (glyph + background) in the octant fold, so the two-tone figure stops muddying at edges. - The wide composition centers past 184 columns with symmetric margins. Co-Authored-By: Claude Fable 5 --- specs/STATUS.md | 9 +- specs/spec10/10-tui.md | 104 +++++--- test/tui-constellation.test.ts | 200 ++++++++++------ test/tui-figure-image.test.ts | 95 ++++++++ test/tui-pet.test.ts | 52 +--- tsconfig.json | 8 +- tui/assets/pet/doze.pix | 64 +++-- tui/assets/pet/idle.pix | 128 ++++++---- tui/assets/pet/music.pix | 128 ++++++---- tui/assets/pet/talk.pix | 192 ++++++++++----- tui/assets/pet/wake.pix | 64 +++-- tui/src/app.tsx | 262 ++++++++++++++------ tui/src/bun-ffi.d.ts | 13 + tui/src/constellation.ts | 426 +++++++++++++++++++++++---------- tui/src/figure-image.ts | 143 +++++++++++ tui/src/palette.ts | 31 ++- tui/src/pet.ts | 66 +---- 17 files changed, 1387 insertions(+), 598 deletions(-) create mode 100644 test/tui-figure-image.test.ts create mode 100644 tui/src/bun-ffi.d.ts create mode 100644 tui/src/figure-image.ts diff --git a/specs/STATUS.md b/specs/STATUS.md index d549705..d32febb 100644 --- a/specs/STATUS.md +++ b/specs/STATUS.md @@ -7,10 +7,11 @@ _This file is a **card, not a ledger**: an entry that is done and no longer guides the work gets **deleted**, not archived. History lives in git and PR bodies; measured facts live in the spec they verify._ -_Last updated: 2026-08-07 (spec 10 §6.1 refined per first by-eye feedback: -the pet is now the logo whisper-figure, the mist floor is an arc with the -figure in its hollow, and the palette is sampled from the concept mockup; -remaining tuning stays issue #79)_ +_Last updated: 2026-08-07 (spec 10 §6.1 third by-eye round: octant sub-pixel +canvas with ring starfield and radial wave, the whisper-figure machine-derived +from the concept via proper-pixel-art and rendered as a kitty-graphics raster +on capable terminals, full palette sampled from the mockup, centered max-width +frame; remaining tuning stays issue #79)_ ## Where we are diff --git a/specs/spec10/10-tui.md b/specs/spec10/10-tui.md index 324018d..00d887b 100644 --- a/specs/spec10/10-tui.md +++ b/specs/spec10/10-tui.md @@ -7,11 +7,11 @@ > commits the candidate or submits the line (§5.1's note below). Slice 2 > (engine FFT tap + `viz` feed, §3.6) and slice 3 (pet substrate + warmth kit, > §3.7) landed 2026-07-30. The §6.1 art-direction session chose and landed the -> **quiet-constellation** composition (2026-08-07): the palette discipline, -> the wide-terminal sky panel (§3.3), and the mist rendering of the viz feed -> (§3.6) — contracts untouched. What remains open is sensory: the §5.11 human -> pass and the by-eye tuning of the new skin (issue #79), plus the pet's -> identity, which stays a §6.1 question. +> **quiet-constellation** composition (2026-08-07): the sampled palette, the +> centered wide-terminal sky panel (§3.3), the octant sub-pixel rendering of +> the viz feed (§3.6), and the raster whisper-figure (§3.7) — contracts +> untouched. What remains open is sensory: the §5.11 human pass and the +> by-eye tuning of the new skin (issue #79). > **Part**: Front-end refinement — replaces the CLI Host's plain print/stdin > with a real TUI. The **single richer front-end murmur ever gets**: there is > no GUI, no menu-bar, no web surface (master [`../DESIGN.md`](../DESIGN.md) @@ -348,14 +348,17 @@ mid-keystroke; a mode the listener opened is the listener spending their own focus, and the broadcast never pauses under it. **As built (2026-08-07, §6.1 quiet-constellation): composition has one -breakpoint.** At ≥ 96 columns the alive band recomposes as a **sky panel** -beside the log: the program log keeps the left with one blank line between -entries, the panel takes the right — a seeded starfield, the visualizer's -bins as a floating braille particle mist (§3.6), the pet drifting inside it, -and now-playing as a dim `♪` line under the panel. Below 96 columns the -classic bottom band stands unchanged, and now-playing stays in the status -strip. Same four regions either way; only the composition moves, which is -exactly the §6.1 licence. +breakpoint, and one max width.** At ≥ 96 columns the alive band recomposes +as a **sky panel** beside the log: the program log keeps the left with one +blank line between entries and the newest broadcast line carrying a bullet, +the panel takes the right — a seeded ring-biased starfield, the visualizer's +bins as a radial wave riding an implied circle (§3.6), the whisper-figure at +the circle's center (§3.7), and now-playing as a centered tricolor `♪` line +under the panel. The strip becomes one centered line over a full-width rule. +Past 184 columns the whole frame centers with symmetric margins instead of +stretching. Below 96 columns the classic bottom band stands unchanged, and +now-playing stays in the status strip. Same four regions either way; only +the composition moves, which is exactly the §6.1 licence. **As built (2026-08-06, issue #95): the pet is optional.** `MURMUR_TUI_PET=0` (also `off` / `false` / `no`) drops the creature from the alive band, and the @@ -417,15 +420,22 @@ only the client — one shutdown path. *speaks*; on the bed it breathes low. (What ships in v1 is a §6.1 styling call; the feed contract is the same.) - **As built (2026-08-07, §6.1)**: the bar strip is the narrow composition; - wide terminals re-render the SAME feed as the constellation mist — - braille-cell particles climbing from an **arced floor** (high at the panel's - edges, dipping at the center, the figure floating in the hollow), density - following the bin level, jittered so cells stay fine-grained, fading out at - the panel's side walls, a sparse fallout drifting below. The sky paints on the - client's own 12fps clock and viz frames only feed the smoother, so stars - twinkle and the pet animates even when the engine is silent. All of it is - client-side arithmetic (`tui/src/constellation.ts`); the feed contract is - untouched. + wide terminals re-render the SAME feed as the constellation wave. The panel + draws on one canvas of square **sub-pixels** — two per cell width, four per + cell height — folded to **octant mosaics** (Unicode 16) where the terminal + synthesizes them (Ghostty/kitty/WezTerm; `penFor`, override + `MURMUR_TUI_PIXEL=octant|half`) and to half-blocks everywhere else. The + wave is dashed columns of square blocks whose **bases ride the lower arc of + one implied circle** (deep at the center, shallow at the arms), three + temperatures by level (peach-ember peaks / cream mids / warm-grey quiet), + fraying tips, ghost echoes past the tip. The starfield is concentric + **ripple rings** off the same circle — quasi-regular arc spacing, jittered, + gap-broken, near-dark — under a thin free scatter with rare warm accents. + A near-covered cell holding exactly two inks keeps both (majority ink on + the glyph, the other behind it). The sky paints on the client's own 12fps + clock and viz frames only feed the smoother, so stars twinkle and the + figure animates even when the engine is silent. All of it is client-side + arithmetic (`tui/src/constellation.ts`); the feed contract is untouched. ### 3.7 The warmth kit (techniques adopted from the case research) @@ -437,6 +447,15 @@ provides them): as assets — zero image machinery, renders everywhere, upgradeable to octant glyphs on capable terminals. Idle loop at 2–8 fps via OpenTUI's timeline; reaction poses keyed off `ProgramState` (§3.2-D). + **As built (2026-08-07, §6.1)**: the asset stays text (`.pix` grids, keys + `x` cream fill / `w` warm outline / `s` ember sparkle), but on + kitty-graphics terminals (Ghostty/kitty; `figurePen`, override + `MURMUR_TUI_FIGURE=image|sprite`) the wide-panel figure renders as a + **runtime-encoded PNG** over the kitty graphics protocol + (`tui/src/figure-image.ts`) — integer nearest-neighbour scale chosen from + the tty's real cell pixel size, pose frames streamed under one image id, + doze fading the inks toward the ground. Character terminals and the + narrow band keep the text-sprite path unchanged. 2. **Content-derived tinting** (kew's signature): when a track starts, derive a small accent palette and tint the UI with it — the interface breathes with the music. v1 may fall back to scene-based tinting (spec 04's @@ -593,8 +612,11 @@ TUI work is built on the framework choice. - **Per-track palette source** (§3.7.2): thumbnails vs metadata-derived vs scene-only for v1 — decide when the tinting mechanism lands. - **Visualizer during talk** (§3.6): voice envelope vs quiet strip — by ear. -- **opentui#92** (graphics-protocol bitmaps): when it lands, evaluate a crisp - pet / album-art upgrade behind the same sprite substrate. +- **opentui#92** (graphics-protocol bitmaps): when it lands, evaluate album + art behind the same substrate. **Partly superseded (2026-08-07)**: the + figure already ships as a kitty-graphics raster via direct escape emission + (§3.7) without waiting for OpenTUI support; opentui#92 remains relevant + only if album art wants renderer-managed images. - **Bun→Node exit**: revisit when OpenTUI's Node FFI support leaves experimental status. @@ -606,23 +628,27 @@ spec builds and does not reopen the contracts above. **Decided (2026-08-07, concept session over `scratch/ui-concepts/logo-pet`): the skin is `04-quiet-constellation`.** Its terms: -- **Palette discipline**: one shared deep blue-charcoal night ground; each - scene is a single near-monochrome accent family on it (dawn cream / - daylight sage / ember / moonlight) — the hour changes the warmth of the - light, never the room. Values live in `tui/src/palette.ts` and stay by-eye - knobs. -- **Composition**: the one-breakpoint sky-panel layout (§3.3) and the mist - rendering of the viz feed (§3.6). +- **Palette discipline**: one shared deep blue-black night ground; each + scene is a single near-monochrome accent family on it — the hour changes + the warmth of the light, never the room. **Sampled from the concept + (2026-08-07)**: everything quiet is a WARM grey (`QUIET`), the bright warm + accent is peach (`EMBER`), the figure's outline is warm brown (`WARM`), + the listener's channel — input line and `♪` — is periwinkle, the room's + one cold ink, and the listener's own words are sage. Values live in + `tui/src/palette.ts` and stay by-eye knobs. +- **Composition**: the one-breakpoint, max-width sky-panel layout (§3.3) and + the octant sub-pixel rendering of the viz feed (§3.6). - **Rejected**: the analog-radio dial chrome (01), the framed scene panel (02 — per-scene hand art, cost without a daily payoff), the zine poster (03 — display type has no CJK form and no small-terminal degradation). **The pet's identity is the murmur logo (decided 2026-08-07):** the -whisper-figure in profile, rendered in the solid-fill cross-stitch reading — -hair as the dim accent mass, skin as the bright mass, dark seams carrying -the closed eye, mouth, and finger gaps. The committed sprites are generated -over a downscale of `assets/murmur-logo.svg`; whisper sparkles and drifting -notes are the pose overlays. Still open: the by-eye tuning of all of the -above in a real terminal (issue #79) — mist arc/density/fade, star density, -twinkle, figure size and placement, per-scene accents (sampled from the -concept mockup; values in `tui/src/palette.ts`). +whisper-figure in profile, hand raised to the lips. The committed sprite is +**machine-derived, not hand-drawn**: the designer's own figure from the 04 +concept, extracted at its true 2px mesh by `proper-pixel-art` +(`scratch/make-pet.py` regenerates), yielding a 42×44 two-tone grid — cream +fill inside a warm outline — with the drifting embers lifted into pose +overlay frames. On kitty-graphics terminals it renders as a raster (§3.7); +elsewhere as octant/half-block cells. Still open: the by-eye tuning of all +of the above in a real terminal (issue #79) — wave depth/density/fray, star +ring density, twinkle, figure size and placement, per-scene accents. diff --git a/test/tui-constellation.test.ts b/test/tui-constellation.test.ts index 3b3e6ea..8080b5c 100644 --- a/test/tui-constellation.test.ts +++ b/test/tui-constellation.test.ts @@ -1,52 +1,59 @@ -// The constellation panel's client half (spec 10 §3.6 / §6.1 quiet-constellation -// art direction): a seeded starfield, the FFT bins re-rendered as a braille -// particle mist, and the pet floating inside it. +// The constellation panel's client half (spec 10 §3.6 / §6.1): the seeded +// ring-biased starfield, the FFT bins as a radial wave of square blocks, and +// the whisper figure at the circle's center. // -// Like bars.ts, the module is free of OpenTUI and React on purpose: the frame a -// terminal paints cannot be unit-asserted (§3.9), but "which dots for this +// Like bars.ts, the module is free of OpenTUI and React on purpose: the frame +// a terminal paints cannot be unit-asserted (§3.9), but "which pixels for this // level" can — and determinism (seeded stars, hashed jitter) is itself part of // the contract, because a resize must not reshuffle the sky. import { describe, expect, it } from 'vitest' -import { Constellation, hash01, panelWidth, WIDE_MIN } from '../tui/src/constellation.ts' -import { accentFor } from '../tui/src/palette.ts' +import { + circleOf, + Constellation, + hash01, + OCTANTS, + panelWidth, + seedStars, + STAR_RINGS, + WIDE_MIN, +} from '../tui/src/constellation.ts' +import { accentFor, INK, WARM } from '../tui/src/palette.ts' +import type { Run } from '../tui/src/constellation.ts' const ACCENT = accentFor('late-night') -const BRAILLE_START = 0x28_00 -const BRAILLE_END = 0x28_ff - -function isBraille(char: string): boolean { - const code = char.codePointAt(0)! - return code >= BRAILLE_START && code <= BRAILLE_END +function rowText(row: Run[]): string { + return row.map((run) => run.text).join('') } -// Total lit sub-pixels in a frame: each braille cell carries its dots in the -// low byte of the code point. -function litDots(rows: { text: string }[][]): number { - let dots = 0 +// Cells carrying any ink (spaces are the empty ground). +function litCells(rows: Run[][]): number { + let cells = 0 for (const row of rows) { for (const run of row) { - for (const char of run.text) { - if (isBraille(char)) { - let bits = char.codePointAt(0)! - BRAILLE_START - while (bits > 0) { - dots += bits & 1 - bits >>= 1 - } - } - } + cells += [...run.text].filter((ch) => ch !== ' ').length } } - return dots + return cells } -function rowText(row: { text: string }[]): string { - return row.map((run) => run.text).join('') +// Per-column deepest lit cell row, over cells NOT present in the silence +// frame — which isolates the wave from the stars. +function waveDepth(rows: Run[][], silence: Run[][], fromCol: number, toCol: number): number { + let deepest = -1 + rows.forEach((row, y) => { + const chars = [...rowText(row)] + const quiet = [...rowText(silence[y]!)] + for (let x = fromCol; x < toCol; x++) { + if (chars[x] !== ' ' && quiet[x] === ' ') deepest = Math.max(deepest, y) + } + }) + return deepest } -describe('Constellation (§6.1: starfield + particle mist)', () => { +describe('Constellation (§6.1: ring of stars, radial wave, square pixels)', () => { it('renders exactly height rows of exactly width cells', () => { const sky = new Constellation(40, 12, 7) const rows = sky.frame([0.2, 0.8, 0.5], ACCENT, null) @@ -54,6 +61,21 @@ describe('Constellation (§6.1: starfield + particle mist)', () => { for (const row of rows) expect([...rowText(row)]).toHaveLength(40) }) + it('paints with the octant pen by default — every glyph from the mosaic table', () => { + const allowed = new Set([...OCTANTS, ' ']) + const rows = new Constellation(40, 12, 7).frame([1, 0.5, 1], ACCENT, null) + for (const char of rows.flatMap((row) => [...rowText(row)])) { + expect(allowed.has(char), char).toBe(true) + } + }) + + it('falls back to the universal half-block pen on request', () => { + const rows = new Constellation(40, 12, 7, 'half').frame([1, 0.5, 1], ACCENT, null) + for (const char of rows.flatMap((row) => [...rowText(row)])) { + expect([' ', '▀']).toContain(char) + } + }) + it('draws the same sky for the same seed — a resize must not reshuffle it', () => { const a = new Constellation(40, 12, 7).frame([], ACCENT, null) const b = new Constellation(40, 12, 7).frame([], ACCENT, null) @@ -66,56 +88,52 @@ describe('Constellation (§6.1: starfield + particle mist)', () => { expect(a).not.toEqual(b) }) - it('has stars but no particles in silence', () => { - const sky = new Constellation(40, 12, 7) - const rows = sky.frame([0, 0, 0], ACCENT, null) - expect(litDots(rows)).toBe(0) - const glyphs = rows.flatMap((row) => [...rowText(row)]).filter((char) => char !== ' ') - expect(glyphs.length).toBeGreaterThan(0) + it('lights more pixels the louder the frame — the wave follows the music', () => { + const silence = litCells(new Constellation(48, 20, 7).frame([], ACCENT, null)) + const quiet = litCells(new Constellation(48, 20, 7).frame([0.3, 0.3, 0.3], ACCENT, null)) + const loud = litCells(new Constellation(48, 20, 7).frame([1, 1, 1], ACCENT, null)) + expect(quiet).toBeGreaterThan(silence) + expect(loud).toBeGreaterThan(quiet) }) - it('lights more dots the louder the frame — the mist follows the music', () => { - const quiet = litDots(new Constellation(40, 12, 7).frame([0.2, 0.2, 0.2], ACCENT, null)) - const loud = litDots(new Constellation(40, 12, 7).frame([1, 1, 1], ACCENT, null)) - expect(quiet).toBeGreaterThan(0) - expect(loud).toBeGreaterThan(quiet) + it('rides the circle: center columns bottom out deeper than the arms', () => { + const levels = Array.from({ length: 24 }, () => 0.7) + const silence = new Constellation(48, 24, 7).frame([], ACCENT, null) + const rows = new Constellation(48, 24, 7).frame(levels, ACCENT, null) + const arms = Math.max( + waveDepth(rows, silence, 4, 10), + waveDepth(rows, silence, 38, 44), + ) + const center = waveDepth(rows, silence, 20, 28) + expect(center).toBeGreaterThan(arms) }) - it('keeps every particle braille and every star a known glyph', () => { - const sky = new Constellation(40, 12, 7) - const rows = sky.frame([1, 0.5, 1], ACCENT, null) - for (const char of rows.flatMap((row) => [...rowText(row)])) { - expect(isBraille(char) || [' ', '·', '+', '✦'].includes(char)).toBe(true) - } + it('draws the figure at the center in cream ink', () => { + const figure = ['xx', 'xx'] + const rows = new Constellation(40, 12, 7).frame([], ACCENT, figure) + const cream = rows.flat().filter((run) => run.fg === INK.text && run.text.trim() !== '') + expect(cream.length).toBeGreaterThan(0) }) - it('blits the pet into the middle of the sky, background and all', () => { - const pet = [ - [ - { fg: '#111111', bg: '#222222' }, - { fg: '#111111', bg: '#222222' }, - ], - ] - const rows = new Constellation(40, 12, 7).frame([], ACCENT, pet) - const withPet = rows.flat().filter((run) => run.bg === '#222222') - expect(withPet.length).toBeGreaterThan(0) - }) - - it('bows the mist floor into an arc — edge columns bottom out higher than center ones', () => { - const rows = new Constellation(48, 20, 7).frame(Array.from({ length: 24 }, () => 0.6), ACCENT, null) - const deepestBraille = (fromCol: number, toCol: number): number => { - let deepest = -1 - rows.forEach((row, y) => { - const chars = [...rowText(row)] - for (let x = fromCol; x < toCol; x++) { - if (isBraille(chars[x] ?? ' ')) deepest = Math.max(deepest, y) - } - }) - return deepest - } - const edge = Math.max(deepestBraille(0, 6), deepestBraille(42, 48)) - const center = deepestBraille(21, 27) - expect(center).toBeGreaterThan(edge) + it('gives a fully-covered two-tone cell both inks: one on the glyph, one behind', () => { + // 2 sub-cols x 4 sub-rows per cell: an 'xw' column pair over 4 rows fills a + // cell with two colors and no gaps — the fold must not flatten it to one. + const figure = Array.from({ length: 8 }, () => 'xwxw') + const rows = new Constellation(40, 12, 7).frame([], ACCENT, figure) + const twoTone = rows + .flat() + .filter((run) => run.fg === INK.text && run.bg === WARM && run.text.trim() !== '') + expect(twoTone.length).toBeGreaterThan(0) + }) + + it('fades a dozing figure toward the room instead of swapping assets', () => { + const figure = ['xx', 'xx'] + const awake = new Constellation(40, 12, 7).frame([], ACCENT, figure, 0) + const dozing = new Constellation(40, 12, 7).frame([], ACCENT, figure, 0.5) + const creamCells = (rows: Run[][]): number => + rows.flat().filter((run) => run.fg === INK.text && run.text.trim() !== '').length + expect(creamCells(awake)).toBeGreaterThan(0) + expect(creamCells(dozing)).toBe(0) }) it('survives hostile bins and degenerate panels', () => { @@ -128,6 +146,38 @@ describe('Constellation (§6.1: starfield + particle mist)', () => { }) }) +describe('seedStars (§6.1: ripple rings of stars + a loose free scatter)', () => { + const subCols = 200 + const subRows = 240 + const stars = seedStars(subCols, subRows, 7) + const { cx, cy, radius } = circleOf(subCols, subRows) + const offRing = (star: { x: number; y: number }): number => + Math.min(...STAR_RINGS.map((frac) => Math.abs(Math.hypot(star.x - cx, star.y - cy) - radius * frac))) + + it('is deterministic per seed', () => { + expect(seedStars(subCols, subRows, 7)).toEqual(stars) + expect(seedStars(subCols, subRows, 8)).not.toEqual(stars) + }) + + it('lays the majority of stars along the concentric rings', () => { + const onRings = stars.filter((star) => offRing(star) <= 4).length + expect(onRings / stars.length).toBeGreaterThan(0.55) + }) + + it('spreads each ring around the circle instead of clumping', () => { + const octants = new Set( + stars + .filter((star) => offRing(star) <= 4) + .map((star) => Math.floor(((Math.atan2(star.y - cy, star.x - cx) + Math.PI) / (2 * Math.PI)) * 8)), + ) + expect(octants.size).toBeGreaterThanOrEqual(6) + }) + + it('keeps a free scatter clear of the rings — order AND looseness', () => { + expect(stars.filter((star) => offRing(star) > 6).length).toBeGreaterThan(5) + }) +}) + describe('hash01 (the survival dice behind every density knob)', () => { it('stays inside 0..1 — a signed hash passes every threshold and defeats the knobs', () => { for (let x = 0; x < 200; x++) { @@ -150,6 +200,6 @@ describe('panelWidth (the one §6.1 breakpoint)', () => { const atMin = panelWidth(WIDE_MIN) expect(atMin).not.toBeNull() expect(atMin!).toBeGreaterThanOrEqual(30) - expect(panelWidth(400)!).toBeLessThanOrEqual(64) + expect(panelWidth(400)!).toBeLessThanOrEqual(110) }) }) diff --git a/test/tui-figure-image.test.ts b/test/tui-figure-image.test.ts new file mode 100644 index 0000000..746ddc1 --- /dev/null +++ b/test/tui-figure-image.test.ts @@ -0,0 +1,95 @@ +// The figure as a real raster (spec 10 §6.1): kitty-graphics terminals draw +// the whisper-girl as an actual PNG, finer than any character mosaic. The +// escape framing is deterministic wire format — unit-assertable. +/* oxlint-disable no-control-regex -- the wire format under test IS escape bytes */ + +import { describe, expect, it } from 'vitest' + +import { + deleteFigures, + encodeFigurePng, + figurePen, + figureScale, + placeFigure, +} from '../tui/src/figure-image.ts' + +describe('figurePen (who can hold a raster at all)', () => { + it('reads the explicit override first', () => { + expect(figurePen({ MURMUR_TUI_FIGURE: 'sprite', TERM_PROGRAM: 'ghostty' })).toBe('sprite') + expect(figurePen({ MURMUR_TUI_FIGURE: 'image' })).toBe('image') + }) + + it('grants the raster to kitty-graphics terminals, sprite to the rest', () => { + expect(figurePen({ TERM_PROGRAM: 'ghostty' })).toBe('image') + expect(figurePen({ TERM: 'xterm-kitty' })).toBe('image') + expect(figurePen({ TERM_PROGRAM: 'Apple_Terminal' })).toBe('sprite') + expect(figurePen({})).toBe('sprite') + }) +}) + +describe('placeFigure (kitty graphics APC framing)', () => { + const png = Buffer.from('fake png bytes') + + it('saves the cursor, moves to the cell, transmits, and restores', () => { + const seq = placeFigure(png, 5, 40, 1) + expect(seq.startsWith('\x1b7\x1b[5;40H')).toBe(true) + expect(seq.endsWith('\x1b8')).toBe(true) + }) + + it('declares PNG transmit-and-display without moving the cursor', () => { + const seq = placeFigure(png, 1, 1, 3) + expect(seq).toContain('f=100') + expect(seq).toContain('a=T') + expect(seq).toContain('C=1') + expect(seq).toContain('i=3') + expect(seq).toContain(png.toString('base64')) + }) + + it('chunks a large payload and chains the continuations', () => { + const big = Buffer.alloc(9000, 7) + const seq = placeFigure(big, 1, 1, 1) + const chunks = seq.match(/\x1b_G[^\x1b]*\x1b\\/g)! + expect(chunks.length).toBeGreaterThan(1) + expect(chunks[0]).toContain('m=1') + expect(chunks.at(-1)).toContain('m=0') + // Only the first chunk carries the control keys. + expect(chunks.at(-1)).not.toContain('a=T') + // The payload survives reassembly. + const joined = chunks.map((c) => c.replace(/^\x1b_G[^;]*;/, '').replace(/\x1b\\$/, '')).join('') + expect(joined).toBe(big.toString('base64')) + }) + + it('deleteFigures clears every placement quietly', () => { + expect(deleteFigures()).toBe('\x1b_Ga=d,d=A,q=2\x1b\\') + }) +}) + +describe('encodeFigurePng (the sprite as a real raster)', () => { + it('emits a valid PNG at sprite-size times scale', () => { + const png = encodeFigurePng(['xw', '.s'], 3) + expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + // IHDR width/height at offsets 16/20. + expect(png.readUInt32BE(16)).toBe(6) + expect(png.readUInt32BE(20)).toBe(6) + }) +}) + +describe('encodeFigurePng fade (the dozer dims toward the room)', () => { + it('changes the emitted pixels without changing the geometry', () => { + const awake = encodeFigurePng(['xw', 'ws'], 2) + const dozing = encodeFigurePng(['xw', 'ws'], 2, 0.5) + expect(dozing.readUInt32BE(16)).toBe(awake.readUInt32BE(16)) + expect(dozing.equals(awake)).toBe(false) + }) +}) + +describe('figureScale (device pixels per sprite pixel)', () => { + it('spans roughly the target cells at a known cell width', () => { + // 16 cells * 20px / 42 sprite cols ~= 8 + expect(figureScale(20, 42)).toBe(8) + }) + + it('falls back when the terminal will not say', () => { + expect(figureScale(0, 42)).toBe(3) + }) +}) diff --git a/test/tui-pet.test.ts b/test/tui-pet.test.ts index 303b3af..16a8140 100644 --- a/test/tui-pet.test.ts +++ b/test/tui-pet.test.ts @@ -11,18 +11,14 @@ import { describe, expect, it } from 'vitest' import { awayGreeting, bandLayout, - cells, loadPoses, parseFrames, - petPalette, POSE_FPS, poseFor, type PoseName, } from '../tui/src/pet.ts' -import { accentFor } from '../tui/src/palette.ts' const POSES = loadPoses() -const PALETTE = petPalette(accentFor('evening'), 0) describe('the committed sprite assets', () => { it('ships a frame set for every pose the state feed can ask for', () => { @@ -44,16 +40,23 @@ describe('the committed sprite assets', () => { const frames = Object.values(POSES).flat() const sizes = new Set(frames.map((frame) => `${frame.length}x${frame[0]!.length}`)) expect(sizes.size).toBe(1) - // Half-block cells pair two pixel rows, so an odd row count would drop one. + // Half-block cells pair two pixel rows, so an odd count would drop one. expect(frames[0]!.length % 2).toBe(0) }) - it('uses only palette keys the renderer knows how to color', () => { - const known = new Set([...Object.keys(PALETTE), '.']) + it('uses only pixel keys the renderers know: figure, outline, sparkle, empty', () => { for (const frame of Object.values(POSES).flat()) { - for (const glyph of frame.join('')) expect(known, `unknown key ${glyph}`).toContain(glyph) + for (const glyph of frame.join('')) { + expect(['x', 'w', 's', '.'], `unknown key ${glyph}`).toContain(glyph) + } } }) + + it('keeps the figure two-tone: cream fill AND the warm outline', () => { + const idle = POSES.idle[0]!.join('') + expect(idle).toContain('x') + expect(idle).toContain('w') + }) }) describe('parseFrames', () => { @@ -76,39 +79,6 @@ describe('parseFrames', () => { }) }) -describe('cells (the krabby half-block technique)', () => { - it('folds two pixel rows into one cell row: upper is ink, lower is ground', () => { - const grid = cells(['bo', 'oe'], PALETTE) - expect(grid).toHaveLength(1) - expect(grid[0]).toEqual([ - { fg: PALETTE.b, bg: PALETTE.o }, - { fg: PALETTE.o, bg: PALETTE.e }, - ]) - }) - - it('renders a transparent pixel as the room behind it', () => { - const grid = cells(['..', '..'], PALETTE) - expect(grid[0]![0]!.fg).toBe(grid[0]![0]!.bg) - }) - - it('gives a real sprite half as many cell rows as it has pixel rows', () => { - const frame = POSES.idle[0]! - const grid = cells(frame, PALETTE) - expect(grid).toHaveLength(frame.length / 2) - expect(grid[0]).toHaveLength(frame[0]!.length) - }) - - it('dims the whole palette for a sleeping pet without touching the assets', () => { - const awake = petPalette(accentFor('evening'), 0) - const asleep = petPalette(accentFor('evening'), 0.6) - expect(asleep.b).not.toBe(awake.b) - }) - - it('takes its body color from the accent, so the pet tints with the hour', () => { - expect(petPalette(accentFor('morning'), 0).b).not.toBe(petPalette(accentFor('late-night'), 0).b) - }) -}) - describe('poseFor (spec 10 §3.2-D: the display-state inventory)', () => { it('dozes when the room is empty — the pet explains the quiet', () => { expect(poseFor({ kind: 'gap', activity: 'away' })).toBe('doze') diff --git a/tsconfig.json b/tsconfig.json index edb03e2..f70161c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,11 @@ "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", ".github/scripts/**/*.ts"] + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "scripts/**/*.ts", + ".github/scripts/**/*.ts", + "tui/src/**/*.d.ts" + ] } diff --git a/tui/assets/pet/doze.pix b/tui/assets/pet/doze.pix index 7986501..8f40be8 100644 --- a/tui/assets/pet/doze.pix +++ b/tui/assets/pet/doze.pix @@ -1,20 +1,44 @@ -.................. -.......bbbbbb..... -......bbbbbbbb.... -.....bbbbbbbbbb... -....bbbbbbbbbbb... -....bbbbbbbbbbbb.. -...bmmmbbbbbbbbb.. -...mmmmbbbbbbbbb.. -..mmmmmbbbbbbbbb.. -..meemmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -..memmmmbbbbbbbb.. -..mmemmemmbbbbbb.. -..mmemmemmbbbbb... -..mmmmmmmmbbbb.... -..mmmmmmmmm....... -..mmmmmmmm........ -...mmmmmm......... -.................. +.....................ww................... +..................wwxxwwwwwwwww........... +.................wxwxxxxxxxxxxwww......... +...............wwxxwwwwwwwwwwwwxxw........ +.............wwwxwww..........wwwwww...... +.............wxww...............wxxw...... +...........wwwxw.................wwxww.... +...........wxww...................wxxw.... +...........wxw.ww..................wxw.... +...........wxw.ww..................wxw.... +...........wxwwwxw..................wwww.. +............wwwxxww..................wxw.. +.............wxxxxxw.................wxw.. +.............wxxxxxwww...............wxw.. +.............wxxxxxxxw...............wxw.. +.............wxxxxxxxxwww..www.......wxw.. +............wxxwwwwwxxxxw.wxxw.......wxw.. +...........wwxxwwwwxxxxxxwxxxxw......wxw.. +..........wwxxxxxxxxxxxxxxxxxxw......wxw.. +...........xxxxxxxxxxxxxxxxxxxw.....wwxw.. +...........wwxxxxxxxxxxxxxxxxxw.....xww... +............wwxxxxxxxxxxxxxww......wxw.... +.......www..wwwxxxxxxxxxxxxw.......wxw.... +.....wwwwxw...wwxxxxxxxxxxxww.......xww... +.....wwwwww..wwwxxxxxxxxxxxxxw......wwxw.. +...wwwwwwwww.wxxxxxxxxxxxxxxxw.......wwwww +...wxwwwwwxw.wxxxxxxxxxxxxxxxxww.....wwxxw +...wxwwwwwxw.wxxxxxxxxxxxxxxxxxw.wwwwwxwww +...wxwwxwwwwwwwwwwwwwxxxxxxxxxxxwxxxxwww.. +...wxwwxxxwwxw.......wxxxxxxxxxxxxwwww.... +....wxxxxxwwxw.......wwxxxxxxxxxxxwww..... +....wxxxxxwwxw.........wxxxxxxxxxxxw...... +....wxxxxxxxxw.........wxxxxxxxxxxxxw..... +....wxxxxxxxxw.........wxxxxxxxxwwwwxw.... +....wwxxxxxxxw........wwxxxxxxxw...wxww... +......xxxxxxww.......wwxwwwwwww.....wwww.. +......xxxxxw........wxxww............wwww. +......wwxxxw.......wwxww..............wxw. +.......wxxxw.......xxxw...............wxw. +.......wxxxw......wwxw................wxw. +.......wxxxww.....wxxw................wxw. +.......wxxxww.....xxw.................wxw. +.......wxxxww.....xx...................w.. +.......wwwww......ww...................... diff --git a/tui/assets/pet/idle.pix b/tui/assets/pet/idle.pix index b6c5844..6381676 100644 --- a/tui/assets/pet/idle.pix +++ b/tui/assets/pet/idle.pix @@ -1,41 +1,89 @@ -.................. -.......bbbbbb..... -......bbbbbbbb.... -.....bbbbbbbbbb... -....bbbbbbbbbbb... -....bbbbbbbbbbbb.. -...bmmmbbbbbbbbb.. -...mmmmbbbbbbbbb.. -..mmmmmbbbbbbbbb.. -..meemmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -..memmmmbbbbbbbb.. -..mmemmemmbbbbbb.. -..mmemmemmbbbbb... -..mmmmmmmmbbbb.... -..mmmmmmmmm....... -..mmmmmmmm........ -...mmmmmm......... -.................. +.....................ww................... +..................wwxxwwwwwwwww........... +.................wxwxxxxxxxxxxwww......... +...............wwxxwwwwwwwwwwwwxxw........ +.............wwwxwww..........wwwwww...... +.............wxww...............wxxw...... +...........wwwxw.................wwxww.... +...........wxww...................wxxw.... +...........wxw.ww..................wxw.... +...........wxw.ww..................wxw.... +...........wxwwwxw..................wwww.. +............wwwxxww..................wxw.. +.............wxxxxxw.................wxw.. +.............wxxxxxwww...............wxw.. +.............wxxxxxxxw...............wxw.. +.............wxxxxxxxxwww..www.......wxw.. +............wxxwwwwwxxxxw.wxxw.......wxw.. +...........wwxxwwwwxxxxxxwxxxxw......wxw.. +..........wwxxxxxxxxxxxxxxxxxxw......wxw.. +...........xxxxxxxxxxxxxxxxxxxw.....wwxw.. +...........wwxxxxxxxxxxxxxxxxxw.....xww... +............wwxxxxxxxxxxxxxww......wxw.... +.......www..wwwxxxxxxxxxxxxw.......wxw.... +.....wwwwxw...wwxxxxxxxxxxxww.......xww... +.....wwwwww..wwwxxxxxxxxxxxxxw......wwxw.. +...wwwwwwwww.wxxxxxxxxxxxxxxxw.......wwwww +...wxwwwwwxw.wxxxxxxxxxxxxxxxxww.....wwxxw +...wxwwwwwxw.wxxxxxxxxxxxxxxxxxw.wwwwwxwww +...wxwwxwwwwwwwwwwwwwxxxxxxxxxxxwxxxxwww.. +...wxwwxxxwwxw.......wxxxxxxxxxxxxwwww.... +....wxxxxxwwxw.......wwxxxxxxxxxxxwww..... +....wxxxxxwwxw.........wxxxxxxxxxxxw...... +....wxxxxxxxxw.........wxxxxxxxxxxxxw..... +....wxxxxxxxxw.........wxxxxxxxxwwwwxw.... +....wwxxxxxxxw........wwxxxxxxxw...wxww... +......xxxxxxww.......wwxwwwwwww.....wwww.. +......xxxxxw........wxxww............wwww. +......wwxxxw.......wwxww..............wxw. +.......wxxxw.......xxxw...............wxw. +.......wxxxw......wwxw................wxw. +.......wxxxww.....wxxw................wxw. +.......wxxxww.....xxw.................wxw. +.......wxxxww.....xx...................w.. +.......wwwww......ww...................... -.................. -.......bbbbbb..... -......bbbbbbbb.... -.....bbbbbbbbbb... -....bbbbbbbbbbb... -....bbbbbbbbbbbb.. -...bmmmbbbbbbbbb.. -...mmmmbbbbbbbbb.. -..mmmmmbbbbbbbbb.. -m.meemmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -..memmmmbbbbbbbb.. -..mmemmemmbbbbbb.. -..mmemmemmbbbbb... -..mmmmmmmmbbbb.... -..mmmmmmmmm....... -..mmmmmmmm........ -...mmmmmm......... -.................. +.....................ww................... +..................wwxxwwwwwwwww........... +.................wxwxxxxxxxxxxwww......... +...............wwxxwwwwwwwwwwwwxxw........ +.............wwwxwww..........wwwwww...... +.............wxww...............wxxw...... +...........wwwxw.................wwxww.... +...........wxww...................wxxw.... +...........wxw.ww..................wxw.... +...........wxw.ww..................wxw.... +...........wxwwwxw..................wwww.. +............wwwxxww..................wxw.. +.............wxxxxxw.................wxw.. +.............wxxxxxwww...............wxw.. +.............wxxxxxxxw...............wxw.. +.............wxxxxxxxxwww..www.......wxw.. +............wxxwwwwwxxxxw.wxxw.......wxw.. +...........wwxxwwwwxxxxxxwxxxxw......wxw.. +...sss....wwxxxxxxxxxxxxxxxxxxw......wxw.. +....sss....xxxxxxxxxxxxxxxxxxxw.....wwxw.. +...........wwxxxxxxxxxxxxxxxxxw.....xww... +............wwxxxxxxxxxxxxxww......wxw.... +.......www..wwwxxxxxxxxxxxxw.......wxw.... +.....wwwwxw...wwxxxxxxxxxxxww.......xww... +.....wwwwww..wwwxxxxxxxxxxxxxw......wwxw.. +...wwwwwwwww.wxxxxxxxxxxxxxxxw.......wwwww +...wxwwwwwxw.wxxxxxxxxxxxxxxxxww.....wwxxw +...wxwwwwwxw.wxxxxxxxxxxxxxxxxxw.wwwwwxwww +...wxwwxwwwwwwwwwwwwwxxxxxxxxxxxwxxxxwww.. +...wxwwxxxwwxw.......wxxxxxxxxxxxxwwww.... +....wxxxxxwwxw.......wwxxxxxxxxxxxwww..... +....wxxxxxwwxw.........wxxxxxxxxxxxw...... +....wxxxxxxxxw.........wxxxxxxxxxxxxw..... +....wxxxxxxxxw.........wxxxxxxxxwwwwxw.... +....wwxxxxxxxw........wwxxxxxxxw...wxww... +......xxxxxxww.......wwxwwwwwww.....wwww.. +......xxxxxw........wxxww............wwww. +......wwxxxw.......wwxww..............wxw. +.......wxxxw.......xxxw...............wxw. +.......wxxxw......wwxw................wxw. +.......wxxxww.....wxxw................wxw. +.......wxxxww.....xxw.................wxw. +.......wxxxww.....xx...................w.. +.......wwwww......ww...................... diff --git a/tui/assets/pet/music.pix b/tui/assets/pet/music.pix index 5245a1c..7814257 100644 --- a/tui/assets/pet/music.pix +++ b/tui/assets/pet/music.pix @@ -1,41 +1,89 @@ -.................. -.......bbbbbb..... -......bbbbbbbb.... -..b..bbbbbbbbbb... -....bbbbbbbbbbb... -....bbbbbbbbbbbb.. -.m.bmmmbbbbbbbbb.. -...mmmmbbbbbbbbb.. -..mmmmmbbbbbbbbb.. -..meemmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -..memmmmbbbbbbbb.. -..mmemmemmbbbbbb.. -..mmemmemmbbbbb... -..mmmmmmmmbbbb.... -..mmmmmmmmm....... -..mmmmmmmm........ -...mmmmmm......... -.................. +.....................ww................... +..................wwxxwwwwwwwww........... +.................wxwxxxxxxxxxxwww......... +...............wwxxwwwwwwwwwwwwxxw........ +........s....wwwxwww..........wwwwww...... +.............wxww...............wxxw...... +......s....wwwxw.................wwxww.... +...........wxww...................wxxw.... +...........wxw.ww..................wxw.... +...........wxw.ww..................wxw.... +...........wxwwwxw..................wwww.. +............wwwxxww..................wxw.. +.............wxxxxxw.................wxw.. +.............wxxxxxwww...............wxw.. +.............wxxxxxxxw...............wxw.. +.............wxxxxxxxxwww..www.......wxw.. +............wxxwwwwwxxxxw.wxxw.......wxw.. +...........wwxxwwwwxxxxxxwxxxxw......wxw.. +..........wwxxxxxxxxxxxxxxxxxxw......wxw.. +...........xxxxxxxxxxxxxxxxxxxw.....wwxw.. +...........wwxxxxxxxxxxxxxxxxxw.....xww... +............wwxxxxxxxxxxxxxww......wxw.... +.......www..wwwxxxxxxxxxxxxw.......wxw.... +.....wwwwxw...wwxxxxxxxxxxxww.......xww... +.....wwwwww..wwwxxxxxxxxxxxxxw......wwxw.. +...wwwwwwwww.wxxxxxxxxxxxxxxxw.......wwwww +...wxwwwwwxw.wxxxxxxxxxxxxxxxxww.....wwxxw +...wxwwwwwxw.wxxxxxxxxxxxxxxxxxw.wwwwwxwww +...wxwwxwwwwwwwwwwwwwxxxxxxxxxxxwxxxxwww.. +...wxwwxxxwwxw.......wxxxxxxxxxxxxwwww.... +....wxxxxxwwxw.......wwxxxxxxxxxxxwww..... +....wxxxxxwwxw.........wxxxxxxxxxxxw...... +....wxxxxxxxxw.........wxxxxxxxxxxxxw..... +....wxxxxxxxxw.........wxxxxxxxxwwwwxw.... +....wwxxxxxxxw........wwxxxxxxxw...wxww... +......xxxxxxww.......wwxwwwwwww.....wwww.. +......xxxxxw........wxxww............wwww. +......wwxxxw.......wwxww..............wxw. +.......wxxxw.......xxxw...............wxw. +.......wxxxw......wwxw................wxw. +.......wxxxww.....wxxw................wxw. +.......wxxxww.....xxw.................wxw. +.......wxxxww.....xx...................w.. +.......wwwww......ww...................... -.................. -.......bbbbbb..... -...m..bbbbbbbb.... -.....bbbbbbbbbb... -.b..bbbbbbbbbbb... -....bbbbbbbbbbbb.. -...bmmmbbbbbbbbb.. -...mmmmbbbbbbbbb.. -..mmmmmbbbbbbbbb.. -..meemmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -..memmmmbbbbbbbb.. -..mmemmemmbbbbbb.. -..mmemmemmbbbbb... -..mmmmmmmmbbbb.... -..mmmmmmmmm....... -..mmmmmmmm........ -...mmmmmm......... -.................. +.....................ww................... +..................wwxxwwwwwwwww........... +.................wxwxxxxxxxxxxwww......... +.......s.......wwxxwwwwwwwwwwwwxxw........ +.............wwwxwww..........wwwwww...... +.............wxww...............wxxw...... +...........wwwxw.................wwxww.... +...........wxww...................wxxw.... +.....s.....wxw.ww..................wxw.... +...........wxw.ww..................wxw.... +...........wxwwwxw..................wwww.. +............wwwxxww..................wxw.. +.............wxxxxxw.................wxw.. +.............wxxxxxwww...............wxw.. +.............wxxxxxxxw...............wxw.. +.............wxxxxxxxxwww..www.......wxw.. +............wxxwwwwwxxxxw.wxxw.......wxw.. +...........wwxxwwwwxxxxxxwxxxxw......wxw.. +..........wwxxxxxxxxxxxxxxxxxxw......wxw.. +...........xxxxxxxxxxxxxxxxxxxw.....wwxw.. +...........wwxxxxxxxxxxxxxxxxxw.....xww... +............wwxxxxxxxxxxxxxww......wxw.... +.......www..wwwxxxxxxxxxxxxw.......wxw.... +.....wwwwxw...wwxxxxxxxxxxxww.......xww... +.....wwwwww..wwwxxxxxxxxxxxxxw......wwxw.. +...wwwwwwwww.wxxxxxxxxxxxxxxxw.......wwwww +...wxwwwwwxw.wxxxxxxxxxxxxxxxxww.....wwxxw +...wxwwwwwxw.wxxxxxxxxxxxxxxxxxw.wwwwwxwww +...wxwwxwwwwwwwwwwwwwxxxxxxxxxxxwxxxxwww.. +...wxwwxxxwwxw.......wxxxxxxxxxxxxwwww.... +....wxxxxxwwxw.......wwxxxxxxxxxxxwww..... +....wxxxxxwwxw.........wxxxxxxxxxxxw...... +....wxxxxxxxxw.........wxxxxxxxxxxxxw..... +....wxxxxxxxxw.........wxxxxxxxxwwwwxw.... +....wwxxxxxxxw........wwxxxxxxxw...wxww... +......xxxxxxww.......wwxwwwwwww.....wwww.. +......xxxxxw........wxxww............wwww. +......wwxxxw.......wwxww..............wxw. +.......wxxxw.......xxxw...............wxw. +.......wxxxw......wwxw................wxw. +.......wxxxww.....wxxw................wxw. +.......wxxxww.....xxw.................wxw. +.......wxxxww.....xx...................w.. +.......wwwww......ww...................... diff --git a/tui/assets/pet/talk.pix b/tui/assets/pet/talk.pix index 01ae431..2ea4b91 100644 --- a/tui/assets/pet/talk.pix +++ b/tui/assets/pet/talk.pix @@ -1,62 +1,134 @@ -.................. -.......bbbbbb..... -......bbbbbbbb.... -.....bbbbbbbbbb... -....bbbbbbbbbbb... -....bbbbbbbbbbbb.. -...bmmmbbbbbbbbb.. -...mmmmbbbbbbbbb.. -..mmmmmbbbbbbbbb.. -m.meemmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -..memmmmbbbbbbbb.. -..mmemmemmbbbbbb.. -..mmemmemmbbbbb... -..mmmmmmmmbbbb.... -..mmmmmmmmm....... -..mmmmmmmm........ -...mmmmmm......... -.................. +.....................ww................... +..................wwxxwwwwwwwww........... +.................wxwxxxxxxxxxxwww......... +...............wwxxwwwwwwwwwwwwxxw........ +.............wwwxwww..........wwwwww...... +.............wxww...............wxxw...... +...........wwwxw.................wwxww.... +...........wxww...................wxxw.... +...........wxw.ww..................wxw.... +...........wxw.ww..................wxw.... +...........wxwwwxw..................wwww.. +............wwwxxww..................wxw.. +.............wxxxxxw.................wxw.. +.............wxxxxxwww...............wxw.. +.............wxxxxxxxw...............wxw.. +.............wxxxxxxxxwww..www.......wxw.. +............wxxwwwwwxxxxw.wxxw.......wxw.. +...........wwxxwwwwxxxxxxwxxxxw......wxw.. +...sss....wwxxxxxxxxxxxxxxxxxxw......wxw.. +....sss....xxxxxxxxxxxxxxxxxxxw.....wwxw.. +...........wwxxxxxxxxxxxxxxxxxw.....xww... +............wwxxxxxxxxxxxxxww......wxw.... +.......www..wwwxxxxxxxxxxxxw.......wxw.... +.....wwwwxw...wwxxxxxxxxxxxww.......xww... +.....wwwwww..wwwxxxxxxxxxxxxxw......wwxw.. +...wwwwwwwww.wxxxxxxxxxxxxxxxw.......wwwww +...wxwwwwwxw.wxxxxxxxxxxxxxxxxww.....wwxxw +...wxwwwwwxw.wxxxxxxxxxxxxxxxxxw.wwwwwxwww +...wxwwxwwwwwwwwwwwwwxxxxxxxxxxxwxxxxwww.. +...wxwwxxxwwxw.......wxxxxxxxxxxxxwwww.... +....wxxxxxwwxw.......wwxxxxxxxxxxxwww..... +....wxxxxxwwxw.........wxxxxxxxxxxxw...... +....wxxxxxxxxw.........wxxxxxxxxxxxxw..... +....wxxxxxxxxw.........wxxxxxxxxwwwwxw.... +....wwxxxxxxxw........wwxxxxxxxw...wxww... +......xxxxxxww.......wwxwwwwwww.....wwww.. +......xxxxxw........wxxww............wwww. +......wwxxxw.......wwxww..............wxw. +.......wxxxw.......xxxw...............wxw. +.......wxxxw......wwxw................wxw. +.......wxxxww.....wxxw................wxw. +.......wxxxww.....xxw.................wxw. +.......wxxxww.....xx...................w.. +.......wwwww......ww...................... -.................. -.......bbbbbb..... -......bbbbbbbb.... -.....bbbbbbbbbb... -....bbbbbbbbbbb... -....bbbbbbbbbbbb.. -...bmmmbbbbbbbbb.. -...mmmmbbbbbbbbb.. -..mmmmmbbbbbbbbb.. -m.meemmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -m.memmmmbbbbbbbb.. -..mmemmemmbbbbbb.. -..mmemmemmbbbbb... -..mmmmmmmmbbbb.... -..mmmmmmmmm....... -..mmmmmmmm........ -...mmmmmm......... -.................. +.....................ww................... +..................wwxxwwwwwwwww........... +.................wxwxxxxxxxxxxwww......... +...............wwxxwwwwwwwwwwwwxxw........ +.............wwwxwww..........wwwwww...... +.............wxww...............wxxw...... +...........wwwxw.................wwxww.... +...........wxww...................wxxw.... +...........wxw.ww..................wxw.... +...........wxw.ww..................wxw.... +...........wxwwwxw..................wwww.. +............wwwxxww..................wxw.. +.............wxxxxxw.................wxw.. +.............wxxxxxwww...............wxw.. +.............wxxxxxxxw...............wxw.. +.............wxxxxxxxxwww..www.......wxw.. +.ssss.......wxxwwwwwxxxxw.wxxw.......wxw.. +..sss......wwxxwwwwxxxxxxwxxxxw......wxw.. +...sss....wwxxxxxxxxxxxxxxxxxxw......wxw.. +....sss....xxxxxxxxxxxxxxxxxxxw.....wwxw.. +...........wwxxxxxxxxxxxxxxxxxw.....xww... +............wwxxxxxxxxxxxxxww......wxw.... +.......www..wwwxxxxxxxxxxxxw.......wxw.... +.....wwwwxw...wwxxxxxxxxxxxww.......xww... +.....wwwwww..wwwxxxxxxxxxxxxxw......wwxw.. +...wwwwwwwww.wxxxxxxxxxxxxxxxw.......wwwww +...wxwwwwwxw.wxxxxxxxxxxxxxxxxww.....wwxxw +...wxwwwwwxw.wxxxxxxxxxxxxxxxxxw.wwwwwxwww +...wxwwxwwwwwwwwwwwwwxxxxxxxxxxxwxxxxwww.. +...wxwwxxxwwxw.......wxxxxxxxxxxxxwwww.... +....wxxxxxwwxw.......wwxxxxxxxxxxxwww..... +....wxxxxxwwxw.........wxxxxxxxxxxxw...... +....wxxxxxxxxw.........wxxxxxxxxxxxxw..... +....wxxxxxxxxw.........wxxxxxxxxwwwwxw.... +....wwxxxxxxxw........wwxxxxxxxw...wxww... +......xxxxxxww.......wwxwwwwwww.....wwww.. +......xxxxxw........wxxww............wwww. +......wwxxxw.......wwxww..............wxw. +.......wxxxw.......xxxw...............wxw. +.......wxxxw......wwxw................wxw. +.......wxxxww.....wxxw................wxw. +.......wxxxww.....xxw.................wxw. +.......wxxxww.....xx...................w.. +.......wwwww......ww...................... -.................. -.......bbbbbb..... -......bbbbbbbb.... -.....bbbbbbbbbb... -....bbbbbbbbbbb... -....bbbbbbbbbbbb.. -...bmmmbbbbbbbbb.. -.b.mmmmbbbbbbbbb.. -..mmmmmbbbbbbbbb.. -m.meemmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -m.memmmmbbbbbbbb.. -..mmemmemmbbbbbb.. -m.mmemmemmbbbbb... -..mmmmmmmmbbbb.... -..mmmmmmmmm....... -..mmmmmmmm........ -...mmmmmm......... -.................. +.....................ww................... +..................wwxxwwwwwwwww........... +.................wxwxxxxxxxxxxwww......... +...............wwxxwwwwwwwwwwwwxxw........ +.............wwwxwww..........wwwwww...... +.............wxww...............wxxw...... +...........wwwxw.................wwxww.... +...........wxww...................wxxw.... +...........wxw.ww..................wxw.... +...........wxw.ww..................wxw.... +...........wxwwwxw..................wwww.. +............wwwxxww..................wxw.. +.............wxxxxxw.................wxw.. +.............wxxxxxwww...............wxw.. +.............wxxxxxxxw...............wxw.. +.............wxxxxxxxxwww..www.......wxw.. +.ssss.......wxxwwwwwxxxxw.wxxw.......wxw.. +..sss......wwxxwwwwxxxxxxwxxxxw......wxw.. +...sss....wwxxxxxxxxxxxxxxxxxxw......wxw.. +....sss....xxxxxxxxxxxxxxxxxxxw.....wwxw.. +ssss.ss....wwxxxxxxxxxxxxxxxxxw.....xww... +sssssss.....wwxxxxxxxxxxxxxww......wxw.... +..s.ssswww..wwwxxxxxxxxxxxxw.......wxw.... +.....wwwwxw...wwxxxxxxxxxxxww.......xww... +.....wwwwww..wwwxxxxxxxxxxxxxw......wwxw.. +...wwwwwwwww.wxxxxxxxxxxxxxxxw.......wwwww +...wxwwwwwxw.wxxxxxxxxxxxxxxxxww.....wwxxw +...wxwwwwwxw.wxxxxxxxxxxxxxxxxxw.wwwwwxwww +...wxwwxwwwwwwwwwwwwwxxxxxxxxxxxwxxxxwww.. +...wxwwxxxwwxw.......wxxxxxxxxxxxxwwww.... +....wxxxxxwwxw.......wwxxxxxxxxxxxwww..... +....wxxxxxwwxw.........wxxxxxxxxxxxw...... +....wxxxxxxxxw.........wxxxxxxxxxxxxw..... +....wxxxxxxxxw.........wxxxxxxxxwwwwxw.... +....wwxxxxxxxw........wwxxxxxxxw...wxww... +......xxxxxxww.......wwxwwwwwww.....wwww.. +......xxxxxw........wxxww............wwww. +......wwxxxw.......wwxww..............wxw. +.......wxxxw.......xxxw...............wxw. +.......wxxxw......wwxw................wxw. +.......wxxxww.....wxxw................wxw. +.......wxxxww.....xxw.................wxw. +.......wxxxww.....xx...................w.. +.......wwwww......ww...................... diff --git a/tui/assets/pet/wake.pix b/tui/assets/pet/wake.pix index f24daec..6ac0b21 100644 --- a/tui/assets/pet/wake.pix +++ b/tui/assets/pet/wake.pix @@ -1,20 +1,44 @@ -.................. -.......bbbbbb..... -......bbbbbbbb.... -.....bbbbbbbbbb... -....bbbbbbbbbbb... -....bbbbbbbbbbbb.. -...bmmmbbbbbbbbb.. -.b.mmmmbbbbbbbbb.. -..mmmmmbbbbbbbbb.. -m.meemmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -.mmmmmmmbbbbbbbb.. -m.memmmmbbbbbbbb.. -..mmemmemmbbbbbb.. -m.mmemmemmbbbbb... -..mmmmmmmmbbbb.... -..mmmmmmmmm....... -..mmmmmmmm........ -...mmmmmm......... -.................. +.....................ww................... +..................wwxxwwwwwwwww........... +.................wxwxxxxxxxxxxwww......... +...............wwxxwwwwwwwwwwwwxxw........ +.............wwwxwww..........wwwwww...... +.............wxww...............wxxw...... +...........wwwxw.................wwxww.... +...........wxww...................wxxw.... +...........wxw.ww..................wxw.... +...........wxw.ww..................wxw.... +...........wxwwwxw..................wwww.. +............wwwxxww..................wxw.. +.............wxxxxxw.................wxw.. +.............wxxxxxwww...............wxw.. +.............wxxxxxxxw...............wxw.. +.............wxxxxxxxxwww..www.......wxw.. +.ssss.......wxxwwwwwxxxxw.wxxw.......wxw.. +..sss......wwxxwwwwxxxxxxwxxxxw......wxw.. +...sss....wwxxxxxxxxxxxxxxxxxxw......wxw.. +....sss....xxxxxxxxxxxxxxxxxxxw.....wwxw.. +ssss.ss....wwxxxxxxxxxxxxxxxxxw.....xww... +sssssss.....wwxxxxxxxxxxxxxww......wxw.... +..s.ssswww..wwwxxxxxxxxxxxxw.......wxw.... +.....wwwwxw...wwxxxxxxxxxxxww.......xww... +.....wwwwww..wwwxxxxxxxxxxxxxw......wwxw.. +...wwwwwwwww.wxxxxxxxxxxxxxxxw.......wwwww +...wxwwwwwxw.wxxxxxxxxxxxxxxxxww.....wwxxw +...wxwwwwwxw.wxxxxxxxxxxxxxxxxxw.wwwwwxwww +...wxwwxwwwwwwwwwwwwwxxxxxxxxxxxwxxxxwww.. +...wxwwxxxwwxw.......wxxxxxxxxxxxxwwww.... +....wxxxxxwwxw.......wwxxxxxxxxxxxwww..... +....wxxxxxwwxw.........wxxxxxxxxxxxw...... +....wxxxxxxxxw.........wxxxxxxxxxxxxw..... +....wxxxxxxxxw.........wxxxxxxxxwwwwxw.... +....wwxxxxxxxw........wwxxxxxxxw...wxww... +......xxxxxxww.......wwxwwwwwww.....wwww.. +......xxxxxw........wxxww............wwww. +......wwxxxw.......wwxww..............wxw. +.......wxxxw.......xxxw...............wxw. +.......wxxxw......wwxw................wxw. +.......wxxxww.....wxxw................wxw. +.......wxxxww.....xxw.................wxw. +.......wxxxww.....xx...................w.. +.......wwwww......ww...................... diff --git a/tui/src/app.tsx b/tui/src/app.tsx index 5e4b8a7..fe3ae0d 100644 --- a/tui/src/app.tsx +++ b/tui/src/app.tsx @@ -13,15 +13,21 @@ import type { InputRenderable } from '@opentui/core' import type { EngineMessage, ProgramState, SettingsSnapshot } from '../../src/ipc.ts' import { Bars, render } from './bars.ts' -import { Constellation, panelWidth, type Run } from './constellation.ts' -import { accentFor, INK, mix, type Accent } from './palette.ts' +import { circleOf, Constellation, panelWidth, penFor, type Run } from './constellation.ts' +import { + cellPixels, + deleteFigures, + encodeFigurePng, + figurePen, + figureScale, + placeFigure, +} from './figure-image.ts' +import { accentFor, EMBER, INK, mix, PERIWINKLE, WARM, type Accent } from './palette.ts' import { adjust, paneFacts, paneItems } from './settings-pane.ts' import { awayGreeting, bandLayout, - cells, loadPoses, - petPalette, POSE_FPS, poseFor, type PoseName, @@ -36,18 +42,20 @@ const LOG_MAX = 500 // that can change under a running client. const POSES = loadPoses() +// The sky composition's max width (§6.1): the concept frames its page; an +// ultra-wide terminal centers that frame rather than stretching it. +const MAX_COLS = 184 + // The alive band is as tall as the pet, and the bars fill it — one band, not two // stacked strips (§3.3). Its height stays the pet's whether or not the pet is // shown: the band is the bars' room, and it must not resize under a knob. +// Half-blocks fold two pixel rows per terminal row. const BAND_ROWS = POSES.idle[0]!.length / 2 // Whether the creature is part of that band is a live setting now (spec 12 // §3.7), so its layout is computed per render inside App — the env override // stays, resolved in bandLayout itself. -// Half-block: the upper pixel is the ink, the lower is the ground behind it. -const HALF = '▀' - // How far the pet's ink fades toward the room while it sleeps. const DOZE_FADE = 0.45 @@ -99,7 +107,7 @@ function Visualizer({ sink, accent }: { sink: VizSink; accent: Accent }): React. } // The pet (§3.7.1): one pose at a time, its frames looped at the pose's own rate. -function Pet({ pose, accent }: { pose: PoseName; accent: Accent }): React.ReactNode { +function Pet({ pose }: { pose: PoseName }): React.ReactNode { const [frame, setFrame] = useState(0) const frames = POSES[pose] @@ -115,20 +123,26 @@ function Pet({ pose, accent }: { pose: PoseName; accent: Accent }): React.ReactN return () => clearInterval(timer) }, [pose, frames]) - const grid = cells(frames[frame % frames.length]!, petPalette(accent, pose === 'doze' ? DOZE_FADE : 0)) - return ( - - {grid.map((row, y) => ( - - {row.map((cell, x) => ( - - {HALF} - - ))} - - ))} - - ) + const fade = pose === 'doze' ? DOZE_FADE : 0 + const cream = mix(INK.text, INK.bg, fade) + const warm = mix(WARM, INK.bg, fade) + const ember = mix(EMBER, INK.bg, fade) + const ink = (key: string | undefined): string => + key === 'x' ? cream : key === 'w' ? warm : key === 's' ? ember : INK.bg + const sprite = frames[frame % frames.length]! + const rows: React.ReactNode[] = [] + for (let top = 0; top + 1 < sprite.length; top += 2) { + rows.push( + + {[...sprite[top]!].map((_, x) => ( + + {'▀'} + + ))} + , + ) + } + return {rows} } // The wide-terminal sky (§6.1 quiet-constellation): starfield, particle mist, @@ -157,7 +171,7 @@ function SkyPanel({ const sky = useRef(null) const tick = useRef(0) const [painted, setPainted] = useState([]) - if (sky.current === null) sky.current = new Constellation(width, rows) + if (sky.current === null) sky.current = new Constellation(width, rows, 1, penFor(process.env)) useEffect(() => { sink.current = (bins) => bars.current.push(bins) @@ -169,10 +183,14 @@ function SkyPanel({ const timer = setInterval(() => { tick.current++ const at = Math.floor(tick.current / (SKY_FPS / POSE_FPS[pose])) % frames.length - const pet = showPet - ? cells(frames[at]!, petPalette(accent, pose === 'doze' ? DOZE_FADE : 0)) - : null - setPainted(sky.current!.frame(bars.current.levels(), accent, pet)) + setPainted( + sky.current!.frame( + bars.current.levels(), + accent, + showPet ? frames[at]! : null, + pose === 'doze' ? DOZE_FADE : 0, + ), + ) }, 1000 / SKY_FPS) return () => clearInterval(timer) }, [accent, pose, frames, showPet]) @@ -297,39 +315,111 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): // beside the log; narrow ones keep the classic bottom band. Same four // regions either way (§3.3) — only the composition moves. const dims = useTerminalDimensions() - const skyWidth = panelWidth(dims.width) - // In the sky composition now-playing lives under the panel as its own quiet - // line; in the band composition it stays in the strip. - const strip = [ - greeting ?? microcopy ?? 'warming up...', - skyWidth === null ? state?.nowPlaying : undefined, - ] - .filter((part) => part !== undefined && part !== '') - .join(' ♪ ') + // The composition has a max width: past it, a wide terminal gets symmetric + // margins instead of a log pinned to the left edge and a stretched sky. + const cols = Math.min(dims.width, MAX_COLS) + const gutter = Math.floor((dims.width - cols) / 2) + const skyWidth = panelWidth(cols) + // In the sky composition the strip is one centred line over a full-width + // rule (concept 04), and now-playing lives under the panel; in the band + // composition the strip stays two-sided and carries now-playing itself. + const strip = + skyWidth === null + ? [greeting ?? microcopy ?? 'warming up...', state?.nowPlaying] + .filter((part) => part !== undefined && part !== '') + .join(' ♪ ') + : [ + greeting ?? microcopy ?? 'murmur is on the air', + state?.scene, + state?.activity ?? 'here', + ] + .filter((part) => part !== undefined && part !== '') + .join(' · ') // The alive band's composition follows the live pet setting (spec 12 §3.7), // with the env override resolved inside bandLayout. const band = bandLayout(process.env, settings?.values.tuiPet) const items = paneOpen && settings !== null ? paneItems(settings) : null - // Rows left for the sky once the strip, identity, input, and now-playing - // lines take theirs. - const skyRows = Math.max(dims.height - 4, 4) + // Rows left for the sky once the strip, its rule, identity, input, and + // now-playing take theirs. + const skyRows = Math.max(dims.height - 5, 4) + // The newest broadcast line carries the bullet (concept 04); older lines + // stand back. + const latestSegment = entries.findLast((entry) => entry.kind === 'segment')?.id + + // The raster figure (§6.1): a kitty-graphics terminal draws the whisper-girl + // as a real PNG at the design's own pixel pitch — the sky stays characters, + // only the figure earns pixels. Scale comes from the terminal's actual cell + // size; placement re-runs on any relayout, after the text frame settles. + const figMode = figurePen(process.env) + useEffect(() => { + if (skyWidth === null || figMode !== 'image' || !band.pet) return + let cancelled = false + let loop: ReturnType | undefined + const settle = setTimeout(() => { + void (async () => { + const cell = await cellPixels() + if (cancelled) return + const frames = POSES[pose] + const spriteCols = frames[0]![0]!.length + const scale = figureScale(cell?.width ?? 0, spriteCols) + const fade = pose === 'doze' ? DOZE_FADE : 0 + const pngs = frames.map((frame) => encodeFigurePng(frame, scale, fade)) + const imgCols = Math.ceil((spriteCols * scale) / (cell?.width ?? 8)) + const imgRows = Math.ceil((frames[0]!.length * scale) / (cell?.height ?? 16)) + const centerRow = 2 + circleOf((skyWidth - 1) * 2, skyRows * 4).cy / 4 + const panelLeft = gutter + cols - skyWidth + const col = Math.max(1, Math.round(panelLeft + (skyWidth - 1) / 2 - imgCols / 2) + 1) + const row = Math.max(1, Math.round(centerRow - imgRows / 2) + 1) + // Retransmitting under one id replaces the frame in place — the pose + // loop is a stream of tiny PNGs at the pose's own rate. + let at = 0 + const paint = (): void => + void process.stdout.write(placeFigure(pngs[at++ % pngs.length]!, row, col, 1)) + paint() + if (pngs.length > 1) loop = setInterval(paint, 1000 / POSE_FPS[pose]) + })() + }, 600) + return () => { + cancelled = true + clearTimeout(settle) + clearInterval(loop) + process.stdout.write(deleteFigures()) + } + }, [skyWidth, cols, gutter, skyRows, figMode, band.pet, pose]) return ( - - - {strip} - - {[identity.persona, state?.scene, state?.activity].filter(Boolean).join(' · ')} - - + + {skyWidth === null ? ( + + {strip} + + {[identity.persona, state?.scene, state?.activity].filter(Boolean).join(' · ')} + + + ) : ( + + + {strip} + + {'─'.repeat(cols)} + + )} {items !== null && settings !== null ? ( @@ -372,7 +462,9 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): > {entries.map((entry) => ( // The sky composition lets the log breathe — one blank line between - // entries, the poem spacing of §6.1. The band composition stays dense. + // entries, the poem spacing of §6.1, no icon markers (the speaker + // lives in the color), the newest broadcast line carrying a bullet. + // The band composition stays dense with its marker column. - {MARKER[entry.kind]} + {skyWidth === null + ? MARKER[entry.kind] + : entry.id === latestSegment + ? '● ' + : entry.kind === 'info' + ? '· ' + : ''} {entry.text} @@ -399,22 +497,42 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): sink={vizSink} accent={accent} pose={pose} - showPet={band.pet} + showPet={band.pet && figMode === 'sprite'} width={skyWidth - 1} rows={skyRows} /> - - {state?.nowPlaying !== undefined && state.nowPlaying !== '' - ? `♪ ${state.nowPlaying}` - : ''} - + {/* Centred, in the concept's colors: violet note, ember artist, + cool title. A title without a dash stays one cool phrase. */} + + {state?.nowPlaying !== undefined && state.nowPlaying !== '' ? ( + (() => { + const split = state.nowPlaying.split(/\s+[—–-]+\s+/, 2) + return ( + + {'♪ '} + {split.length === 2 ? ( + <> + {split[0]} + {' —— '} + {split[1]} + + ) : ( + {state.nowPlaying} + )} + + ) + })() + ) : ( + + )} + )} {skyWidth === null && ( - {band.pet && } + {band.pet && } @@ -434,15 +552,20 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): backgroundColor: INK.bg, }} > - {'> '} + {/* The listener's channel is periwinkle — the room's one cold accent + (§6.1): prompt, typed text, and the resting invitation alike. */} + {'> '} + {skyWidth !== null && ( + + {'─'.repeat(cols)} + + )} ) diff --git a/tui/src/bun-ffi.d.ts b/tui/src/bun-ffi.d.ts new file mode 100644 index 0000000..4e05d3d --- /dev/null +++ b/tui/src/bun-ffi.d.ts @@ -0,0 +1,13 @@ +// Ambient slice of bun:ffi — the module exists only under the Bun runtime, +// and tsc carries no Bun types. Only the sliver figure-image.ts touches is +// declared; the import stays behind a runtime guard. +declare module 'bun:ffi' { + export const FFIType: { readonly i32: unknown; readonly u64: unknown; readonly ptr: unknown } + export function ptr(view: ArrayBufferView): number + export function dlopen< + Defs extends Record, + >( + path: string, + definitions: Defs, + ): { symbols: { [Name in keyof Defs]: (...args: (number | bigint)[]) => number } } +} diff --git a/tui/src/constellation.ts b/tui/src/constellation.ts index 37136a7..9efe83e 100644 --- a/tui/src/constellation.ts +++ b/tui/src/constellation.ts @@ -1,68 +1,147 @@ -// The quiet-constellation panel (spec 10 §6.1): a seeded starfield, the FFT -// bins re-rendered as a braille particle mist rising from the panel floor, and -// the pet floating in the middle of it. This is the wide-terminal composition -// of the alive band; bars.ts remains the narrow one. +// The quiet-constellation panel (spec 10 §6.1): one big implied circle — a +// starfield with a faint ring bias, the FFT bins as columns of small square +// blocks whose bases ride the circle's lower arc, and the whisper figure +// floating at the circle's center. // -// No OpenTUI and no React in here, same reasoning as bars.ts: the painted frame -// cannot be unit-asserted (§3.9), the arithmetic can. Everything random is -// seeded or hashed — the sky must survive a re-render without reshuffling, and -// a test must be able to hold two instances to the same frame. +// Everything is drawn on ONE canvas of square sub-pixels — two per character +// width, four per character height — and folded to glyphs by the chosen pen: +// octant mosaics (Unicode 16) where the terminal can draw them, half-blocks +// (2x coarser, universal) where it cannot. The canvas, the geometry, and the +// colors never change; only the fold does. +// +// No OpenTUI and no React in here, same reasoning as bars.ts: the painted +// frame cannot be unit-asserted (§3.9), the arithmetic can. Everything random +// is seeded or hashed — the sky must survive a re-render without reshuffling. -import { INK, mix, type Accent } from './palette.ts' -import type { Cell } from './pet.ts' +import { EMBER, INK, mix, QUIET, WARM, type Accent } from './palette.ts' +import type { Sprite } from './pet.ts' export type Run = { text: string; fg: string; bg?: string | undefined } +export type Pen = 'octant' | 'half' + +// Mask-indexed glyphs for every 2x4 sub-pixel pattern, bit r*2+c (top-left +// first). 230 Unicode-16 octants plus the 26 legacy exceptions, generated +// from wezterm's verified table and name-checked against unicodedata 16. +export const OCTANTS: readonly string[] = Array.from( + ' 𜺨𜺫🮂𜴀▘𜴁𜴂𜴃𜴄▝𜴅𜴆𜴇𜴈▀𜴉𜴊𜴋𜴌🯦𜴍𜴎𜴏𜴐𜴑𜴒𜴓𜴔𜴕𜴖𜴗𜴘𜴙𜴚𜴛𜴜𜴝𜴞𜴟🯧𜴠𜴡𜴢𜴣𜴤𜴥𜴦𜴧𜴨𜴩𜴪𜴫𜴬𜴭𜴮𜴯𜴰𜴱𜴲𜴳𜴴𜴵🮅𜺣𜴶𜴷𜴸𜴹𜴺𜴻𜴼𜴽𜴾𜴿𜵀𜵁𜵂𜵃𜵄▖𜵅𜵆𜵇𜵈▌𜵉𜵊𜵋𜵌▞𜵍𜵎𜵏𜵐▛𜵑𜵒𜵓𜵔𜵕𜵖𜵗𜵘𜵙𜵚𜵛𜵜𜵝𜵞𜵟𜵠𜵡𜵢𜵣𜵤𜵥𜵦𜵧𜵨𜵩𜵪𜵫𜵬𜵭𜵮𜵯𜵰𜺠𜵱𜵲𜵳𜵴𜵵𜵶𜵷𜵸𜵹𜵺𜵻𜵼𜵽𜵾𜵿𜶀𜶁𜶂𜶃𜶄𜶅𜶆𜶇𜶈𜶉𜶊𜶋𜶌𜶍𜶎𜶏▗𜶐𜶑𜶒𜶓▚𜶔𜶕𜶖𜶗▐𜶘𜶙𜶚𜶛▜𜶜𜶝𜶞𜶟𜶠𜶡𜶢𜶣𜶤𜶥𜶦𜶧𜶨𜶩𜶪𜶫▂𜶬𜶭𜶮𜶯𜶰𜶱𜶲𜶳𜶴𜶵𜶶𜶷𜶸𜶹𜶺𜶻𜶼𜶽𜶾𜶿𜷀𜷁𜷂𜷃𜷄𜷅𜷆𜷇𜷈𜷉𜷊𜷋𜷌𜷍𜷎𜷏𜷐𜷑𜷒𜷓𜷔𜷕𜷖𜷗𜷘𜷙𜷚▄𜷛𜷜𜷝𜷞▙𜷟𜷠𜷡𜷢▟𜷣▆𜷤𜷥█', +) + +// Which pen this terminal can hold. Ghostty/Kitty/WezTerm synthesize octant +// mosaics regardless of font; everyone else gets the universal half-block. +// MURMUR_TUI_PIXEL=octant|half is the explicit override, either way. +export function penFor(env: NodeJS.ProcessEnv): Pen { + const forced = (env.MURMUR_TUI_PIXEL ?? '').trim().toLowerCase() + if (forced === 'octant' || forced === 'half') return forced + const program = (env.TERM_PROGRAM ?? '').toLowerCase() + if (['ghostty', 'wezterm'].includes(program)) return 'octant' + if ((env.TERM ?? '').includes('kitty')) return 'octant' + return 'half' +} + // Below this many columns the two-column composition has no room to breathe and // the client keeps the classic bottom band (§6.1's single breakpoint). export const WIDE_MIN = 96 -// The panel's share of a wide terminal, clamped to where the mist still reads: -// narrower has no sky, wider turns the log into a sidebar. +// The panel's share of a wide terminal, clamped to where the ring still reads. export function panelWidth(cols: number): number | null { if (cols < WIDE_MIN) return null - return Math.min(Math.max(Math.round(cols * 0.42), 32), 64) + return Math.min(Math.max(Math.round(cols * 0.45), 32), 110) } -// Braille dot bits by [row-in-cell][column-in-cell], top row first. -const DOT_BITS = [ - [0x01, 0x08], - [0x02, 0x10], - [0x04, 0x20], - [0x40, 0x80], -] as const - -const BRAILLE_BASE = 0x28_00 - -// The mist floor is an arc, not a flat line (§6.1): high at the panel's edges, -// dipping at the center, the pet floating in the hollow. Fractions of the -// panel's sub-pixel height, measured from the top. -const ARC_EDGE = 0.5 -const ARC_DIP = 0.86 +// Where the circle sits and how far it reaches. By-eye knobs, all of them. +const CENTER_Y = 0.44 +const RADIUS = 0.92 -// How much of the panel the mist may climb at full level, in sub-pixel rows. -const MIST_CEILING = 0.45 +// The wave: climb toward the center at full level, the dash rhythm of its +// blocks (one on, one off), and the fraying tip. +const CLIMB = 0.8 +const TIP_FRAY = 0.35 -// Peak dot density — the mist is scattered squares with dark between them, -// never a filled bar; density thins toward a column's top edge, and every dot -// is jittered a couple of sub-rows so cells rarely clump into heavy braille -// blocks — isolated dots are what read as fine grain. -const MIST_DENSITY = 0.62 -const JITTER_SUBROWS = 3 +// Heat thresholds: ember peaks, cream mids, cool quiet. +const HOT_LEVEL = 0.6 +const WARM_LEVEL = 0.3 -// The sparse trail under the floor. -const FALLOUT_ROWS = 6 -const FALLOUT_DENSITY = 0.18 +// Ghost echoes drifting past a column's tip. +const GHOST_ROWS = 4 +const GHOST_DENSITY = 0.25 -// Stars per cell, roughly — sparse enough to stay a sky, not a texture. -const STAR_DENSITY = 0.03 +// The starfield (concept 04): concentric ripple rings expanding from the +// circle's center — quasi-regular arc spacing with jitter and gaps — plus a +// thin free scatter, so the sky reads ordered AND loose at once. +export const STAR_RINGS: readonly number[] = [0.5, 0.72, 0.94, 1.16, 1.38] +const RING_ARC_STEP = 8 +const RING_SURVIVAL = 0.68 +const RING_JITTER = 1.6 +const SCATTER_DENSITY = 0.0012 +const BIG_SHARE = 0.12 -// The mist shimmers slower than the 24fps feed, or it reads as static noise. +// The wave shimmers slower than the 24fps feed, or it reads as static noise. const SHIMMER_DIVISOR = 4 -type Star = { x: number; y: number; glyph: string; phase: number } +// 'faint' is the ring ink: the ripples sit BEHIND the free scatter, close to +// the room's dark, or they read as noise instead of order. +export type Star = { + x: number + y: number + ink: 'faint' | 'cool' | 'cream' | 'ember' + big: boolean + phase: number +} + +// The one implied circle everything hangs off — the wave's arc, the figure's +// seat, and the star rings all share it. +export function circleOf( + subCols: number, + subRows: number, +): { cx: number; cy: number; radius: number } { + const cx = subCols / 2 + const cy = subRows * CENTER_Y + return { cx, cy, radius: Math.min(cx, subRows - cy) * RADIUS } +} -const STAR_GLYPHS = ['·', '·', '·', '+', '✦'] as const +export function seedStars(subCols: number, subRows: number, seed: number): Star[] { + const rand = prng(seed) + const { cx, cy, radius } = circleOf(subCols, subRows) + const stars: Star[] = [] + for (const frac of STAR_RINGS) { + const r = radius * frac + const count = Math.max(Math.round((2 * Math.PI * r) / RING_ARC_STEP), 6) + for (let i = 0; i < count; i++) { + // Every slot draws its dice even when skipped, so one gap never + // reshuffles the rest of the ring. + const keep = rand() < RING_SURVIVAL + const angle = ((i + (rand() - 0.5) * 0.9) / count) * Math.PI * 2 + const rr = r + (rand() - 0.5) * 2 * RING_JITTER + const inkRoll = rand() + const phase = rand() + if (!keep) continue + const x = Math.round(cx + Math.cos(angle) * rr) + const y = Math.round(cy + Math.sin(angle) * rr) + if (x < 0 || x >= subCols || y < 0 || y >= subRows) continue + // Ring stars are always single sub-pixels, near-dark: the ripple is a + // texture, not a set of landmarks. + stars.push({ + x, + y, + ink: inkRoll < 0.86 ? 'faint' : inkRoll < 0.96 ? 'cool' : 'cream', + big: false, + phase, + }) + } + } + const scatter = Math.round(subCols * subRows * SCATTER_DENSITY) + for (let i = 0; i < scatter; i++) { + const x = Math.floor(rand() * subCols) + const y = Math.floor(rand() * subRows) + const roll = rand() + const bigRoll = rand() + const ink = roll < 0.55 ? 'cool' : roll < 0.85 ? 'cream' : 'ember' + // Only the warm accents earn 2x2: big grey blocks read as noise. + stars.push({ x, y, ink, big: ink !== 'cool' && bigRoll < BIG_SHARE, phase: rand() }) + } + return stars +} function clamp01(value: number): number { return Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0 @@ -80,7 +159,7 @@ function prng(seed: number): () => number { } } -// Deterministic per-dot jitter: same dot, same tick bucket, same verdict. +// Deterministic per-block jitter: same block, same tick bucket, same verdict. // Exported for its regression test: the final XOR must be forced back to // unsigned, or half of all hashes come out negative and pass any threshold. export function hash01(x: number, y: number, tick: number): number { @@ -92,117 +171,206 @@ export function hash01(x: number, y: number, tick: number): number { export class Constellation { private readonly stars: Star[] private readonly width: number - private readonly height: number + private readonly subCols: number + private readonly subRows: number + private readonly pen: Pen private tick = 0 - constructor(width: number, height: number, seed = 1) { + constructor(width: number, height: number, seed = 1, pen: Pen = 'octant') { this.width = width - this.height = height - const rand = prng(seed) - const stars: Star[] = [] - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - if (rand() < STAR_DENSITY) { - stars.push({ - x, - y, - glyph: STAR_GLYPHS[Math.floor(rand() * STAR_GLYPHS.length)]!, - phase: rand(), - }) - } - } - } - this.stars = stars + this.subCols = width * 2 + this.subRows = height * 4 + this.pen = pen + this.stars = seedStars(this.subCols, this.subRows, seed) } - // One painted frame: levels are the engine's 0..1 bins (empty = silence), pet - // is pre-folded half-block cells (pet.ts `cells`) or null for a hidden pet. - frame(levels: readonly number[], accent: Accent, pet: Cell[][] | null): Run[][] { + // One painted frame: levels are the engine's 0..1 bins (empty = silence), + // figure is the pixel sprite (pet.ts, one sprite pixel = one sub-pixel) or + // null for a hidden pet; figureFade dims a dozing figure toward the room. + frame( + levels: readonly number[], + accent: Accent, + figure: Sprite | null, + figureFade = 0, + ): Run[][] { const tick = this.tick++ - const subCols = this.width * 2 - const subRows = this.height * 4 - const bits = Array.from({ length: this.height }, () => Array.from({ length: this.width }, () => 0)) - - // The mist: scattered surviving dots per sub-pixel column, climbing from - // the arced floor, with a thin fallout drifting below it. const bucket = Math.floor(tick / SHIMMER_DIVISOR) - const dot = (sc: number, fromTop: number): void => { - if (fromTop < 0 || fromTop >= subRows) return - bits[Math.floor(fromTop / 4)]![Math.floor(sc / 2)]! |= DOT_BITS[fromTop % 4]![sc % 2]! + const canvas: (string | null)[][] = Array.from({ length: this.subRows }, () => + Array.from({ length: this.subCols }, () => null), + ) + const paint = (x: number, y: number, ink: string): void => { + if (x >= 0 && x < this.subCols && y >= 0 && y < this.subRows) canvas[y]![x] = ink } - for (let sc = 0; sc < subCols; sc++) { - const level = - levels.length === 0 ? 0 : clamp01(levels[Math.floor((sc / subCols) * levels.length)]!) + + // Stars first — the wave and the figure may cover them. + for (const star of this.stars) { + const beat = (tick / 48 + star.phase) % 1 + const twinkle = beat < 0.5 ? beat * 2 : (1 - beat) * 2 + const ink = + star.ink === 'ember' + ? mix(QUIET, EMBER, 0.35 + twinkle * 0.65) + : star.ink === 'cream' + ? mix(QUIET, INK.text, 0.3 + twinkle * 0.7) + : star.ink === 'cool' + ? mix(INK.bg, QUIET, 0.45 + twinkle * 0.55) + : mix(INK.bg, QUIET, 0.34 + twinkle * 0.3) + paint(star.x, star.y, ink) + if (star.big) { + paint(star.x + 1, star.y, ink) + paint(star.x, star.y + 1, ink) + paint(star.x + 1, star.y + 1, ink) + } + } + + // The wave: dashed columns of square blocks riding the circle's lower arc, + // every other sub-column dark so the columns keep air between them. + const { cx, cy, radius } = circleOf(this.subCols, this.subRows) + for (let x = 0; x < this.subCols; x += 2) { + const span = (x - cx) / radius + if (Math.abs(span) > 0.98 || levels.length === 0) continue + const level = clamp01(levels[Math.floor(((span + 1) / 2) * levels.length)]!) if (level === 0) continue - // -1..1 across the panel; the floor bows from ARC_EDGE down to ARC_DIP, - // and the mist thins out before it can hit the panel's side walls. - const across = (2 * sc) / subCols - 1 - const fade = Math.min(1, (1 - Math.abs(across)) / 0.25) - const floor = Math.floor(subRows * (ARC_DIP - (ARC_DIP - ARC_EDGE) * across * across)) - const target = level * subRows * MIST_CEILING - for (let up = 0; up < target; up++) { - const density = fade * MIST_DENSITY * (1 - (0.85 * up) / target) - if (hash01(sc, up, bucket) >= density) continue - const jitter = Math.floor((hash01(sc, up + 1000, bucket) - 0.5) * JITTER_SUBROWS) - dot(sc, floor - up + jitter) + const base = cy + Math.sqrt(Math.max(radius * radius - (x - cx) * (x - cx), 0)) + const climb = level * (base - cy) * CLIMB + const heat = level > HOT_LEVEL ? EMBER : level > WARM_LEVEL ? accent.bright : QUIET + for (let up = 0; up < climb; up += 2) { + const edge = up / Math.max(climb, 1) + if (edge > 1 - TIP_FRAY && hash01(x, up, bucket) < (edge - (1 - TIP_FRAY)) / TIP_FRAY) + continue + const ink = edge > 0.72 ? mix(heat, QUIET, 0.55) : mix(heat, INK.bg, edge * 0.35) + paint(x, Math.round(base - up), ink) } - for (let down = 1; down <= FALLOUT_ROWS; down++) { - if (hash01(sc, -down, bucket) < fade * FALLOUT_DENSITY * level) dot(sc, floor + down) + for (let g = 1; g <= GHOST_ROWS; g++) { + if (hash01(x, -g, bucket) < GHOST_DENSITY * level) { + paint(x, Math.round(base - climb - g * 3), mix(INK.bg, QUIET, 0.7)) + } } } - // The pet floats above the mist floor, centred — with one clear cell of - // halo so it sits IN the sky rather than on top of it. - const petRows = pet?.length ?? 0 - const petCols = petRows > 0 ? pet![0]!.length : 0 - const fits = petRows > 0 && petCols <= this.width && petRows <= this.height - // Centred in the arc's hollow: the figure floats where the floor dips. - const petX = fits ? Math.floor((this.width - petCols) / 2) : 0 - const petY = fits ? Math.max(Math.floor(this.height * 0.4) - Math.floor(petRows / 2), 0) : 0 + // The figure at the circle's center, cream ink with its ember sparkle — + // cleared halo first, so it floats in the hollow rather than on the wave. + if (figure !== null && figure.length > 0) { + const fh = figure.length + const fw = figure[0]!.length + if (fw <= this.subCols && fh <= this.subRows) { + const fx = Math.round(cx - fw / 2) + const fy = Math.round(cy - fh / 2) + for (let y = fy - 2; y < fy + fh + 2; y++) { + for (let x = fx - 2; x < fx + fw + 2; x++) { + if (x >= 0 && x < this.subCols && y >= 0 && y < this.subRows) canvas[y]![x] = null + } + } + const cream = mix(INK.text, INK.bg, figureFade) + const warm = mix(WARM, INK.bg, figureFade) + const ember = mix(EMBER, INK.bg, figureFade) + for (let y = 0; y < fh; y++) { + for (let x = 0; x < fw; x++) { + const key = figure[y]![x] + if (key === 'x') paint(fx + x, fy + y, cream) + else if (key === 'w') paint(fx + x, fy + y, warm) + else if (key === 's') paint(fx + x, fy + y, ember) + } + } + } + } - const starAt = new Map() - for (const star of this.stars) starAt.set(star.y * this.width + star.x, star) + return this.pen === 'octant' ? this.foldOctants(canvas) : this.foldHalves(canvas) + } + // Fold 2x4 sub-pixels per cell into an octant glyph. One foreground per + // cell: the most frequent ink among its lit sub-pixels wins the cell. + private foldOctants(canvas: (string | null)[][]): Run[][] { const rows: Run[][] = [] - for (let y = 0; y < this.height; y++) { + for (let top = 0; top + 3 < this.subRows; top += 4) { const runs: Run[] = [] const put = (text: string, fg: string, bg?: string): void => { const last = runs.at(-1) if (last !== undefined && last.fg === fg && last.bg === bg) last.text += text else runs.push({ text, fg, bg }) } - for (let x = 0; x < this.width; x++) { - if (fits && y >= petY && y < petY + petRows && x >= petX && x < petX + petCols) { - const cell = pet![y - petY]![x - petX]! - put('▀', cell.fg, cell.bg) - continue + for (let cell = 0; cell < this.width; cell++) { + let mask = 0 + const votes = new Map() + for (let r = 0; r < 4; r++) { + for (let c = 0; c < 2; c++) { + const ink = canvas[top + r]![cell * 2 + c] ?? null + if (ink !== null) { + mask |= 1 << (r * 2 + c) + votes.set(ink, (votes.get(ink) ?? 0) + 1) + } + } } - const halo = - fits && - y >= petY - 1 && - y <= petY + petRows && - x >= petX - 1 && - x <= petX + petCols - const dots = halo ? 0 : bits[y]![x]! - if (dots !== 0) { - let lit = 0 - for (let bit = dots; bit > 0; bit >>= 1) lit += bit & 1 - put(String.fromCodePoint(BRAILLE_BASE + dots), mix(accent.dim, accent.bright, lit / 8)) + if (mask === 0) { + put(' ', INK.dim) continue } - const star = halo ? undefined : starAt.get(y * this.width + x) - if (star === undefined) { - put(' ', INK.dim) + // A cell owns one foreground AND one background: a near-covered cell + // holding exactly two inks keeps both — the majority ink rides the + // glyph, the other paints behind it (first-seen wins a tie, which is + // the cell's top-left ink). The bled background on up to two empty + // sub-pixels reads as a soft edge, not a hole. Anything else takes + // the majority alone. + let fg = INK.dim + let best = 0 + let lit = 0 + for (const [ink, n] of votes) { + lit += n + if (n > best) { + best = n + fg = ink + } + } + if (lit >= 6 && votes.size === 2) { + const other = [...votes.keys()].find((ink) => ink !== fg)! + let fgMask = 0 + for (let r = 0; r < 4; r++) { + for (let c = 0; c < 2; c++) { + if (canvas[top + r]![cell * 2 + c] === fg) fgMask |= 1 << (r * 2 + c) + } + } + put(OCTANTS[fgMask]!, fg, other) continue } - // A slow triangle-wave twinkle, phase-shifted per star. - const beat = (tick / 48 + star.phase) % 1 - const twinkle = beat < 0.5 ? beat * 2 : (1 - beat) * 2 - const ink = - star.glyph === '✦' - ? mix(accent.dim, accent.bright, twinkle) - : mix(INK.dim, INK.text, twinkle * 0.6) - put(star.glyph, ink) + put(OCTANTS[mask]!, fg, INK.bg) + } + rows.push(runs) + } + return rows + } + + // The universal fallback: downsample 2x2 sub-pixels per half-block pixel. + private foldHalves(canvas: (string | null)[][]): Run[][] { + const sample = (top: number, cell: number): string | null => { + const votes = new Map() + for (let r = 0; r < 2; r++) { + for (let c = 0; c < 2; c++) { + const ink = canvas[top + r]![cell * 2 + c] ?? null + if (ink !== null) votes.set(ink, (votes.get(ink) ?? 0) + 1) + } + } + let fg: string | null = null + let best = 0 + for (const [ink, n] of votes) { + if (n > best) { + best = n + fg = ink + } + } + return fg + } + const rows: Run[][] = [] + for (let top = 0; top + 3 < this.subRows; top += 4) { + const runs: Run[] = [] + const put = (text: string, fg: string, bg?: string): void => { + const last = runs.at(-1) + if (last !== undefined && last.fg === fg && last.bg === bg) last.text += text + else runs.push({ text, fg, bg }) + } + for (let cell = 0; cell < this.width; cell++) { + const upper = sample(top, cell) + const lower = sample(top + 2, cell) + if (upper === null && lower === null) put(' ', INK.dim) + else put('▀', upper ?? INK.bg, lower ?? INK.bg) } rows.push(runs) } diff --git a/tui/src/figure-image.ts b/tui/src/figure-image.ts new file mode 100644 index 0000000..65346e5 --- /dev/null +++ b/tui/src/figure-image.ts @@ -0,0 +1,143 @@ +// The figure as a real raster (spec 10 §6.1): terminals speaking the kitty +// graphics protocol draw the whisper-girl as an actual PNG at the design's +// own pixel pitch — finer than any character mosaic can go. Everyone else +// keeps the octant/half-block sprite. The sky stays characters either way; +// only the figure earns pixels. + +import { deflateSync } from 'node:zlib' + +import type { Sprite } from './pet.ts' + +export type FigurePen = 'image' | 'sprite' + +// MURMUR_TUI_FIGURE=image|sprite overrides; otherwise kitty-graphics +// terminals (Ghostty, kitty) get the raster. +export function figurePen(env: NodeJS.ProcessEnv): FigurePen { + const forced = (env.MURMUR_TUI_FIGURE ?? '').trim().toLowerCase() + if (forced === 'image' || forced === 'sprite') return forced + const program = (env.TERM_PROGRAM ?? '').toLowerCase() + if (program === 'ghostty') return 'image' + if ((env.TERM ?? '').includes('kitty')) return 'image' + return 'sprite' +} + +// The raster's inks are the design's own sampled colors — brighter than the +// log's text cream on purpose, the way the concept's figure pops off the sky. +const RGBA: Record = { + x: [0xfc, 0xf8, 0xe7, 255], + w: [0xb0, 0x78, 0x49, 255], + s: [0xea, 0xb4, 0x8c, 255], +} + +function crc32(bytes: Buffer): number { + let crc = 0xff_ff_ff_ff + for (const byte of bytes) { + crc ^= byte + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xed_b8_83_20 & -(crc & 1)) + } + return (crc ^ 0xff_ff_ff_ff) >>> 0 +} + +function chunk(type: string, data: Buffer): Buffer { + const body = Buffer.concat([Buffer.from(type, 'latin1'), data]) + const head = Buffer.alloc(4) + head.writeUInt32BE(data.length) + const tail = Buffer.alloc(4) + tail.writeUInt32BE(crc32(body)) + return Buffer.concat([head, body, tail]) +} + +// The room's ground, for fading a dozing figure toward the dark. +const BG: [number, number, number] = [0x09, 0x0e, 0x17] + +// A .pix sprite as a real PNG at an integer scale — nearest-neighbour by +// construction, transparent ground, pixel-art crisp at any cell size. fade +// pulls every ink toward the room the way the sprite path dims a dozer. +export function encodeFigurePng(sprite: Sprite, scale: number, fade = 0): Buffer { + const rows = sprite.length + const cols = sprite[0]?.length ?? 0 + const width = cols * scale + const height = rows * scale + const inks = new Map() + for (const [key, [r, g, b, a]] of Object.entries(RGBA)) { + inks.set(key, [ + Math.round(r + (BG[0] - r) * fade), + Math.round(g + (BG[1] - g) * fade), + Math.round(b + (BG[2] - b) * fade), + a, + ]) + } + const raw = Buffer.alloc(height * (1 + width * 4)) + for (let y = 0; y < height; y++) { + const at = y * (1 + width * 4) + 1 + const line = sprite[Math.floor(y / scale)]! + for (let x = 0; x < width; x++) { + const ink = inks.get(line[Math.floor(x / scale)]!) + if (ink !== undefined) raw.set(ink, at + x * 4) + } + } + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(width, 0) + ihdr.writeUInt32BE(height, 4) + ihdr.set([8, 6, 0, 0, 0], 8) + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]) +} + +// How many device pixels one sprite pixel should get so the figure spans about +// `targetCells` terminal columns. cellWidth 0/unknown falls back to 3 — the +// half-block-era footprint. +export function figureScale(cellWidth: number, spriteCols: number, targetCells = 16): number { + if (cellWidth <= 0 || spriteCols <= 0) return 3 + return Math.max(1, Math.round((targetCells * cellWidth) / spriteCols)) +} + +// The protocol caps one escape's payload at 4096 base64 chars. +const CHUNK = 4096 + +// One placement: save the cursor, park it on the anchor cell (1-based), stream +// the PNG (f=100) as transmit-and-display (a=T) without disturbing the cursor +// (C=1), quietly (q=2), above the sky's text cells (z=1), then restore. The +// terminal keeps the image until it is deleted; text repaints do not touch it. +export function placeFigure(png: Buffer, row: number, col: number, id: number): string { + const b64 = png.toString('base64') + const parts: string[] = [] + for (let at = 0; at < b64.length; at += CHUNK) parts.push(b64.slice(at, at + CHUNK)) + const seq = parts + .map((part, index) => { + const head = index === 0 ? `f=100,a=T,C=1,q=2,z=1,i=${id},` : '' + const more = index === parts.length - 1 ? 'm=0' : 'm=1' + return `\x1b_G${head}${more};${part}\x1b\\` + }) + .join('') + return `\x1b7\x1b[${row};${col}H${seq}\x1b8` +} + +// Drop every image placement — resize repaints and shutdown both route here. +export function deleteFigures(): string { + return '\x1b_Ga=d,d=A,q=2\x1b\\' +} + +// Device pixels per terminal cell, from the kernel's window size (Ghostty +// fills ws_xpixel/ws_ypixel on the tty). Bun-only FFI behind a guard: any +// runtime or platform that cannot answer gets null and the caller's fallback. +export async function cellPixels(): Promise<{ width: number; height: number } | null> { + try { + const { dlopen, FFIType, ptr } = await import('bun:ffi') + const libc = dlopen(process.platform === 'darwin' ? '/usr/lib/libSystem.B.dylib' : 'libc.so.6', { + ioctl: { args: [FFIType.i32, FFIType.u64, FFIType.ptr], returns: FFIType.i32 }, + }) + const TIOCGWINSZ = process.platform === 'darwin' ? 0x40_08_74_68 : 0x54_13 + const winsize = new Uint16Array(4) + if (libc.symbols.ioctl(1, TIOCGWINSZ, ptr(winsize)) !== 0) return null + const [rows, cols, xPixels, yPixels] = winsize + if (!rows || !cols || !xPixels || !yPixels) return null + return { width: Math.floor(xPixels / cols), height: Math.floor(yPixels / rows) } + } catch { + return null + } +} diff --git a/tui/src/palette.ts b/tui/src/palette.ts index 9e86e0d..8116d05 100644 --- a/tui/src/palette.ts +++ b/tui/src/palette.ts @@ -14,16 +14,31 @@ export type Accent = { bright: string } -// The quiet-constellation ground (§6.1 art direction): a deep blue-charcoal -// night sky, constant across scenes, with warm paper ink floating on it. +// The quiet-constellation ground (§6.1 art direction), every value sampled +// from the 04 concept itself: a deep blue-black night, and — the design's +// defining temperature — WARM greys for everything quiet. The only cold ink +// in the whole room is the periwinkle the listener types in. export const INK = { - bg: '#0a0d15', - dim: '#4e5464', - text: '#d4cabb', - user: '#c9a878', - notice: '#697080', + bg: '#090e17', + dim: '#4a4642', + text: '#ddd0c0', + // The listener's ink is sage — the one green in the room. + user: '#96a47e', + notice: '#787068', } +// The three temperatures of the constellation wave (§6.1, sampled from the 04 +// concept): peach-ember peaks over cream mids over a warm quiet grey, the +// same trio the stars borrow. +export const EMBER = '#eab48c' +export const QUIET = '#5e5852' +// The figure's outline ink, sampled from the designer's own figure: the +// whisper-girl is two-tone — cream fill inside a warm brown line. +export const WARM = '#b07849' +// The listener's channel: the input line and the now-playing note glyph, +// the room's one cold accent. +export const PERIWINKLE = '#a4aede' + const DEFAULT_ACCENT: Accent = { dim: '#6e665a', bright: '#c9bda8' } // Scene keys are spec 04's (`morning` | `afternoon` | `evening` | `late-night`), @@ -33,7 +48,7 @@ const DEFAULT_ACCENT: Accent = { dim: '#6e665a', bright: '#c9bda8' } // The §6.1 discipline: each scene is ONE near-monochrome family on the shared // night ground — the hour changes the warmth of the light, never the room. const ACCENTS: Record = { - morning: { dim: '#73695a', bright: '#dcceb2' }, + morning: { dim: '#776e60', bright: '#ddd0c0' }, afternoon: { dim: '#6e6a5e', bright: '#d5ccb8' }, evening: { dim: '#7c624e', bright: '#e0b088' }, 'late-night': { dim: '#4e5878', bright: '#a8b6dd' }, diff --git a/tui/src/pet.ts b/tui/src/pet.ts index af379c9..3e1369b 100644 --- a/tui/src/pet.ts +++ b/tui/src/pet.ts @@ -1,28 +1,22 @@ -// The pixel pet's substrate (spec 10 §3.7.1), built on krabby's technique: -// sprites are committed TEXT assets, not images — no image machinery, renders in -// every terminal, and a later art pass replaces the .pix files and nothing else -// (§6.1 owns what the creature actually looks like). +// The pixel pet's substrate (spec 10 §3.7.1): sprites are committed TEXT +// assets, not images — no image machinery, renders in every terminal, and a +// later art pass replaces the .pix files and nothing else (§6.1 owns what the +// figure actually looks like). // -// An asset is a grid of PIXELS, one character per pixel, each character a key -// into a palette this module colors at render time ('.' = transparent). Two -// pixel rows fold into one terminal cell drawn as an upper-half block: the upper -// pixel is the foreground, the lower is the background. That is what makes cell -// art read as pixel art, and it costs one glyph per two pixels. -// -// Keeping the palette in code rather than baking ANSI into the assets is what -// lets the pet tint with the hour (§3.7.2) and fade when it dozes. +// An asset is a grid of square PIXELS, one character per pixel, derived from +// the murmur logo figure in the 04 concept art at its true pixel mesh. 'x' is +// cream fill, 'w' the warm outline ink, 's' the ember whisper-sparkle, '.' +// empty. Two pixel rows fold into one terminal cell drawn as half-blocks — a +// full-width half-height block is the terminal's square solid pixel. import { readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import type { ProgramState } from '../../src/ipc.ts' -import { INK, mix, type Accent } from './palette.ts' // A frame is its pixel rows, top first. All rows the same width. export type Sprite = string[] -export type Cell = { fg: string; bg: string } - export const POSE_NAMES = ['idle', 'talk', 'music', 'doze', 'wake'] as const export type PoseName = (typeof POSE_NAMES)[number] @@ -41,9 +35,9 @@ export const POSE_FPS: Record = { const ASSET_DIR = join(import.meta.dirname, '..', 'assets', 'pet') -// Split an asset into frames on blank lines, padding every row out to the widest -// one: trailing transparent pixels are trailing whitespace, and every editor and -// formatter in the chain wants to eat them. +// Split an asset into frames on blank lines, padding every row out to the +// widest one: trailing empty sub-pixels are trailing whitespace, and every +// editor and formatter in the chain wants to eat them. export function parseFrames(text: string): Sprite[] { return text .split(/\n\s*\n/) @@ -68,42 +62,6 @@ export function loadPoses(dir = ASSET_DIR): Record { return poses } -// The palette the .pix keys resolve through. The figure borrows the hour's -// accent (§3.7.2), so it warms and cools with the program; `fade` toward the -// room is how a dozing figure dims without a second set of assets. -// -// The whisper figure (§6.1) reads by solid fill, cross-stitch style: hair as -// the dim mass, skin as the bright mass, and dark seams carrying the detail -// (the closed eye, the mouth, the gaps between fingers). -export function petPalette(accent: Accent, fade: number): Record { - const dim = (color: string): string => (fade === 0 ? color : mix(color, INK.bg, fade)) - return { - o: dim('#2a2620'), // outline - b: dim(accent.dim), // hair - e: dim(mix(INK.bg, accent.dim, 0.35)), // seams: eye, mouth, finger gaps - m: dim(accent.bright), // skin - } -} - -// Fold pixel rows into cell rows. An odd trailing pixel row would be dropped, so -// the assets are held to an even count by test rather than padded silently here. -export function cells(sprite: Sprite, palette: Record): Cell[][] { - const rows: Cell[][] = [] - for (let top = 0; top + 1 < sprite.length; top += 2) { - const upper = sprite[top]! - const lower = sprite[top + 1]! - const row: Cell[] = [] - for (let x = 0; x < upper.length; x++) { - row.push({ - fg: palette[upper[x]!] ?? INK.bg, - bg: palette[lower[x]!] ?? INK.bg, - }) - } - rows.push(row) - } - return rows -} - // Which pose the program is in (§3.2-D). Precedence is the order of what the // listener needs to notice: an empty room first, then whatever is on air. export function poseFor(state: ProgramState | null): PoseName { From 4eab89d2f1068c7cba7461cf36ef890654d94c16 Mon Sep 17 00:00:00 2001 From: Zach Guo Date: Fri, 7 Aug 2026 21:42:17 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(tui):=20review=20fixes=20=E2=80=94=20ba?= =?UTF-8?q?nd-scale=20pet,=20persona=20line,=20label=20split=20[spec=2010]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three genuine codex findings: the 42x44 asset made the narrow band eat a 24-row terminal (the band now draws a 2x2-folded half-scale pet, sparkle surviving the fold); the wide composition dropped the persona (it rides the identity footer now); and the now-playing split truncated labels with a second dash (one split at the first separator, the rest kept whole). Co-Authored-By: Claude Fable 5 --- test/tui-pet.test.ts | 35 +++++++++++++++++++++++++++++++++++ tui/src/app.tsx | 28 +++++++++++++++++++++------- tui/src/pet.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/test/tui-pet.test.ts b/test/tui-pet.test.ts index 16a8140..b4bdd80 100644 --- a/test/tui-pet.test.ts +++ b/test/tui-pet.test.ts @@ -11,10 +11,12 @@ import { describe, expect, it } from 'vitest' import { awayGreeting, bandLayout, + halve, loadPoses, parseFrames, POSE_FPS, poseFor, + splitNowPlaying, type PoseName, } from '../tui/src/pet.ts' @@ -59,6 +61,39 @@ describe('the committed sprite assets', () => { }) }) +describe('halve (the narrow band cannot afford the full grid)', () => { + it('folds 2x2 sprite pixels to one, so the band fits a 24-row terminal', () => { + const half = halve(POSES.idle[0]!) + expect(half.length).toBe(POSES.idle[0]!.length / 2) + expect(half[0]!.length).toBe(Math.floor(POSES.idle[0]![0]!.length / 2)) + // Half-block folding downstream still needs an even row count. + expect(half.length % 2).toBe(0) + }) + + it('lets a lone sparkle survive the fold', () => { + expect(halve(['s...', '....', '....', '....'])).toEqual(['s.', '..']) + }) + + it('keeps only keys the renderers know', () => { + for (const glyph of halve(POSES.talk.at(-1)!).join('')) { + expect(['x', 'w', 's', '.']).toContain(glyph) + } + }) +}) + +describe('splitNowPlaying (unsanitized labels must not truncate)', () => { + it('splits at the first spaced dash and keeps the rest whole', () => { + expect(splitNowPlaying('Song — Live — Artist')).toEqual({ + head: 'Song', + rest: 'Live — Artist', + }) + }) + + it('declines a label with no separator', () => { + expect(splitNowPlaying('just a title')).toBeNull() + }) +}) + describe('parseFrames', () => { it('splits frames on a blank line', () => { expect(parseFrames('ab\ncd\n\nef\ngh\n')).toEqual([ diff --git a/tui/src/app.tsx b/tui/src/app.tsx index fe3ae0d..72a8aab 100644 --- a/tui/src/app.tsx +++ b/tui/src/app.tsx @@ -27,10 +27,14 @@ import { adjust, paneFacts, paneItems } from './settings-pane.ts' import { awayGreeting, bandLayout, + halve, loadPoses, POSE_FPS, + POSE_NAMES, poseFor, + splitNowPlaying, type PoseName, + type Sprite, } from './pet.ts' import type { Wire } from './wire.ts' @@ -46,11 +50,17 @@ const POSES = loadPoses() // ultra-wide terminal centers that frame rather than stretching it. const MAX_COLS = 184 +// The narrow band draws the pet at half scale — the full 42x44 grid would eat +// a 24-row terminal whole (codex review, 2026-08-07). +const BAND_POSES = Object.fromEntries( + POSE_NAMES.map((pose) => [pose, POSES[pose].map(halve)]), +) as Record + // The alive band is as tall as the pet, and the bars fill it — one band, not two // stacked strips (§3.3). Its height stays the pet's whether or not the pet is // shown: the band is the bars' room, and it must not resize under a knob. // Half-blocks fold two pixel rows per terminal row. -const BAND_ROWS = POSES.idle[0]!.length / 2 +const BAND_ROWS = BAND_POSES.idle[0]!.length / 2 // Whether the creature is part of that band is a live setting now (spec 12 // §3.7), so its layout is computed per render inside App — the env override @@ -109,7 +119,7 @@ function Visualizer({ sink, accent }: { sink: VizSink; accent: Accent }): React. // The pet (§3.7.1): one pose at a time, its frames looped at the pose's own rate. function Pet({ pose }: { pose: PoseName }): React.ReactNode { const [frame, setFrame] = useState(0) - const frames = POSES[pose] + const frames = BAND_POSES[pose] useEffect(() => { setFrame(0) @@ -506,15 +516,15 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): {state?.nowPlaying !== undefined && state.nowPlaying !== '' ? ( (() => { - const split = state.nowPlaying.split(/\s+[—–-]+\s+/, 2) + const split = splitNowPlaying(state.nowPlaying) return ( {'♪ '} - {split.length === 2 ? ( + {split !== null ? ( <> - {split[0]} + {split.head} {' —— '} - {split[1]} + {split.rest} ) : ( {state.nowPlaying} @@ -539,8 +549,12 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): )} + {/* Persona rides the identity line: the wide strip is the program's + words, but WHO is on air must survive in the status region (§3.3). */} - {`${identity.brain} · ${identity.voice}`} + + {[identity.persona, identity.brain, identity.voice].filter(Boolean).join(' · ')} + { return poses } +// The narrow band's pet is the same asset at half scale (the 42x44 grid would +// eat a 24-row terminal): 2x2 sprite pixels fold to one, any ink beating +// empty and the sparkle beating the body inks — sparks are one pixel and must +// survive the fold. +export function halve(sprite: Sprite): Sprite { + const rows: string[] = [] + for (let y = 0; y + 1 < sprite.length; y += 2) { + let row = '' + for (let x = 0; x + 1 < sprite[y]!.length; x += 2) { + const quad = [sprite[y]![x]!, sprite[y]![x + 1]!, sprite[y + 1]![x]!, sprite[y + 1]![x + 1]!] + row += + quad.includes('s') ? 's' + : quad.includes('x') || quad.includes('w') + ? (quad.filter((key) => key === 'x').length >= quad.filter((key) => key === 'w').length && + quad.includes('x') + ? 'x' + : 'w') + : '.' + } + rows.push(row) + } + return rows +} + +// One split at the FIRST spaced dash: the head takes the accent, the rest — +// dashes and all — stays intact. Labels carry unsanitized track metadata, so +// a second dash inside the title must never truncate it. +export function splitNowPlaying(label: string): { head: string; rest: string } | null { + const match = label.match(/\s+[—–-]+\s+/) + if (match === null || match.index === undefined) return null + return { head: label.slice(0, match.index), rest: label.slice(match.index + match[0].length) } +} + // Which pose the program is in (§3.2-D). Precedence is the order of what the // listener needs to notice: an empty room first, then whatever is on air. export function poseFor(state: ProgramState | null): PoseName {