diff --git a/CHANGELOG.md b/CHANGELOG.md index 07d9c25d..4ee0bddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/welcome/startup.ts b/src/welcome/startup.ts index f7fa6cf5..2be1c9e0 100644 --- a/src/welcome/startup.ts +++ b/src/welcome/startup.ts @@ -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 = () => {}, @@ -59,8 +61,12 @@ 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); @@ -68,6 +74,13 @@ export class NativeWelcomePresentation { 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; } } diff --git a/tests/welcomeLogoAnimation.test.ts b/tests/welcomeLogoAnimation.test.ts index f65a5b86..0c56bcb8 100644 --- a/tests/welcomeLogoAnimation.test.ts +++ b/tests/welcomeLogoAnimation.test.ts @@ -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(); +}); diff --git a/tests/welcomePlayback.test.ts b/tests/welcomePlayback.test.ts index 8955cce6..db4821c3 100644 --- a/tests/welcomePlayback.test.ts +++ b/tests/welcomePlayback.test.ts @@ -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 { @@ -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}); } }); @@ -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 { @@ -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(); } }