Files
oh-my-pi/packages/tui/src/components/loader.ts
T
roboomp 09429fe4ea fix(tui): cached loader layout across shimmer frames
Separated stable plain-text layout from animated ANSI coloring so 30fps shimmer updates no longer repeat wrapping and width measurement.

Fixes #5230
2026-07-14 19:47:33 +00:00

134 lines
4.1 KiB
TypeScript

import type { TUI } from "../tui";
import { getPaddingX, sliceByColumn, visibleWidth } from "../utils";
import { Text } from "./text";
const RENDER_INTERVAL_MS = 1000 / 30;
const SPINNER_ADVANCE_MS = 80;
type ColorFn = (str: string) => string;
/**
* Styles Loader message fragments without changing their visible text or width.
* Set `animated` for colorizers whose ANSI output changes over time.
*/
export type LoaderMessageColorFn = ColorFn & {
readonly animated?: true;
};
/** Animates a spinner and colorized message while asynchronous work is pending. */
export class Loader extends Text {
#frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
#currentFrame = 0;
#intervalId?: NodeJS.Timeout;
#ui: TUI | null = null;
#lastSpinnerTick = 0;
#layoutSource?: readonly string[];
#layout?: readonly { leading: string; content: string; trailing: string }[];
constructor(
ui: TUI,
private spinnerColorFn: ColorFn,
private messageColorFn: LoaderMessageColorFn,
private message: string = "Loading...",
spinnerFrames?: string[],
) {
super("", 1, 0);
this.#ui = ui;
if (spinnerFrames && spinnerFrames.length > 0) {
this.#frames = spinnerFrames;
}
this.start();
}
render(width: number): readonly string[] {
const source = super.render(width);
if (source !== this.#layoutSource) {
const paddingX = getPaddingX(1);
this.#layoutSource = source;
this.#layout = source.map(line => {
const clamped = visibleWidth(line) > width ? sliceByColumn(line, 0, width, true) : line;
const body = clamped.slice(paddingX);
const content = body.trimEnd();
return {
leading: clamped.slice(0, paddingX),
content,
trailing: body.slice(content.length),
};
});
}
const frame = this.#frames[this.#currentFrame];
const lines = [""];
const layout = this.#layout ?? [];
for (let i = 0; i < layout.length; i++) {
const { leading, content, trailing } = layout[i];
if (i === 0 && content.startsWith(frame)) {
const remainder = content.slice(frame.length);
const separator = remainder.startsWith(" ") ? " " : "";
const message = remainder.slice(separator.length);
lines.push(
`${leading}${this.spinnerColorFn(frame)}${separator}${message ? this.messageColorFn(message) : ""}${trailing}`,
);
} else {
lines.push(`${leading}${content ? this.messageColorFn(content) : ""}${trailing}`);
}
}
return lines;
}
start() {
this.#lastSpinnerTick = performance.now();
this.#updateDisplay();
const intervalMs = this.messageColorFn.animated === true ? RENDER_INTERVAL_MS : SPINNER_ADVANCE_MS;
this.#intervalId = setInterval(() => {
const now = performance.now();
const elapsed = now - this.#lastSpinnerTick;
const shouldAdvanceSpinner = elapsed >= SPINNER_ADVANCE_MS;
if (shouldAdvanceSpinner) {
const steps = Math.floor(elapsed / SPINNER_ADVANCE_MS);
this.#currentFrame = (this.#currentFrame + steps) % this.#frames.length;
this.#lastSpinnerTick += steps * SPINNER_ADVANCE_MS;
}
if (shouldAdvanceSpinner || this.#ui?.synchronizedOutput === true) {
this.#updateDisplay();
}
}, intervalMs);
}
stop() {
if (this.#intervalId) {
clearInterval(this.#intervalId);
this.#intervalId = undefined;
}
}
/** Lifecycle teardown: stop the animation timer. Idempotent. */
dispose() {
this.stop();
}
setMessage(message: string) {
if (message === this.message) {
return;
}
this.message = message;
this.#updateDisplay();
}
#updateDisplay() {
const frame = this.#frames[this.#currentFrame];
const textChanged = this.setText(`${frame} ${this.message}`);
if ((textChanged || this.messageColorFn.animated === true) && this.#ui) {
// Direct write: a loader tick changes only this component, so the TUI
// can update the already-positioned rows without driving the full
// compose/prepare/diff pipeline. Lightweight test stubs may not carry
// the newer API; keep their legacy component-scoped path working.
if (typeof this.#ui.requestDirectWrite === "function") {
this.#ui.requestDirectWrite(this);
} else {
this.#ui.requestComponentRender(this);
}
}
}
}