Skip to content

Commit 65eba66

Browse files
Make the landing snow actually render on an idle screen (#428)
* Make the landing snow actually render on an idle screen Three independent gates kept the falling-snow decoration over the landing mountain from ever drawing: the mount-time paint was still=true, which the old snowOn check tied directly to snow visibility; paintLanding early-returned whenever it wasn't re-entered with animating=true, which never happens while idle; and its only caller was the turn monitor, which deliberately stops ticking once idle. Fix: snow visibility no longer depends on `still` (that flag now only freezes the mountain's own draw/fill/fade timeline, not the flakes over it), the early-return is gone so idle repaints actually happen, and the shell's createAppShell now repaints the landing off the renderer's own FRAME event whenever the landing is up and not mid-turn-animation. That event is already scoped to shell lifetime (wired at construction, unwired in dispose) and paintLanding already no-ops once the landing tears down, so this needed no new timer to arm or leak, and it doesn't touch the turn monitor's cadence at all. The alternative — a separate timer armed from createLandingAbove and stopped in the landing teardown — was rejected: it would duplicate the monitor's own cadence-management responsibility for no real gain, since the FRAME event already has the right lifetime. Added a real-mount-path regression test in landing.test.ts that goes through createAppShell and lets the renderer's FRAME event drive the repaint, unlike every existing test in that file, which drives the mark by calling paintLanding directly with a hand-picked clock. That gap is exactly why this shipped broken and nobody caught it. This supersedes PR #380, which added the snow-drawing code but never made it reachable; that PR should stay open until this one is reviewed and can then be closed in favor of this one. * Throttle the idle landing repaint to ~8fps FRAME fires from inside the on-demand render loop, and paintLanding reassigns a fresh StyledText to every row every call regardless of whether content changed, which unconditionally dirties renderables and requests another render. Left unthrottled that turned the idle landing into a perpetual ~60fps render loop instead of riding an existing one. Guard the onFrame-driven repaint with a stored last-paint timestamp so it only actually repaints once every ~125ms, and drop the comment that falsely claimed only changed rows get touched. Also thread an explicit reducedMotion input through the mark renderer (renderMark/markChunks/paintLandingMark/paintLanding), separate from still, so a future reduced-motion setting has a real plumbing path instead of overloading still (which only freezes the mountain's own draw/fill/fade timeline). Defaults to false everywhere; no behavior change until something sets it. Updates the mark-anim.ts and landing.ts docblocks that stated still suppressed snow, which PR #428 made no longer true. * Drive the idle landing repaint off a mount-scoped timer, not a throttled FRAME hook The FRAME-driven throttle added in the previous commit killed the landing's self-driving loop entirely: the renderer only keeps rendering because each paint dirties a row, which schedules the next FRAME; skipping a paint on a throttled tick breaks that chain on the very next frame and the snow freezes after the first paint. Any throttle above zero frames has the same effect, since the throttle and the frame source were the same mechanism. Replace it with a plain ~125ms interval armed when the shell mounts (the landing exists for the lifetime of the shell until the first transcript row tears it down) and cleared on whichever teardown happens first: the landing going away, or the shell disposing. The timer self-cancels once the renderer reports destroyed, so headless test harnesses that skip explicit shell.dispose() don't leave it firing against torn-down renderables. Also replace the test's manual renderOnce-loop clock with a real wall-clock wait and no frame pumping at all, so the test can no longer stay green while production's self-driving mechanism is dead — that blind spot is exactly how the frozen throttle shipped in the first place.
1 parent 00f81b6 commit 65eba66

5 files changed

Lines changed: 179 additions & 31 deletions

File tree

src/tui-opentui/landing.test.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
import { LOCKUP_WORDMARK } from "./lockup"
3939
import pkg from "../../package.json" with { type: "json" }
4040
import { MARK_LARGE, MARK_MID, MARK_SMALL } from "./mark-shape"
41+
import { SNOW_CHAR } from "./mark-anim"
4142
import { UI } from "./theme"
4243

4344
const SIZE = { width: 80, height: 24 } as const
@@ -229,11 +230,14 @@ describe("landing screen", () => {
229230
try {
230231
await settle(h)
231232
const still = markRows(h).join("\n")
233+
const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " ")
232234

233-
// Idle re-entry holds the filled frame however far the clock moves.
235+
// Idle re-entry holds the mountain's filled frame however far the
236+
// clock moves — but the snow drifting over it is not still, since the
237+
// idle landing screen is exactly where it needs to animate.
234238
paintLanding(shell, 1_700, false)
235239
await settle(h)
236-
expect(markRows(h).join("\n")).toBe(still)
240+
expect(stripSnow(markRows(h).join("\n"))).toBe(stripSnow(still))
237241

238242
const frames = new Set<string>()
239243
for (const nowMs of [0, 500, 1_100, 1_900, 2_600, 3_400]) {
@@ -248,6 +252,49 @@ describe("landing screen", () => {
248252
}, SIZE)
249253
})
250254

255+
test(
256+
"an idle mount keeps the snow drifting on its own, with nothing pumping frames by hand",
257+
async () => {
258+
// Regression for CL-5737: every other test in this file drives the mark
259+
// by calling `paintLanding` directly with a hand-picked clock. That is
260+
// exactly why the landing snow shipped completely unreachable — none of
261+
// those tests go through the real driver a running session actually
262+
// uses. This one mounts the shell for real and lets it repaint itself:
263+
// no `paintLanding`/`renderMark` calls, and critically no `renderOnce`
264+
// loop either while waiting — a test that pumps frames by hand can stay
265+
// green even when production's self-driving mechanism is dead, which is
266+
// exactly the blind spot that let the throttled build ship frozen snow.
267+
await withTestRenderer(async (h) => {
268+
const shell = createAppShell(h.renderer, {
269+
terminal: { columns: 80, rows: 24 },
270+
wireKeys: false,
271+
run: "idle",
272+
})
273+
try {
274+
await settle(h)
275+
const before = markRows(h).join("\n")
276+
277+
// Real wall-clock wait, no renderOnce in between: only the mount's
278+
// own idle-repaint timer can be advancing the snow here. `flush`
279+
// waits on the renderer's own scheduler settling rather than
280+
// forcing frames, so it does not manufacture the motion itself.
281+
await new Promise((resolve) => setTimeout(resolve, 3_000))
282+
283+
await h.flush()
284+
const after = markRows(h).join("\n")
285+
286+
expect(after).not.toBe(before)
287+
288+
const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " ")
289+
expect(stripSnow(after)).toBe(stripSnow(before))
290+
} finally {
291+
shell.dispose()
292+
}
293+
}, SIZE)
294+
},
295+
15_000,
296+
)
297+
251298
test("a starter key fills the prompt; a typed prompt keeps its digits", async () => {
252299
await withTestRenderer(async (h) => {
253300
const shell = createAppShell(h.renderer, {

src/tui-opentui/landing.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,9 @@ function markChunks(
271271
grid: MarkGrid,
272272
nowMs: number,
273273
still: boolean,
274+
reducedMotion = false,
274275
): readonly TextChunk[][] {
275-
return renderMark({ nowMs, still, grid }).map((row) =>
276+
return renderMark({ nowMs, still, grid, reducedMotion }).map((row) =>
276277
row.map((cell) => fgChunk(cell.fg)(cell.char)),
277278
)
278279
}
@@ -403,17 +404,20 @@ export function fitLandingMark(above: LandingAbove, grid: MarkGrid | null): void
403404
}
404405

405406
/**
406-
* Repaint the mark for the given clock. `still` holds the fully-filled frame —
407-
* the idle state, and the reduced-motion state.
407+
* Repaint the mark for the given clock. `still` holds the mountain's
408+
* draw/fill/fade timeline on its fully-filled frame — the idle state.
409+
* `reducedMotion` is the separate hook that suppresses snow; it does not
410+
* affect `still`'s mountain framing.
408411
*/
409412
export function paintLandingMark(
410413
above: LandingAbove,
411414
nowMs: number,
412415
still: boolean,
416+
reducedMotion = false,
413417
): void {
414418
const grid = above.grid
415419
if (grid === null) return
416-
const chunks = markChunks(grid, nowMs, still)
420+
const chunks = markChunks(grid, nowMs, still, reducedMotion)
417421
const offset = MARK_LARGE.rows - grid.rows
418422
above.markRows.forEach((line, index) => {
419423
const row = chunks[index - offset]

src/tui-opentui/mark-anim.test.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,19 +106,38 @@ describe("renderMark", () => {
106106
const grid = renderMark({ nowMs: 0, still: true, grid: MARK_LARGE })
107107
grid.forEach((row, y) => {
108108
row.forEach((cell, x) => {
109-
// Still mode has no snow — only space or mountain blocks.
110-
expect(` ${MOUNTAIN_CHARS}`).toContain(cell.char)
109+
// Still mode freezes the mountain, but snow still drifts over the sky.
110+
expect(` ${MOUNTAIN_CHARS}${SNOW_CHAR}`).toContain(cell.char)
111111
if ((MARK_LARGE.coverage[y]?.[x] ?? 0) === 1) expect(cell.char).toBe("█")
112112
})
113113
})
114114
})
115115

116-
test("the still frame is clock-independent and has no snow", () => {
117-
const a = markText(renderMark({ nowMs: 0, still: true }))
118-
const b = markText(renderMark({ nowMs: 987_654, still: true }))
116+
test("still holds the mountain fixed while the clock advances", () => {
117+
// Snow moves with the clock even in still mode (the idle landing screen),
118+
// so isolate the mountain by stripping snow before comparing.
119+
const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " ")
120+
const a = stripSnow(markText(renderMark({ nowMs: 0, still: true })))
121+
const b = stripSnow(markText(renderMark({ nowMs: 987_654, still: true })))
119122
expect(b).toBe(a)
120123
expect(a.replace(/[\s\n]/g, "").length).toBeGreaterThan(0)
121-
expect(a.includes(SNOW_CHAR)).toBe(false)
124+
})
125+
126+
test("snow keeps drifting in still mode while the mountain stays frozen", () => {
127+
const times = [0, 1500, 3000, 4500, 6000, 7500]
128+
const snowSets = times.map((nowMs) => {
129+
const grid = renderMark({ nowMs, still: true, grid: MARK_LARGE })
130+
const snow: string[] = []
131+
grid.forEach((row, y) => {
132+
row.forEach((cell, x) => {
133+
if (isSnow(cell.char)) snow.push(`${y},${x}`)
134+
})
135+
})
136+
return snow.join("|")
137+
})
138+
const withSnow = snowSets.filter((s) => s.length > 0)
139+
expect(withSnow.length).toBeGreaterThan(1)
140+
expect(new Set(withSnow).size).toBeGreaterThan(1)
122141
})
123142

124143
test("the animated frame advances with the injected clock", () => {
@@ -203,11 +222,12 @@ describe("renderMark", () => {
203222
expect(mountains).toBeGreaterThan(flakes)
204223
})
205224

206-
test("still mode freezes the mark with no snow motion", () => {
225+
test("still mode freezes the mountain but not the snow", () => {
207226
const a = renderMark({ nowMs: 0, still: true, grid: MARK_SMALL })
208227
const b = renderMark({ nowMs: 50_000, still: true, grid: MARK_SMALL })
209-
expect(markText(b)).toBe(markText(a))
210-
expect(a.flat().some((cell) => isSnow(cell.char))).toBe(false)
228+
const mountainText = (grid: typeof a) =>
229+
grid.map((row) => row.map((cell) => (isMountain(cell.char) ? cell.char : " ")).join("")).join("\n")
230+
expect(mountainText(b)).toBe(mountainText(a))
211231
})
212232

213233
test("snow drops out during the fade-out phase, matching the mark", () => {

src/tui-opentui/mark-anim.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99
*
1010
* Over the sky (zero-coverage cells) a sparse field of pixel snow falls on the
1111
* same injected clock. Density and speed stay low so the ridgeline keeps its
12-
* silhouette; `still` (idle or reduced motion) freezes the mark and drops the
13-
* snow entirely. Mountain cells always win over flakes.
12+
* silhouette. `still` freezes the mountain's own draw/fill/fade timeline to
13+
* its fully-filled frame but leaves snow drifting — the landing screen is
14+
* idle by definition, so tying snow to the same flag that freezes the
15+
* mountain would mean it never falls. `reducedMotion` is the separate hook
16+
* that does suppress snow. Mountain cells always win over flakes.
1417
*
1518
* Everything here is pure and clock-injected: `nowMs` is the only time source,
1619
* so the caller's existing 250 ms status tick drives the animation and tests
@@ -94,8 +97,20 @@ export type MarkCell = {
9497

9598
export type MarkInput = {
9699
readonly nowMs: number
97-
/** Hold the mark still: idle session, or reduced motion. */
100+
/**
101+
* Hold the mountain's draw/fill/fade timeline on its fully-filled frame:
102+
* idle session, or reduced motion. Snow is not gated by this — see
103+
* `snowOn` in `renderMark`.
104+
*/
98105
readonly still: boolean
106+
/**
107+
* Reduced-motion hook: suppresses snow regardless of `still`. Nothing
108+
* wires a live setting into this yet, but the parameter exists so a
109+
* future reduced-motion setting has a real path to gate motion, rather
110+
* than overloading `still` (which only ever freezes the mountain's
111+
* draw/fill/fade timeline). Defaults to off.
112+
*/
113+
readonly reducedMotion?: boolean
99114
/** Which baked rasterization to composite. Defaults to the compact grid. */
100115
readonly grid?: MarkGrid
101116
}
@@ -138,7 +153,8 @@ function snowflakeAt(
138153
* the mark is a mountain, and a mountain is opaque.
139154
*
140155
* Sky cells (zero coverage) may hold a single falling snow pixel. Flakes never
141-
* overwrite mountain coverage, and `still` suppresses them entirely.
156+
* overwrite mountain coverage; `reducedMotion` suppresses them, `still` does
157+
* not (see `snowOn` below).
142158
*
143159
* `alpha` has no terminal equivalent, so it scales the block height instead:
144160
* the mark sinks toward empty rather than blending to black.
@@ -149,9 +165,11 @@ export function renderMark(input: MarkInput): readonly (readonly MarkCell[])[] {
149165
const { drawProg, fillProg, alpha } = markFrame(seconds, input.still)
150166
const revealed = drawProg * shape.cols
151167
const fillLine = shape.rows * (1 - fillProg)
152-
// Fade out drops the snow too so the decoration doesn't outlast the mark
153-
// it drifts over.
154-
const snowOn = !input.still && alpha === 1
168+
// Independent of `still`: the mountain can be frozen full while snow still
169+
// drifts (the idle landing screen). `reducedMotion` is the actual
170+
// motion-suppression hook. Fade out drops the snow too so the decoration
171+
// doesn't outlast the mark it drifts over.
172+
const snowOn = alpha === 1 && !input.reducedMotion
155173

156174
const grid: MarkCell[][] = []
157175
for (let row = 0; row < shape.rows; row++) {

src/tui-opentui/shell.ts

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ function dispatchOverlayAccept(
471471
/** Renderer surface required by the shell (CliRenderer / createTestRenderer). */
472472
export type ShellRenderer = Pick<
473473
CliRenderer,
474-
"root" | "width" | "height" | "keyInput" | "on" | "off"
474+
"root" | "width" | "height" | "keyInput" | "on" | "off" | "isDestroyed"
475475
>
476476

477477
export type AppShellOptions = {
@@ -1930,6 +1930,13 @@ type ShellInternals = {
19301930
landingAnimating: boolean
19311931
/** Clock of the last painted mark frame, so a resize can redraw in place. */
19321932
landingNowMs: number
1933+
/**
1934+
* Cancels the mount-scoped idle repaint timer (see `armLandingIdleTimer`
1935+
* in `createAppShell`), or null while none is armed. Cleared by whichever
1936+
* teardown happens first — the landing going away (`clearLandingMark`) or
1937+
* the whole shell disposing (`dispose`) — so it can never outlive either.
1938+
*/
1939+
landingIdleTimerCancel: (() => void) | null
19331940
/** Chrome content (empty array = zone off). */
19341941
chrome: {
19351942
/**
@@ -2533,6 +2540,8 @@ function clearLandingMark(shell: AppShell): void {
25332540
const landing = bag?.landing
25342541
if (bag === undefined || landing === null || landing === undefined) return
25352542
bag.landing = null
2543+
bag.landingIdleTimerCancel?.()
2544+
bag.landingIdleTimerCancel = null
25362545
shell.transcript.remove(landing.above.box)
25372546
destroySubtree(landing.above.box)
25382547
shell.root.remove(landing.below)
@@ -2556,26 +2565,36 @@ function clearLandingMark(shell: AppShell): void {
25562565
}
25572566

25582567
/**
2559-
* Repaint the landing mark for `nowMs`. `animating` runs the draw/fill/fade
2560-
* timeline; anything else holds the filled frame. No-op once the landing is
2561-
* gone, so the caller can drive it unconditionally.
2568+
* Cadence of the mount-scoped idle repaint timer (see `armLandingIdleTimer`
2569+
* in `createAppShell`). The snow only needs to advance about half a row per
2570+
* second, so ~8fps is comfortably enough to read as motion.
2571+
*/
2572+
const LANDING_IDLE_REPAINT_INTERVAL_MS = 125
2573+
2574+
/**
2575+
* Repaint the landing mark for `nowMs`. `animating` runs the mountain's
2576+
* draw/fill/fade timeline; anything else holds its filled frame. No-op once
2577+
* the landing is gone, so the caller can drive it unconditionally.
25622578
*
2563-
* A still mark draws the same frame for every clock value, so repainting it
2564-
* only dirties renderables; the guard mirrors `setLockupFrame` and lets an idle
2565-
* session sit without touching the paint tree.
2579+
* Always repaints while the landing is up, even when `animating` is false:
2580+
* the landing is idle by definition (no turn processing), and snow still
2581+
* needs to drift across a frozen mountain. Driven by the mount-scoped timer
2582+
* armed in `createAppShell` (see `armLandingIdleTimer`) rather than a render
2583+
* event, so the repaint cadence is independent of however often the renderer
2584+
* happens to paint.
25662585
*/
25672586
export function paintLanding(
25682587
shell: AppShell,
25692588
nowMs: number,
25702589
animating: boolean,
2590+
reducedMotion = false,
25712591
): void {
25722592
const bag = internals.get(shell)
25732593
const landing = bag?.landing
25742594
if (bag === undefined || landing === null || landing === undefined) return
2575-
if (!animating && !bag.landingAnimating) return
25762595
bag.landingAnimating = animating
25772596
bag.landingNowMs = nowMs
2578-
paintLandingMark(landing.above, nowMs, !animating)
2597+
paintLandingMark(landing.above, nowMs, !animating, reducedMotion)
25792598
}
25802599

25812600
/** True while the landing composition is still mounted. */
@@ -5687,6 +5706,7 @@ export function createAppShell(
56875706
}
56885707
renderer.off(CliRenderEvents.FRAME, onFrame)
56895708
renderer.off(CliRenderEvents.RESIZE, onResize)
5709+
internals.get(shell)?.landingIdleTimerCancel?.()
56905710
flashTimers.get(shell)?.()
56915711
flashTimers.delete(shell)
56925712
try {
@@ -5726,9 +5746,48 @@ export function createAppShell(
57265746
landingSuggestionsVisible: true,
57275747
landingAnimating: false,
57285748
landingNowMs: 0,
5749+
landingIdleTimerCancel: null,
57295750
chrome: { task: [], tasksRaw: [], agents: [] },
57305751
tasksPanelHidden: false,
57315752
})
5753+
// The landing's snow needs a frame source that keeps running while the
5754+
// turn monitor is deliberately quiet (idle, no session yet). A plain timer
5755+
// armed at mount is that source: it does not depend on the renderer
5756+
// scheduling further frames, so it cannot stall the way riding the
5757+
// renderer's own FRAME event did (see CL-5737 history in the PR).
5758+
//
5759+
// Only repaints while idle (`landingAnimating` false): while a turn is
5760+
// processing, `paintPhaseAt` in runtime-bridge.ts drives the mountain's
5761+
// own draw/fill/fade loop off the turn monitor's clock, and this timer
5762+
// must not stomp that with an unrelated real-clock value.
5763+
//
5764+
// Cleared on whichever teardown happens first: the landing going away
5765+
// (`clearLandingMark`, first transcript row) or the whole shell disposing
5766+
// (`dispose` below, e.g. tests that never grow a transcript).
5767+
//
5768+
// Also self-cancels on `renderer.isDestroyed`: a real terminal session
5769+
// always disposes the shell, but headless test harnesses commonly destroy
5770+
// the renderer directly (`withTestRenderer`'s cleanup) without ever
5771+
// calling `shell.dispose()`. Without this check the timer would keep
5772+
// firing against renderables the harness already tore down.
5773+
const landingIdleHandle = setInterval(() => {
5774+
if (renderer.isDestroyed) {
5775+
clearInterval(landingIdleHandle)
5776+
return
5777+
}
5778+
const bag = internals.get(shell)
5779+
if (bag?.landing == null || bag.landingAnimating) return
5780+
paintLanding(shell, Date.now(), false)
5781+
}, LANDING_IDLE_REPAINT_INTERVAL_MS)
5782+
landingIdleHandle.unref?.()
5783+
{
5784+
const bag = internals.get(shell)
5785+
if (bag !== undefined) {
5786+
bag.landingIdleTimerCancel = () => clearInterval(landingIdleHandle)
5787+
} else {
5788+
clearInterval(landingIdleHandle)
5789+
}
5790+
}
57325791
transcriptSpacers.set(shell, transcriptSpacer)
57335792
if (onCommandOpt) setPaletteOnCommand(shell, onCommandOpt)
57345793
if (onObserveRequestOpt) {

0 commit comments

Comments
 (0)