Something went wrong. Try again.
Shared TUI abstractions for Pi extensions
Something went wrong. Try again.
4.9 kB · 170 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171import type { Component } from "@earendil-works/pi-tui";import { truncateToWidth } from "@earendil-works/pi-tui";
export type ColumnDef = { child: Component; /** Fixed width in columns. Omit = split remaining equally. */ width?: number; /** Collapse to vertical stack if this can't be met. */ minWidth?: number;};
export type ColumnsOptions = { /** Horizontal gap between columns (default: 1) */ gap?: number; /** Width threshold to switch to vertical stack */ collapseAt?: number; /** Vertical alignment when columns have different heights (default: "top") */ align?: "top" | "bottom";};
export class Columns implements Component { private columns: ColumnDef[]; private gap: number; private collapseAt: number; private _align: "top" | "bottom";
constructor(columns: ColumnDef[], options?: ColumnsOptions) { this.columns = columns; this.gap = options?.gap ?? 1; this.collapseAt = options?.collapseAt ?? 0; this._align = options?.align ?? "top"; void this._align; }
setColumns(columns: ColumnDef[]): void { this.columns = columns; }
render(width: number): string[] { // Check if any column's minWidth can't be met, or if width < collapseAt if (this.shouldCollapse(width)) { return this.renderVertical(width); } return this.renderHorizontal(width); }
invalidate(): void { for (const col of this.columns) { col.child.invalidate(); } }
private shouldCollapse(width: number): boolean { if (this.collapseAt > 0 && width < this.collapseAt) { return true; } // Check minWidth constraints const totalFixed = this.columns.reduce((sum, c) => sum + (c.width ?? 0), 0); const totalGaps = Math.max(0, this.columns.length - 1) * this.gap; const flexibleCount = this.columns.filter((c) => !c.width).length;
// Remaining width after fixed columns and gaps const remaining = width - totalFixed - totalGaps; const perFlexible = flexibleCount > 0 ? remaining / flexibleCount : 0;
for (const col of this.columns) { if (col.minWidth) { const colWidth = col.width ?? perFlexible; if (colWidth < col.minWidth) { return true; } } }
return false; }
private renderHorizontal(width: number): string[] { const colWidths = this.computeWidths(width); if (!colWidths) { return this.renderVertical(width); }
// Render each column const rendered = this.columns.map((col, i) => ({ lines: col.child.render(colWidths[i]!), width: colWidths[i]!, }));
// Pad each line to its column width and merge side-by-side const maxLines = Math.max(...rendered.map((r) => r.lines.length)); const lines: string[] = [];
for (let row = 0; row < maxLines; row++) { let line = ""; for (let col = 0; col < rendered.length; col++) { const colLine = rendered[col]!.lines[row] ?? ""; const colWidth = rendered[col]!.width;
// Pad or truncate the line to fit the column width const padded = this.padLine(colLine, colWidth);
if (col > 0) { line += " ".repeat(this.gap); } line += padded; } lines.push(truncateToWidth(line, width, "", true)); }
return lines; }
private renderVertical(width: number): string[] { const lines: string[] = []; for (let i = 0; i < this.columns.length; i++) { const childLines = this.columns[i]!.child.render(width); lines.push(...childLines); // Add blank line between stacked columns (not after last) if (i < this.columns.length - 1) { lines.push(""); } } return lines; }
private computeWidths(totalWidth: number): number[] | null { const n = this.columns.length; if (n === 0) return [];
const totalGaps = Math.max(0, n - 1) * this.gap; let available = totalWidth - totalGaps;
if (available < n) return null; // not enough space
const widths: number[] = new Array(n).fill(0); const flexible: number[] = [];
// Assign fixed widths first for (let i = 0; i < n; i++) { const colWidth = this.columns[i]!.width; if (colWidth !== undefined) { widths[i] = colWidth; available -= colWidth; } else { flexible.push(i); } }
if (available < 0) return null;
// Distribute remaining equally among flexible columns if (flexible.length > 0) { const perCol = Math.floor(available / flexible.length); let remainder = available - perCol * flexible.length;
for (const idx of flexible) { widths[idx] = perCol + (remainder > 0 ? 1 : 0); if (remainder > 0) remainder--; } }
return widths; }
/** Pad a line to exactly `width` visible columns, handling ANSI codes. */ private padLine(line: string, width: number): string { return truncateToWidth(line, width, "", true); }}