Files

215 lines
8.2 KiB
TypeScript

/**
* Terminal drawing primitives for the stats dashboard: colour, block-glyph
* charts, and the alternate-screen plumbing.
*
* Hand-rolled for the same reason apps/api/src/charts.ts and png.ts are — the
* repo carries near-zero runtime dependencies, and everything a progress
* dashboard needs is a bar, a sparkline and a coloured cell. The palette is
* charts.ts's, so the terminal, the README SVGs and the digest email are the
* same picture in three renderers.
*
* Side-effect-free on import except for reading the colour environment once;
* nothing is written to the terminal until openScreen() is called.
*/
// ── palette (apps/api/src/charts.ts) ─────────────────────────────
export const FG = "#fafafa"; // zinc-50 text
export const MUTED = "#a1a1aa"; // zinc-400 secondary text
export const LINE = "#27272a"; // zinc-800 structure / empty track
export const GREEN = "#16a34a"; // green-600 (core / pass)
export const BLUE = "#2563eb"; // blue-600 (optional)
export const YELLOW = "#eab308"; // yellow-500 (attention)
export const RED = "#f87171"; // red-400 (fail, on dark)
/** GitHub dark-mode contribution hues; level 0 is the empty cell. */
export const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"];
// ── colour ───────────────────────────────────────────────────────
/**
* Honour NO_COLOR and pipes, and let FORCE_COLOR override both — the dashboard
* is legible without colour, and `bun run stats | less -R` should still be.
*/
export const COLOR =
process.env.FORCE_COLOR !== undefined
? process.env.FORCE_COLOR !== "0"
: process.env.NO_COLOR === undefined && process.stdout.isTTY === true;
const RESET = "\x1b[0m";
function channels(hex: string): string {
return `${parseInt(hex.slice(1, 3), 16)};${parseInt(hex.slice(3, 5), 16)};${parseInt(hex.slice(5, 7), 16)}`;
}
export function fg(hex: string, text: string): string {
return COLOR ? `\x1b[38;2;${channels(hex)}m${text}${RESET}` : text;
}
export function bg(hex: string, text: string): string {
return COLOR ? `\x1b[48;2;${channels(hex)}m${text}${RESET}` : text;
}
export function bold(text: string): string {
return COLOR ? `\x1b[1m${text}${RESET}` : text;
}
const ANSI = /\x1b\[[0-9;]*m/g;
/** Printable width — every glyph used here is one cell wide. */
export function width(text: string): number {
return text.replace(ANSI, "").length;
}
/** Pad or cut a *plain* string to exactly n cells; colour it afterwards. */
export function cell(text: string, n: number, align: "left" | "right" = "left"): string {
const clipped = text.length > n ? `${text.slice(0, Math.max(0, n - 1))}…` : text;
return align === "left" ? clipped.padEnd(n) : clipped.padStart(n);
}
/** Pad an already-coloured string to n cells. */
export function padTo(text: string, n: number): string {
return text + " ".repeat(Math.max(0, n - width(text)));
}
// ── charts ───────────────────────────────────────────────────────
const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
const SPARKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
/**
* Filled and empty. The two differ by GLYPH as well as by colour, so a bar
* still reads under NO_COLOR or in a pipe — every chart here has to survive
* `bun run stats > notes.txt`.
*/
const FILL = "█";
const TRACK = "░";
/**
* One bar over a muted track, `cells` wide. Sub-cell remainders use the
* eighth-block glyphs, so one solved problem out of 161 still shows.
*/
export function bar(value: number, total: number, cells: number, color = GREEN): string {
const units = Math.round(Math.max(0, Math.min(1, total > 0 ? value / total : 0)) * cells * 8);
const filled = FILL.repeat(Math.floor(units / 8)) + EIGHTHS[units % 8]!;
return fg(color, filled) + fg(LINE, TRACK.repeat(Math.max(0, cells - filled.length)));
}
export interface Segment {
value: number;
color: string;
/** Defaults to the solid block; pass TRACK-ish glyphs for "not done yet". */
glyph?: string;
}
/**
* Segments laid end to end, `total` mapped to `cells`. Anything short of
* `total` is left BLANK, not tracked: bars sharing a scale must show their
* different lengths. A caller wanting a visible remainder passes it as a
* segment of its own.
*
* Non-zero segments are floored but never vanish; the overshoot that floor
* creates is taken back from the widest segment, which can spare it.
*/
export function stackedBar(segments: Segment[], total: number, cells: number): string {
if (total <= 0) return " ".repeat(cells);
const widths = segments.map((s) =>
s.value > 0 ? Math.max(1, Math.floor((s.value / total) * cells)) : 0,
);
let over = widths.reduce((a, b) => a + b, 0) - cells;
while (over > 0) {
const i = widths.indexOf(Math.max(...widths));
if (widths[i]! <= 1) break;
widths[i]!--;
over--;
}
const drawn = widths.map((n, i) => fg(segments[i]!.color, (segments[i]!.glyph ?? FILL).repeat(n)));
return drawn.join("") + " ".repeat(Math.max(0, cells - widths.reduce((a, b) => a + b, 0)));
}
/**
* One cell of the campaign heatmap. With colour it is the GREENS ramp the
* README and the digest use; without it, the same five levels as shading.
*/
export function heatCell(attempts: number, body: string): string {
const level = Math.min(attempts, GREENS.length - 1);
return COLOR ? bg(GREENS[level]!, body) : [" ", "·", "▒", "▓", "█"][level]!.repeat(body.length);
}
/** One glyph per value, scaled to the largest. All-zero renders as a floor. */
export function sparkline(values: number[], color = GREEN): string {
const peak = Math.max(...values, 0);
const line = values
.map((v) => (peak === 0 ? SPARKS[0]! : SPARKS[Math.min(7, Math.round((v / peak) * 7))]!))
.join("");
return fg(color, line);
}
// ── layout ───────────────────────────────────────────────────────
/** A section rule that fills the width: `── name ────────────`. */
export function rule(title: string, cells: number): string {
const label = ` ${title} `;
return (
fg(LINE, "──") + fg(MUTED, label) + fg(LINE, "─".repeat(Math.max(0, cells - 2 - label.length)))
);
}
// ── screen ───────────────────────────────────────────────────────
const ALT_ON = "\x1b[?1049h\x1b[?25l";
const ALT_OFF = "\x1b[?25h\x1b[?1049l";
export interface Screen {
readonly rows: number;
readonly columns: number;
/** Draw a frame, clipped to the terminal height. */
draw(lines: string[]): void;
close(): void;
}
/**
* Take over the terminal: alternate buffer, hidden cursor, raw keys. `onKey`
* receives the raw chunk (`"\t"`, `"\x1b[B"`, `"q"`, …) — decoding belongs to
* the app, which is the only thing that knows what a key means.
*
* The caller MUST close() in a finally: raw mode survives a thrown exception.
*/
export function openScreen(onKey: (chunk: string) => void, onResize: () => void): Screen {
const out = process.stdout;
const input = process.stdin;
out.write(ALT_ON);
input.setRawMode?.(true);
input.setEncoding("utf8");
input.resume();
input.on("data", onKey);
out.on("resize", onResize);
let closed = false;
return {
get rows() {
return out.rows ?? 24;
},
get columns() {
return out.columns ?? 80;
},
draw(lines: string[]): void {
const visible = lines.slice(0, this.rows);
// Home, then erase each line as it is rewritten plus everything below the
// last one. No full clear, so a redraw does not flash, and no trailing
// newline, which would scroll the alternate screen by a row per frame.
out.write(`\x1b[H${visible.map((l) => `${l}\x1b[K`).join("\r\n")}\x1b[J`);
},
close(): void {
if (closed) return;
closed = true;
input.off("data", onKey);
out.off("resize", onResize);
input.setRawMode?.(false);
input.pause();
out.write(ALT_OFF);
},
};
}