Skip to content

Commit 48b1c3a

Browse files
Merge pull request #722 from corbitsdev/polish-first-run-welcome-hold
Hold the first-run mountain filled until continue
2 parents cd856e8 + a3ff191 commit 48b1c3a

2 files changed

Lines changed: 148 additions & 12 deletions

File tree

src/tui/welcome.test.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import { describe, expect, test } from "bun:test";
22

33
import { PRODUCT_NAME } from "../branding.js";
4+
import { MARK_PERIOD_SECONDS, markFrame, markText, renderMark } from "./mark-anim.js";
45
import { MARK_LARGE, MARK_MID, MARK_SMALL } from "./mark-shape.js";
56
import { createHarness } from "./harness.js";
6-
import { resolveWelcomeMarkGrid, runWelcome, WELCOME_LINE } from "./welcome.js";
7+
import { stringWidth } from "./view/height.js";
8+
import {
9+
resolveWelcomeLine,
10+
resolveWelcomeMarkGrid,
11+
runWelcome,
12+
WELCOME_AUTO_ADVANCE_MS,
13+
WELCOME_LINE,
14+
welcomeMarkStill,
15+
} from "./welcome.js";
716

817
describe("WELCOME_LINE", () => {
918
test("names the product as the local software factory", () => {
@@ -74,3 +83,114 @@ describe("runWelcome", () => {
7483
}
7584
});
7685
});
86+
87+
describe("welcomeMarkStill", () => {
88+
test("animates through fill, then freezes the full frame", () => {
89+
const fillMs = 0.76 * MARK_PERIOD_SECONDS * 1000;
90+
expect(welcomeMarkStill(fillMs - 1)).toBe(false);
91+
expect(welcomeMarkStill(fillMs)).toBe(true);
92+
expect(welcomeMarkStill(0.95 * MARK_PERIOD_SECONDS * 1000)).toBe(true);
93+
expect(welcomeMarkStill(MARK_PERIOD_SECONDS * 1000 + 900)).toBe(true);
94+
});
95+
});
96+
97+
describe("WELCOME_AUTO_ADVANCE_MS", () => {
98+
test("lands on the held filled frame, not fade-out or a second draw-in", () => {
99+
const seconds = WELCOME_AUTO_ADVANCE_MS / 1000;
100+
expect(seconds).toBeGreaterThanOrEqual(0.76 * MARK_PERIOD_SECONDS);
101+
expect(seconds).toBeLessThan(MARK_PERIOD_SECONDS);
102+
103+
const still = welcomeMarkStill(WELCOME_AUTO_ADVANCE_MS);
104+
const frame = markFrame(seconds, still);
105+
expect(still).toBe(true);
106+
expect(frame).toEqual({ drawProg: 1, fillProg: 1, alpha: 1 });
107+
108+
// Looping math at this delay must also still be the full hold — never the
109+
// fade (90–100%) or the wrapped second draw-in.
110+
const looping = markFrame(seconds, false);
111+
expect(looping.alpha).toBe(1);
112+
expect(looping.fillProg).toBe(1);
113+
expect(looping.drawProg).toBe(1);
114+
expect(seconds).toBeLessThan(0.9 * MARK_PERIOD_SECONDS + 1e-9);
115+
});
116+
});
117+
118+
describe("resolveWelcomeLine", () => {
119+
test("keeps the full factory sentence or hides it, never a mid-word slice", () => {
120+
expect(resolveWelcomeLine(80)).toBe(WELCOME_LINE);
121+
expect(resolveWelcomeLine(stringWidth(WELCOME_LINE))).toBe(WELCOME_LINE);
122+
123+
const truncated = WELCOME_LINE.slice(0, 39);
124+
expect(truncated).toContain("facto");
125+
expect(truncated).not.toBe(WELCOME_LINE);
126+
127+
const narrow = resolveWelcomeLine(40);
128+
expect(narrow === "" || narrow === WELCOME_LINE).toBe(true);
129+
expect(narrow).not.toBe(truncated);
130+
expect(narrow.includes("facto") && !narrow.includes("factory")).toBe(false);
131+
});
132+
});
133+
134+
describe("runWelcome hold and cancel", () => {
135+
test("paints a still full mark after fill instead of fading", async () => {
136+
const fadeMs = 0.95 * MARK_PERIOD_SECONDS * 1000;
137+
// First `now()` is mount (`startedAt`); later samples are elapsed fadeMs.
138+
let samples = 0;
139+
const harness = await createHarness({ width: 80, height: 30 });
140+
const done = runWelcome({
141+
createRenderer: async () => harness.renderer,
142+
autoAdvanceMs: 60_000,
143+
now: () => (samples++ === 0 ? 0 : fadeMs),
144+
});
145+
try {
146+
await harness.renderOnce();
147+
await harness.renderOnce();
148+
const frame = harness.captureCharFrame();
149+
const held = markText(renderMark({ nowMs: fadeMs, still: true, grid: MARK_LARGE }));
150+
const fading = markText(renderMark({ nowMs: fadeMs, still: false, grid: MARK_LARGE }));
151+
const mountain = (text: string) => (text.match(/[]/g) ?? []).length;
152+
expect(mountain(frame)).toBe(mountain(held));
153+
expect(mountain(held)).toBeGreaterThan(mountain(fading));
154+
} finally {
155+
harness.pressKey("Ctrl+C");
156+
await Promise.race([done, new Promise((r) => setTimeout(r, 50))]);
157+
harness.destroy();
158+
}
159+
});
160+
161+
test("cancels on Ctrl+D without continuing", async () => {
162+
const harness = await createHarness({ width: 80, height: 30 });
163+
const done = runWelcome({
164+
createRenderer: async () => harness.renderer,
165+
autoAdvanceMs: 60_000,
166+
now: () => 2_000,
167+
});
168+
try {
169+
await harness.renderOnce();
170+
harness.pressKey("d", { ctrl: true });
171+
await expect(done).resolves.toBe(false);
172+
} finally {
173+
harness.destroy();
174+
}
175+
});
176+
177+
test("narrow terminals do not paint a sliced factory sentence", async () => {
178+
const harness = await createHarness({ width: 42, height: 30 });
179+
const done = runWelcome({
180+
createRenderer: async () => harness.renderer,
181+
autoAdvanceMs: 60_000,
182+
now: () => 2_000,
183+
});
184+
try {
185+
await harness.renderOnce();
186+
await harness.renderOnce();
187+
const frame = harness.captureCharFrame();
188+
expect(frame).not.toContain("software facto");
189+
expect(frame.includes("facto") && !frame.includes("factory")).toBe(false);
190+
} finally {
191+
harness.pressKey("Ctrl+C");
192+
await Promise.race([done, new Promise((r) => setTimeout(r, 50))]);
193+
harness.destroy();
194+
}
195+
});
196+
});

src/tui/welcome.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,27 @@ const MARK_TIERS: readonly MarkGrid[] = [MARK_LARGE, MARK_MID, MARK_SMALL];
3737
/** Rows reserved under the mark for the product line, hint, and breathing room. */
3838
const BELOW_MARK_ROWS = 5;
3939

40-
/** Hold after one full mark period before auto-advancing. */
41-
const HOLD_AFTER_PERIOD_MS = 900;
40+
/** Fill completes at this fraction of one mark period (full-frame hold starts). */
41+
const MARK_FILL_END = 0.76;
42+
43+
/** Last instant of the full-frame hold; fade starts after this. */
44+
const MARK_HOLD_END = 0.9;
45+
46+
/**
47+
* Auto-advance at the end of the full-frame hold so setup never opens on a
48+
* fade or a second draw-in.
49+
*/
50+
export const WELCOME_AUTO_ADVANCE_MS = Math.round(MARK_HOLD_END * MARK_PERIOD_SECONDS * 1000);
51+
52+
/** Freeze the mountain on its filled frame once fill completes. */
53+
export function welcomeMarkStill(elapsedMs: number): boolean {
54+
return elapsedMs / 1000 >= MARK_FILL_END * MARK_PERIOD_SECONDS;
55+
}
56+
57+
/** Full product line when it fits; otherwise hide it rather than slicing words. */
58+
export function resolveWelcomeLine(columns: number): string {
59+
return stringWidth(WELCOME_LINE) > columns ? "" : WELCOME_LINE;
60+
}
4261

4362
/** Paint cadence while the mountain draws. */
4463
const PAINT_TICK_MS = 80;
@@ -47,8 +66,8 @@ export interface WelcomeConfig {
4766
/** Renderer factory override for headless mounting in tests. */
4867
readonly createRenderer?: () => Promise<CliRenderer>;
4968
/**
50-
* Auto-advance delay after mount. Defaults to one mark period plus a short
51-
* hold so the silhouette finishes drawing before setup opens.
69+
* Auto-advance delay after mount. Defaults to the end of the full-frame
70+
* hold (`WELCOME_AUTO_ADVANCE_MS`) so setup opens on a filled silhouette.
5271
*/
5372
readonly autoAdvanceMs?: number;
5473
/** Injected clock for mark animation (and tests). */
@@ -87,8 +106,7 @@ export async function runWelcome(config: WelcomeConfig = {}): Promise<boolean> {
87106

88107
const now = config.now ?? Date.now;
89108
const startedAt = now();
90-
const autoAdvanceMs =
91-
config.autoAdvanceMs ?? Math.round(MARK_PERIOD_SECONDS * 1000) + HOLD_AFTER_PERIOD_MS;
109+
const autoAdvanceMs = config.autoAdvanceMs ?? WELCOME_AUTO_ADVANCE_MS;
92110

93111
const margin = resolveSideMargin(renderer.width || 80);
94112

@@ -192,16 +210,14 @@ export async function runWelcome(config: WelcomeConfig = {}): Promise<boolean> {
192210
markRows.forEach((row, index) => {
193211
row.visible = grid !== null && index >= offset;
194212
});
195-
line.content =
196-
stringWidth(WELCOME_LINE) > columns
197-
? WELCOME_LINE.slice(0, Math.max(0, columns - 1))
198-
: WELCOME_LINE;
213+
line.content = resolveWelcomeLine(columns);
199214
};
200215

201216
const paint = (): void => {
202217
if (settled || grid === null) return;
203218
try {
204-
const chunks = markChunks(grid, now() - startedAt, false);
219+
const elapsed = now() - startedAt;
220+
const chunks = markChunks(grid, elapsed, welcomeMarkStill(elapsed));
205221
const offset = MARK_LARGE.rows - grid.rows;
206222
markRows.forEach((row, index) => {
207223
if (!row.visible) return;

0 commit comments

Comments
 (0)