Something went wrong. Try again.
Shared TUI abstractions for Pi extensions
Something went wrong. Try again.
5.6 kB · 193 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194import type { Component } from "@earendil-works/pi-tui";import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
export type TreeNode = { /** Display label for this node */ label: string | Component; /** Style function for this node's label. Overrides Tree default. */ labelStyle?: (text: string) => string; /** Child nodes */ children?: TreeNode[];};
export type TreeOptions = { /** Root nodes */ nodes: TreeNode[]; /** Style for guide lines (├──, └──, │). Default: dim */ guideStyle?: (text: string) => string; /** Default style for node labels. Overridden per-node by TreeNode.labelStyle. Default: passthrough */ labelStyle?: (text: string) => string; /** Whether to show root-level connectors (├──/└──). Default: false */ rootConnectors?: boolean; /** Suffix appended to nodes that have children. Default: "/" */ dirSuffix?: string;};
/** * Tree component — renders nested TreeNode data with box-drawing characters. * * This is a **pure render component**. It has no internal expand/collapse * or selection state. Callers manage what nodes are visible by providing * already-expanded/sliced tree data. * * Guide chars (├──, └──, │) are rendered in guideStyle. * Each node's label is rendered in its own labelStyle (or the tree default). * Long labels are word-wrapped, with continuation lines indented to * align with the label start. */export class Tree implements Component { private nodes: TreeNode[]; private guideStyle: (text: string) => string; private labelStyle: (text: string) => string; private rootConnectors: boolean; private dirSuffix: string;
constructor(options: TreeOptions) { this.nodes = options.nodes; this.guideStyle = options.guideStyle ?? ((t: string) => `\x1b[2m${t}\x1b[0m`); this.labelStyle = options.labelStyle ?? ((t: string) => t); this.rootConnectors = options.rootConnectors ?? false; this.dirSuffix = options.dirSuffix ?? "/"; }
setNodes(nodes: TreeNode[]): void { this.nodes = nodes; }
invalidate(): void {}
render(width: number): string[] { const lines: string[] = [];
for (const [i, node] of this.nodes.entries()) { const isLast = i === this.nodes.length - 1;
if (this.rootConnectors) { renderChild( node, isLast, [], width, this.guideStyle, this.labelStyle, this.dirSuffix, lines, ); } else { renderRoot( node, width, this.guideStyle, this.labelStyle, this.dirSuffix, lines, ); } }
return lines; }}
/** Render a root node (no connector prefix). */function renderRoot( node: TreeNode, width: number, guideStyle: (t: string) => string, defaultLabelStyle: (t: string) => string, dirSuffix: string, lines: string[],): void { const hasChildren = node.children && node.children.length > 0; const labelStr = resolveLabel(node.label, width); const label = hasChildren ? `${labelStr}${dirSuffix}` : labelStr; const styleFn = node.labelStyle ?? defaultLabelStyle;
const wrapped = wrapTextWithAnsi(label, width); for (const line of wrapped) { lines.push(styleFn(line)); }
if (!node.children || node.children.length === 0) return;
for (const [i, child] of node.children.entries()) { const isLast = i === node.children.length - 1; renderChild( child, isLast, [], width, guideStyle, defaultLabelStyle, dirSuffix, lines, ); }}
/** * Render a child node with a connector prefix. * `ancestorBars` tracks whether each ancestor level has a continuation bar (│). */function renderChild( node: TreeNode, isLast: boolean, ancestorBars: boolean[], width: number, guideStyle: (t: string) => string, defaultLabelStyle: (t: string) => string, dirSuffix: string, lines: string[],): void { // Build prefix: ancestor continuation bars + connector let prefix = ""; for (const hasBar of ancestorBars) { prefix += hasBar ? "│ " : " "; } prefix += isLast ? "└── " : "├── ";
const hasChildren = node.children && node.children.length > 0; const labelStr = resolveLabel(node.label, width); const label = hasChildren ? `${labelStr}${dirSuffix}` : labelStr; const styleFn = node.labelStyle ?? defaultLabelStyle;
const prefixVisible = visibleWidth(prefix); const available = Math.max(1, width - prefixVisible);
// Wrap the label, then indent continuation lines to align with label start const wrapIndent = " ".repeat(prefixVisible); const wrapped = wrapTextWithAnsi(label, available);
for (const [i, line] of wrapped.entries()) { if (i === 0) { lines.push(`${guideStyle(prefix)}${styleFn(line)}`); } else { lines.push(`${wrapIndent}${styleFn(line)}`); } }
if (!node.children || node.children.length === 0) return;
const childBars = [...ancestorBars, !isLast];
for (const [i, child] of node.children.entries()) { const childIsLast = i === node.children.length - 1; renderChild( child, childIsLast, childBars, width, guideStyle, defaultLabelStyle, dirSuffix, lines, ); }}
/** Resolve a string | Component label to a string. */function resolveLabel(label: string | Component, width: number): string { if (typeof label === "string") return label; return label.render(width).join(" ");}