Skip to content

Commit 0f67623

Browse files
committed
Honor reduced motion on the landing mount path
1 parent 8ed2ddc commit 0f67623

5 files changed

Lines changed: 70 additions & 51 deletions

File tree

docs/TUI.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -434,9 +434,10 @@ across a frozen mountain. It is cancelled on the first real transcript
434434
row or on shell dispose. Deferred system notices do not count. While
435435
the landing is still up, the callback no-ops if a turn is already
436436
driving the mark. `still` freezes the mountain's draw/fill/fade
437-
timeline only; snow still drifts. Reduced motion drops snow at
438-
`renderMark` independently of `still`; the idle timer always paints
439-
with that hook off.
437+
timeline only; snow still drifts. Reduced motion
438+
(`AppShellOptions.reducedMotion`, forwarded from `ProductHostConfig`)
439+
never starts that timer and paints a still mountain with no snow, even
440+
when a caller asks `paintLanding` to animate.
440441

441442
That timer is the pre-session frame source. The renderer FRAME event
442443
follows dirty rows, not a clock, and starves under throttle. The turn

src/tui/landing.test.ts

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ const nativeClearInterval = globalThis.clearInterval;
5656
* export.
5757
*/
5858
const LANDING_IDLE_REPAINT_INTERVAL_MS = 125;
59-
const SNOW_SAMPLE_CLOCKS_MS = [0, 1500, 3000, 4500, 6000, 7500] as const;
6059
const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " ");
6160

6261
interface IdleTimerHandle {
@@ -363,36 +362,31 @@ describe("landing screen", () => {
363362
globalThis.clearInterval = nativeClearInterval;
364363
});
365364

366-
test("paintLanding with reducedMotion draws no snow on a clock that otherwise snows", async () => {
367-
wrapLandingIdleTimer();
365+
test("reduced-motion mount never arms the idle timer and never draws snow", async () => {
366+
const { armed } = wrapLandingIdleTimer();
368367
await withTestRenderer(async (h) => {
369368
const shell = createAppShell(h.renderer, {
370369
terminal: { columns: 80, rows: 24 },
371370
wireKeys: false,
372371
run: "idle",
372+
reducedMotion: true,
373373
});
374374
try {
375+
expect(armed).toHaveLength(0);
375376
await settle(h);
376-
let snowingAt: number | undefined;
377-
let snowing = "";
378-
for (const nowMs of SNOW_SAMPLE_CLOCKS_MS) {
379-
paintLanding(shell, nowMs, false, false);
377+
const first = markRows(h).join("\n");
378+
expect(first.includes(SNOW_CHAR)).toBe(false);
379+
expect(first.length).toBeGreaterThan(0);
380+
381+
const frames = new Set<string>([first]);
382+
for (const nowMs of [0, 500, 1_100, 1_900, 2_600, 3_400]) {
383+
paintLanding(shell, nowMs, true);
380384
await settle(h);
381385
const frame = markRows(h).join("\n");
382-
if (frame.includes(SNOW_CHAR)) {
383-
snowingAt = nowMs;
384-
snowing = frame;
385-
break;
386-
}
387-
}
388-
if (snowingAt === undefined) {
389-
throw new Error("expected a still-mode clock that draws snow through paintLanding");
386+
expect(frame.includes(SNOW_CHAR)).toBe(false);
387+
frames.add(frame);
390388
}
391-
paintLanding(shell, snowingAt, false, true);
392-
await settle(h);
393-
const quiet = markRows(h).join("\n");
394-
expect(quiet.includes(SNOW_CHAR)).toBe(false);
395-
expect(stripSnow(quiet)).toBe(stripSnow(snowing));
389+
expect(frames.size).toBe(1);
396390
} finally {
397391
shell.dispose();
398392
}

src/tui/landing.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ export interface LandingAbove {
290290
* Rows are allocated for the largest tier once and hidden from the top down as
291291
* smaller tiers are selected, so a resize never rebuilds the subtree.
292292
*/
293-
export function createLandingAbove(ctx: CliRenderer): LandingAbove {
293+
export function createLandingAbove(ctx: CliRenderer, reducedMotion = false): LandingAbove {
294294
const box = new BoxRenderable(ctx, {
295295
id: "shell-landing-above",
296296
width: "100%",
@@ -346,7 +346,7 @@ export function createLandingAbove(ctx: CliRenderer): LandingAbove {
346346
grid: MARK_SMALL,
347347
};
348348
fitLandingMark(above, MARK_SMALL);
349-
paintLandingMark(above, 0, true);
349+
paintLandingMark(above, 0, true, reducedMotion);
350350
return above;
351351
}
352352

src/tui/product-host.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ export interface ProductHostConfig {
175175
readonly turnMonitor?: TurnMonitorOptions;
176176
/** First-run telemetry disclosure, shown on the landing screen. */
177177
readonly telemetryNotice?: string;
178+
/**
179+
* Suppress landing snow and mountain motion. Forwarded to the shell at
180+
* mount; the idle timer is never armed.
181+
*/
182+
readonly reducedMotion?: boolean;
178183
/**
179184
* Take DEC mouse reporting. Default true: wheel/trackpad scroll only
180185
* reaches OpenTUI when the terminal is told to report it, otherwise the
@@ -311,6 +316,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise<Produ
311316
...(config.onCommand !== undefined ? { onCommand: config.onCommand } : {}),
312317
...(config.onObserveRequest !== undefined ? { onObserveRequest: config.onObserveRequest } : {}),
313318
...(config.telemetryNotice !== undefined ? { telemetryNotice: config.telemetryNotice } : {}),
319+
...(config.reducedMotion === true ? { reducedMotion: true } : {}),
314320
});
315321

316322
// Announced on the notice strip (or transcript once the session has content)

src/tui/shell.ts

Lines changed: 44 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,11 @@ export interface AppShellOptions {
505505
* notice has been shown, so it is not permanent chrome.
506506
*/
507507
readonly telemetryNotice?: string;
508+
/**
509+
* Suppress landing snow and mountain motion. The idle timer is not
510+
* armed, and `paintLanding` holds a still mountain with no flakes.
511+
*/
512+
readonly reducedMotion?: boolean;
508513
/**
509514
* Clipboard port for Alt+C and drag-select auto-copy. Defaults to an
510515
* in-memory recorder so tests and demos never shell out; the product host
@@ -1811,7 +1816,7 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void {
18111816
// A new zone can seat a different tier, and a tier is a different grid, so
18121817
// the mark is redrawn rather than left showing the previous size's frame.
18131818
fitLandingMark(landing.above, resolveMarkGrid(split.above, layout.contentWidth));
1814-
paintLandingMark(landing.above, bag.landingNowMs, !bag.landingAnimating);
1819+
paintLandingMark(landing.above, bag.landingNowMs, !bag.landingAnimating, bag.reducedMotion);
18151820
landing.below.height = Math.max(0, split.below);
18161821
landing.below.visible = split.below > 0;
18171822
}
@@ -2109,6 +2114,12 @@ interface ShellInternals {
21092114
landingAnimating: boolean;
21102115
/** Clock of the last painted mark frame, so a resize can redraw in place. */
21112116
landingNowMs: number;
2117+
/**
2118+
* Mount-time reduced-motion flag. When true, the idle snow timer is
2119+
* never armed and every landing paint holds a still mountain with no
2120+
* flakes. Set once at `createAppShell`; not a per-paint argument.
2121+
*/
2122+
reducedMotion: boolean;
21122123
/**
21132124
* Cancels the mount-scoped idle repaint timer armed in `createAppShell`,
21142125
* or null while none is armed. Cleared by whichever teardown happens
@@ -2813,19 +2824,19 @@ const LANDING_IDLE_REPAINT_INTERVAL_MS = 125;
28132824
* needs to drift across a frozen mountain. Driven by the mount-scoped timer
28142825
* armed in `createAppShell` rather than a render event, so the repaint
28152826
* cadence is independent of however often the renderer happens to paint.
2827+
*
2828+
* Reduced motion is a mount-time flag on the shell, not a per-paint
2829+
* argument: it freezes the mountain and drops snow even when a caller
2830+
* asks for `animating`.
28162831
*/
2817-
export function paintLanding(
2818-
shell: AppShell,
2819-
nowMs: number,
2820-
animating: boolean,
2821-
reducedMotion = false,
2822-
): void {
2832+
export function paintLanding(shell: AppShell, nowMs: number, animating: boolean): void {
28232833
const bag = internals.get(shell);
28242834
const landing = bag?.landing;
28252835
if (bag === undefined || landing === null || landing === undefined) return;
2826-
bag.landingAnimating = animating;
2836+
const motion = bag.reducedMotion ? false : animating;
2837+
bag.landingAnimating = motion;
28272838
bag.landingNowMs = nowMs;
2828-
paintLandingMark(landing.above, nowMs, !animating, reducedMotion);
2839+
paintLandingMark(landing.above, nowMs, !motion, bag.reducedMotion);
28292840
}
28302841

28312842
/** True while the landing composition is still mounted. */
@@ -5749,6 +5760,7 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption
57495760
const paletteCatalogOpt = options?.paletteCatalog ?? null;
57505761
const onCommandOpt = options?.onCommand;
57515762
const onObserveRequestOpt = options?.onObserveRequest;
5763+
const reducedMotion = options?.reducedMotion === true;
57525764

57535765
const terminal = terminalOf(renderer, options?.terminal);
57545766
const layout = resolveGeometry({
@@ -5865,7 +5877,7 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption
58655877
});
58665878
transcript.add(transcriptSpacer);
58675879

5868-
const landingAbove = createLandingAbove(ctx);
5880+
const landingAbove = createLandingAbove(ctx, reducedMotion);
58695881
const landingBelowState = landingBelowContent({
58705882
rows: splitLandingRows(layout.heights.transcript).below,
58715883
columns: layout.contentWidth,
@@ -6650,6 +6662,7 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption
66506662
landingSuggestionsVisible: true,
66516663
landingAnimating: false,
66526664
landingNowMs: 0,
6665+
reducedMotion,
66536666
landingIdleTimerCancel: null,
66546667
chrome: { task: [], tasksRaw: [], agents: [] },
66556668
// CL-5847: the manage_tasks checklist panel is hidden by default. The
@@ -6679,22 +6692,27 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption
66796692
// the renderer directly (`withTestRenderer`'s cleanup) without ever
66806693
// calling `shell.dispose()`. Without this check the timer would keep
66816694
// firing against renderables the harness already tore down.
6682-
const landingIdleHandle = setInterval(() => {
6683-
if (renderer.isDestroyed) {
6684-
clearInterval(landingIdleHandle);
6685-
return;
6686-
}
6687-
const bag = internals.get(shell);
6688-
if (bag?.landing == null || bag.landingAnimating) return;
6689-
paintLanding(shell, Date.now(), false);
6690-
}, LANDING_IDLE_REPAINT_INTERVAL_MS);
6691-
landingIdleHandle.unref?.();
6692-
{
6693-
const bag = internals.get(shell);
6694-
if (bag !== undefined) {
6695-
bag.landingIdleTimerCancel = () => clearInterval(landingIdleHandle);
6696-
} else {
6697-
clearInterval(landingIdleHandle);
6695+
//
6696+
// Reduced motion never starts the timer: there is no snow to advance
6697+
// and the mountain stays on its filled frame.
6698+
if (!reducedMotion) {
6699+
const landingIdleHandle = setInterval(() => {
6700+
if (renderer.isDestroyed) {
6701+
clearInterval(landingIdleHandle);
6702+
return;
6703+
}
6704+
const bag = internals.get(shell);
6705+
if (bag?.landing == null || bag.landingAnimating) return;
6706+
paintLanding(shell, Date.now(), false);
6707+
}, LANDING_IDLE_REPAINT_INTERVAL_MS);
6708+
landingIdleHandle.unref?.();
6709+
{
6710+
const bag = internals.get(shell);
6711+
if (bag !== undefined) {
6712+
bag.landingIdleTimerCancel = () => clearInterval(landingIdleHandle);
6713+
} else {
6714+
clearInterval(landingIdleHandle);
6715+
}
66986716
}
66996717
}
67006718
transcriptSpacers.set(shell, transcriptSpacer);

0 commit comments

Comments
 (0)