- Added setup wizard with provider login, glyph mode, and theme scenes shown once per setup version. - Wired `omp setup` (no args) to trigger the wizard in a TTY; `--check`/`--json` still show help. - Extracted `gradientEscape` and exported `PI_LOGO`/`ShineConfig` from welcome for shared use in splash/outro. - Fixed race condition in `setSymbolPreset`/`setColorBlindMode` by tracking load request IDs.
276 lines
8.5 KiB
TypeScript
276 lines
8.5 KiB
TypeScript
import { type Component, matchesKey, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
import { APP_NAME } from "@oh-my-pi/pi-utils";
|
|
import { gradientLogo, PI_LOGO } from "../components/welcome";
|
|
import { theme } from "../theme/theme";
|
|
import type { InteractiveModeContext } from "../types";
|
|
import { renderSetupOutro, SETUP_OUTRO_MS } from "./scenes/outro";
|
|
import { renderSetupSplash, SETUP_SPLASH_MS, SETUP_TICK_MS } from "./scenes/splash";
|
|
import type { SetupScene, SetupSceneController, SetupSceneHost, SetupSceneResult } from "./scenes/types";
|
|
|
|
type WizardPhase = "splash" | "transition" | "scene" | "outro" | "done";
|
|
|
|
const SCENE_MARGIN_X = 4;
|
|
const MIN_CONTENT_WIDTH = 20;
|
|
/** Cross-dissolve duration from the splash into the first scene. */
|
|
const SCENE_TRANSITION_MS = 420;
|
|
|
|
function centerLine(line: string, width: number): string {
|
|
const lineWidth = visibleWidth(line);
|
|
if (lineWidth >= width) return truncateToWidth(line, width);
|
|
const left = Math.floor((width - lineWidth) / 2);
|
|
return padding(left) + line + padding(width - left - lineWidth);
|
|
}
|
|
|
|
function clampLine(line: string, width: number): string {
|
|
const truncated = truncateToWidth(line, width);
|
|
return truncated + padding(Math.max(0, width - visibleWidth(truncated)));
|
|
}
|
|
|
|
function indentLine(line: string, width: number, indent: number): string {
|
|
const prefix = padding(Math.min(indent, Math.max(0, width - 1)));
|
|
return clampLine(prefix + line, width);
|
|
}
|
|
/** Stable per-row jitter in [0,1) for the dissolve reveal order. */
|
|
function rowNoise(y: number): number {
|
|
const h = Math.imul(y ^ 0x9e3779b9, 2654435761);
|
|
return ((h ^ (h >>> 15)) >>> 0) / 4294967296;
|
|
}
|
|
|
|
/**
|
|
* Top-biased cross-dissolve between two equal-height frames. As `progress`
|
|
* (0..1) advances, each row flips from `from` to `to` once it crosses a per-row
|
|
* threshold — top rows reveal first (so the scene's mark/header materializes
|
|
* before the splash water below it), with a little jitter for an organic edge.
|
|
*/
|
|
function dissolveFrames(from: string[], to: string[], progress: number, height: number): string[] {
|
|
const eased = progress * progress * (3 - 2 * progress);
|
|
const denom = Math.max(1, height - 1);
|
|
const out: string[] = [];
|
|
for (let y = 0; y < height; y++) {
|
|
const threshold = 0.78 * (y / denom) + 0.22 * rowNoise(y);
|
|
out.push((eased >= threshold ? to[y] : from[y]) ?? "");
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export class SetupWizardComponent implements Component {
|
|
#phase: WizardPhase = "splash";
|
|
#phaseStartedAt = performance.now();
|
|
#sceneIndex = 0;
|
|
#activeScene: SetupSceneController | undefined;
|
|
#timer: NodeJS.Timeout | undefined;
|
|
#done = Promise.withResolvers<void>();
|
|
#disposed = false;
|
|
|
|
constructor(
|
|
readonly ctx: InteractiveModeContext,
|
|
readonly scenes: readonly SetupScene[],
|
|
) {}
|
|
|
|
run(): Promise<void> {
|
|
this.#phase = this.scenes.length === 0 ? "outro" : "splash";
|
|
this.#phaseStartedAt = performance.now();
|
|
this.#startTimer();
|
|
this.ctx.ui.requestRender();
|
|
return this.#done.promise;
|
|
}
|
|
|
|
dispose(): void {
|
|
this.#disposed = true;
|
|
this.#stopTimer();
|
|
this.#unmountActiveScene();
|
|
}
|
|
|
|
invalidate(): void {
|
|
this.#activeScene?.invalidate();
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
if (this.#phase === "done") return;
|
|
if (matchesKey(data, "ctrl+c")) {
|
|
this.#beginOutro();
|
|
return;
|
|
}
|
|
if (this.#phase === "splash") {
|
|
if (
|
|
matchesKey(data, "enter") ||
|
|
matchesKey(data, "return") ||
|
|
matchesKey(data, "space") ||
|
|
matchesKey(data, "escape")
|
|
) {
|
|
this.#beginScene();
|
|
}
|
|
return;
|
|
}
|
|
if (this.#phase === "outro") {
|
|
if (
|
|
matchesKey(data, "enter") ||
|
|
matchesKey(data, "return") ||
|
|
matchesKey(data, "space") ||
|
|
matchesKey(data, "escape")
|
|
) {
|
|
this.#complete();
|
|
}
|
|
return;
|
|
}
|
|
this.#activeScene?.handleInput?.(data);
|
|
}
|
|
|
|
render(width: number): string[] {
|
|
const safeWidth = Math.max(1, width);
|
|
const height = Math.max(1, this.ctx.ui.terminal.rows);
|
|
let lines: string[];
|
|
switch (this.#phase) {
|
|
case "splash":
|
|
lines = renderSetupSplash(safeWidth, height, performance.now() - this.#phaseStartedAt);
|
|
break;
|
|
case "transition": {
|
|
const elapsed = performance.now() - this.#phaseStartedAt;
|
|
const progress = Math.min(1, elapsed / SCENE_TRANSITION_MS);
|
|
const splash = renderSetupSplash(safeWidth, height, SETUP_SPLASH_MS + elapsed);
|
|
const scene = this.#renderScene(safeWidth, height);
|
|
lines = dissolveFrames(splash, scene, progress, height);
|
|
break;
|
|
}
|
|
case "outro":
|
|
lines = renderSetupOutro(safeWidth, height, performance.now() - this.#phaseStartedAt);
|
|
break;
|
|
case "scene":
|
|
lines = this.#renderScene(safeWidth, height);
|
|
break;
|
|
case "done":
|
|
lines = [];
|
|
break;
|
|
}
|
|
return this.#fitToScreen(lines, safeWidth, height);
|
|
}
|
|
|
|
#renderScene(width: number, height: number): string[] {
|
|
const scene = this.scenes[this.#sceneIndex];
|
|
const title = this.#activeScene?.title ?? scene?.title ?? "Setup";
|
|
const subtitle = this.#activeScene?.subtitle;
|
|
const contentWidth = Math.max(MIN_CONTENT_WIDTH, width - SCENE_MARGIN_X * 2);
|
|
const logo = gradientLogo(PI_LOGO, 0);
|
|
const header = [
|
|
"",
|
|
...logo.map(line => centerLine(line, width)),
|
|
centerLine(theme.bold(theme.fg("accent", APP_NAME)), width),
|
|
centerLine(theme.fg("muted", `Setup step ${this.#sceneIndex + 1} of ${this.scenes.length}`), width),
|
|
"",
|
|
indentLine(theme.bold(title), width, SCENE_MARGIN_X),
|
|
];
|
|
if (subtitle) {
|
|
header.push(indentLine(theme.fg("muted", subtitle), width, SCENE_MARGIN_X));
|
|
}
|
|
header.push("");
|
|
|
|
const footer = [
|
|
"",
|
|
centerLine(theme.fg("dim", "↑/↓ select · enter confirm · esc skip · ctrl+c exit setup"), width),
|
|
];
|
|
const maxBodyLines = Math.max(0, height - header.length - footer.length);
|
|
const body = this.#activeScene?.render(contentWidth).slice(0, maxBodyLines) ?? [];
|
|
const lines = [...header, ...body.map(line => indentLine(line, width, SCENE_MARGIN_X))];
|
|
while (lines.length + footer.length < height) {
|
|
lines.push("");
|
|
}
|
|
lines.push(...footer);
|
|
return lines;
|
|
}
|
|
|
|
#fitToScreen(lines: string[], width: number, height: number): string[] {
|
|
const fitted = lines.slice(0, height).map(line => clampLine(line, width));
|
|
while (fitted.length < height) {
|
|
fitted.push(padding(width));
|
|
}
|
|
return fitted;
|
|
}
|
|
|
|
#startTimer(): void {
|
|
if (this.#timer) return;
|
|
this.#timer = setInterval(() => {
|
|
if (this.#disposed) return;
|
|
const elapsed = performance.now() - this.#phaseStartedAt;
|
|
if (this.#phase === "splash" && elapsed >= SETUP_SPLASH_MS) {
|
|
this.#beginScene();
|
|
} else if (this.#phase === "transition" && elapsed >= SCENE_TRANSITION_MS) {
|
|
this.#phase = "scene";
|
|
this.#phaseStartedAt = performance.now();
|
|
this.ctx.ui.requestRender();
|
|
} else if (this.#phase === "outro" && elapsed >= SETUP_OUTRO_MS) {
|
|
this.#complete();
|
|
} else {
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
}, SETUP_TICK_MS);
|
|
}
|
|
|
|
#stopTimer(): void {
|
|
if (!this.#timer) return;
|
|
clearInterval(this.#timer);
|
|
this.#timer = undefined;
|
|
}
|
|
|
|
#mountSceneController(targetPhase: "scene" | "transition"): void {
|
|
if (this.#disposed) return;
|
|
this.#unmountActiveScene();
|
|
if (this.#sceneIndex >= this.scenes.length) {
|
|
this.#beginOutro();
|
|
return;
|
|
}
|
|
const scene = this.scenes[this.#sceneIndex];
|
|
const host: SetupSceneHost = {
|
|
ctx: this.ctx,
|
|
requestRender: () => this.ctx.ui.requestRender(),
|
|
finish: (_result: SetupSceneResult) => this.#finishScene(),
|
|
setFocus: component => this.ctx.ui.setFocus(component),
|
|
restoreFocus: () => this.ctx.ui.setFocus(this),
|
|
};
|
|
this.#activeScene = scene.mount(host);
|
|
this.#phase = targetPhase;
|
|
this.#phaseStartedAt = performance.now();
|
|
this.ctx.ui.setFocus(this);
|
|
void this.#activeScene.onMount?.();
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
|
|
/** Enter the first scene through a dissolve from the splash. */
|
|
#beginScene(): void {
|
|
this.#mountSceneController("transition");
|
|
}
|
|
|
|
#mountCurrentScene(): void {
|
|
this.#mountSceneController("scene");
|
|
}
|
|
|
|
#finishScene(): void {
|
|
if (this.#phase !== "scene" && this.#phase !== "transition") return;
|
|
this.#unmountActiveScene();
|
|
this.#sceneIndex += 1;
|
|
this.#mountCurrentScene();
|
|
}
|
|
|
|
#unmountActiveScene(): void {
|
|
this.#activeScene?.onUnmount?.();
|
|
this.#activeScene?.dispose?.();
|
|
this.#activeScene = undefined;
|
|
}
|
|
|
|
#beginOutro(): void {
|
|
if (this.#phase === "done") return;
|
|
this.#unmountActiveScene();
|
|
this.#phase = "outro";
|
|
this.#phaseStartedAt = performance.now();
|
|
this.ctx.ui.setFocus(this);
|
|
this.#startTimer();
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
|
|
#complete(): void {
|
|
if (this.#phase === "done") return;
|
|
this.#phase = "done";
|
|
this.#stopTimer();
|
|
this.#done.resolve();
|
|
}
|
|
}
|