From d615c471d42a4aa50cab749ddee0a90000d44bbd Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:12:34 +0530 Subject: [PATCH] Welcome: a key press keeps the native still instead of character art Stopping playback (any key, scroll, resize, handover) used to repaint the welcome image as character art, even with Logo animation Off. Playback frames are still deleted at once; a still already on screen stays, and the next paint shows the still as a native image. The archived card stays portable character art as before. --- CHANGELOG.md | 1 + src/welcome/startup.ts | 21 +++++++++++++++++---- tests/welcomeLogoAnimation.test.ts | 30 ++++++++++++++++++++++++++++++ tests/welcomePlayback.test.ts | 15 +++++++++------ 4 files changed, 57 insertions(+), 10 deletions(-) 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(); } }