can1357--oh-my-pi
108 行
3.0 KiB
TypeScript
108 行
3.0 KiB
TypeScript
import type { TUI } from "../tui";
|
|
import { sliceByColumn, visibleWidth } from "../utils";
|
|
import { Text } from "./text";
|
|
|
|
/**
|
|
* Loader component. Spinner frames advance at `SPINNER_ADVANCE_MS`.
|
|
*
|
|
* Message colorizers that are time-dependent can opt into 30fps redraws by
|
|
* setting `animated` to `true` on the function object.
|
|
*/
|
|
const RENDER_INTERVAL_MS = 1000 / 30;
|
|
const SPINNER_ADVANCE_MS = 80;
|
|
|
|
type ColorFn = (str: string) => string;
|
|
|
|
export type LoaderMessageColorFn = ColorFn & {
|
|
readonly animated?: true;
|
|
};
|
|
|
|
export class Loader extends Text {
|
|
#frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
#currentFrame = 0;
|
|
#intervalId?: NodeJS.Timeout;
|
|
#ui: TUI | null = null;
|
|
#lastSpinnerTick = 0;
|
|
|
|
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 lines = ["", ...super.render(width)];
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (visibleWidth(line) > width) {
|
|
lines[i] = sliceByColumn(line, 0, width, true);
|
|
}
|
|
}
|
|
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 text = `${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`;
|
|
if (this.setText(text) && 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);
|
|
}
|
|
}
|
|
}
|
|
}
|