Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- A native welcome image (Kitty, Ghostty) stays a native image after the first key press, scroll or resize instead of turning into character art. A logo animation still stops at once and its frames are deleted; only the still remains.
- With the Portable cursor renderer, NMSh's Kitty cursor setup no longer leaves Kitty's own `cursor_trail` on, which drew two trails.

## [0.23.0] - 2026-10-21 — Vespyr Arcade
Expand Down
21 changes: 17 additions & 4 deletions src/welcome/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export class NativeWelcomePresentation {
private started: number;
private finalKey?: string;
private still = false;
/** The last paint showed playback frames (generated or decoded), not the admitted still. */
private paintedMotion = false;
private waitingForAsset: boolean;
constructor(readonly snapshot: WelcomeSnapshot, private service: GraphicsService, private caps: () => WelcomeCapabilities,
private background: () => {red: number; green: number; blue: number}, private repaint: () => void = () => {},
Expand Down Expand Up @@ -59,15 +61,26 @@ export class NativeWelcomePresentation {
}
// At most ~30 repaints a second: very short GIF delays play slightly slower rather than driving 60 fps app renders.
this.sequence.sync(!this.still && frame.active, this.repaint, frame.nextFrameMs === undefined ? undefined : Math.max(33, frame.nextFrameMs));
const painted = paintWelcome(frame, frame.active ? live : this.asset, this.service, this.owner, 'inline', this.background(), this.still);
return {...frame, active: !this.still && frame.active, rows: painted.rows.map(ansi => ({ansi, plain: stripAnsi(ansi)}))};
// A stopped animation shows the still the way the terminal draws it (a native image where supported), exactly like Off.
const moving = !this.still && frame.active;
const painted = paintWelcome(frame, moving ? live : this.asset, this.service, this.owner, 'inline', this.background());
// Still-image effects reuse the still's upload; only generated or decoded frames are separate uploads.
this.paintedMotion = moving && (live !== this.asset || (this.asset?.kind === 'image' && (this.asset.animation?.frames.length ?? 0) > 1));
return {...frame, active: moving, rows: painted.rows.map(ansi => ({ansi, plain: stripAnsi(ansi)}))};
}
rows(snapshot: WelcomeSnapshot, columns: number, reason?: string) {
if (snapshot.studio !== this.snapshot.studio) return renderWelcome(snapshot, columns);
const frame = this.frame(columns, 40, undefined, reason);
snapshot.still = this.snapshot.still?.slice();
return frame.rows;
}
cancelMotion(): void { this.still = true; this.sequence.stop(); this.service.releaseOwner(this.owner); }
dispose(): void { this.cancelMotion(); this.closed = true; this.asset = undefined; }
/**
* Stops playback for good. Uploaded playback frames are deleted now; a still already on screen stays, so a key press
* never turns the picture into character art.
*/
cancelMotion(): void {
this.still = true; this.sequence.stop();
if (this.paintedMotion) { this.service.releaseOwner(this.owner); this.paintedMotion = false; }
}
dispose(): void { this.cancelMotion(); this.service.releaseOwner(this.owner); this.closed = true; this.asset = undefined; }
}
30 changes: 30 additions & 0 deletions tests/welcomeLogoAnimation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,33 @@ test('Welcome Studio shows Logo animation and Play in the Animation section at 1
if (height >= 40) assert.match(rows.join('\n'), /Gentle loop repeats softly/);
}
});

test('a key press keeps the native still: Off stays put and a cancelled animation frees only its frames', () => {
const PLACEHOLDER = String.fromCodePoint(0x10eeee);
const deletes = (sent: string[]) => sent.filter(d => d.includes('a=d')).length;
const still = asset(logo()), bg = () => ({red: 0, green: 0, blue: 0});
const sentOff: string[] = [], offService = service(sentOff, true);
const off = new NativeWelcomePresentation(snapshot(model()), offService, () => ({color: true}), bg, () => {}, still);
const before = off.frame(80, 40, 0);
assert.ok(before.rows.some(r => r.plain.includes(PLACEHOLDER)), 'Off draws a native placement');
const uploads = offService.stats().uploads, removed = deletes(sentOff);
off.cancelMotion();
const after = off.frame(80, 40, 10);
assert.deepEqual(after.rows, before.rows, 'a key press never turns the still into character art');
assert.equal(offService.stats().uploads, uploads); assert.equal(deletes(sentOff), removed, 'the still is not deleted and re-sent');
off.dispose();
assert.equal(offService.stats().uploads, 0, 'dispose still releases everything');

const count = presentationClock.subscriberCount;
const playing = service([], true);
const native = new NativeWelcomePresentation(snapshot(model({logoAnimation: 'assemble'})), playing, () => ({color: true}), bg, () => {}, still);
assert.equal(native.frame(80, 40, 300).active, true);
native.cancelMotion();
assert.equal(playing.stats().uploads, 0, 'playback frames are deleted at once');
assert.equal(presentationClock.subscriberCount, count, 'no frame timer remains');
const settled = native.frame(80, 40, 320);
assert.equal(settled.active, false);
assert.ok(settled.rows.some(r => r.plain.includes(PLACEHOLDER)), 'the cancelled animation shows the native still');
assert.equal(playing.stats().uploads, 1, 'only the still is uploaded');
native.dispose();
});
15 changes: 9 additions & 6 deletions tests/welcomePlayback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ test('Reduced Motion, Effects Off and no color hold decoded frame zero without a
} finally { await rm(dir, {recursive: true, force: true}); }
});

test('only a portable final still is serialized; input releases uploads/timers and cannot restart them', async () => {
test('only a portable final still is serialized; input frees playback uploads/timers, cannot restart them and keeps the native still', async () => {
const dir = await mkdtemp(join(tmpdir(), 'welcome-still-'));
const count = presentationClock.subscriberCount;
try {
Expand All @@ -152,8 +152,9 @@ test('only a portable final still is serialized; input releases uploads/timers a
assert.deepEqual(output.transcript(), before, 'runtime animation never mutates transcript frames');
assert.doesNotMatch(JSON.stringify(before), /assetPath|a.gif|frameIndex|iVBOR|\\u001b_G/);
assert.ok(before.welcome?.still?.length);
native.cancelMotion(); native.frame(80, 40, 200);
assert.equal(h.graphics().stats().uploads, 0); assert.equal(presentationClock.subscriberCount, count);
native.cancelMotion(); assert.equal(h.graphics().stats().uploads, 0, 'playback frames are deleted at once');
native.frame(80, 40, 200);
assert.equal(h.graphics().stats().uploads, 1, 'the still stays a native image'); assert.equal(presentationClock.subscriberCount, count);
} finally { native.dispose(); }
} finally { await rm(dir, {recursive: true, force: true}); }
});
Expand Down Expand Up @@ -262,7 +263,7 @@ test('the shared sequencer requests decoded boundaries and removes its last subs
} finally { presentationClock.subscribe = original; await rm(dir, {recursive: true, force: true}); }
});

test('startup releases uploads and timers on input, Esc, resize, history detach, handover, frontend detach and shutdown', async () => {
test('startup frees playback uploads and timers on input, Esc, resize, history detach, handover, frontend detach and shutdown', async () => {
const dir = await mkdtemp(join(tmpdir(), 'welcome-startup-exits-'));
const previous = process.env.XDG_CONFIG_HOME; process.env.XDG_CONFIG_HOME = dir;
try {
Expand All @@ -283,10 +284,12 @@ test('startup releases uploads and timers on input, Esc, resize, history detach,
else if (exit === 'handover') app['handOverTerminal']();
else if (exit === 'detach') app['onTerminate']();
else app['stop'](0);
assert.equal(h.graphics().stats().uploads, 0); assert.equal(presentationClock.subscriberCount, count);
// Scrolling repaints at once: the still is back as one native upload; every other exit repaints later.
assert.equal(h.graphics().stats().uploads, exit === 'history' ? 1 : 0, exit); assert.equal(presentationClock.subscriberCount, count);
if (!app['stopped']) {
app['output'].wrapped(80);
assert.equal(h.graphics().stats().uploads, 0); assert.equal(presentationClock.subscriberCount, count);
// Playback cannot restart; the repaint shows the still as a native image (one upload), never character art.
assert.equal(h.graphics().stats().uploads, 1, exit); assert.equal(presentationClock.subscriberCount, count);
}
} finally { app['stop'](0); app['session'].kill(); }
}
Expand Down
Loading