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;
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);
}
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();
}
#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();
}
}