chore(vendor): import pi 0.85.1

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
2026-09-14 10:48:47 -04:00
parent 5ebc7018c3
commit 4def357d09
1669 changed files with 397627 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
import type { Component } from "../tui.ts";
import { truncateToWidth } from "../utils.ts";
const DEFAULT_DURATION_MS = 1000;
interface FlashEntry {
id: number;
message: string;
timer: NodeJS.Timeout;
}
/** Transient messages composited by the alternate-screen renderer. */
export class AltScreenFlashContainer implements Component {
private readonly entries: FlashEntry[] = [];
private nextId = 0;
private readonly requestRender: () => void;
constructor(requestRender: () => void) {
this.requestRender = requestRender;
}
flash(message: string, durationMs = DEFAULT_DURATION_MS): void {
const id = this.nextId++;
const timer = setTimeout(
() => {
const index = this.entries.findIndex((entry) => entry.id === id);
if (index === -1) return;
this.entries.splice(index, 1);
this.requestRender();
},
Math.max(0, durationMs),
);
timer.unref();
this.entries.push({ id, message, timer });
this.requestRender();
}
dispose(): void {
for (const entry of this.entries) clearTimeout(entry.timer);
this.entries.length = 0;
}
invalidate(): void {}
render(width: number): string[] {
return this.entries.map((entry) => {
const message = truncateToWidth(` ${entry.message} `, width, "");
return `\x1b[7m${message}\x1b[27m`;
});
}
}

View File

@@ -0,0 +1,167 @@
import { type Component, dispatchMouseEvent, type TuiMouseDispatchResult, type TuiMouseEvent } from "../tui.ts";
import { applyBackgroundToLine, visibleWidth } from "../utils.ts";
type RenderCache = {
childLines: string[];
width: number;
bgSample: string | undefined;
lines: string[];
};
/**
* Box component - a container that applies padding and background to all children
*/
export class Box implements Component {
children: Component[] = [];
private paddingX: number;
private paddingY: number;
private bgFn?: (text: string) => string;
// Cache for rendered output
private cache?: RenderCache;
private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> };
constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) {
this.paddingX = paddingX;
this.paddingY = paddingY;
this.bgFn = bgFn;
}
addChild(component: Component): void {
this.children.push(component);
this.invalidateCache();
}
removeChild(component: Component): void {
const index = this.children.indexOf(component);
if (index !== -1) {
this.children.splice(index, 1);
this.invalidateCache();
}
}
clear(): void {
this.children = [];
this.invalidateCache();
}
setBgFn(bgFn?: (text: string) => string): void {
this.bgFn = bgFn;
// Don't invalidate here - we'll detect bgFn changes by sampling output
}
private invalidateCache(): void {
this.cache = undefined;
}
private matchCache(width: number, childLines: string[], bgSample: string | undefined): boolean {
const cache = this.cache;
return (
!!cache &&
cache.width === width &&
cache.bgSample === bgSample &&
cache.childLines.length === childLines.length &&
cache.childLines.every((line, i) => line === childLines[i])
);
}
invalidate(): void {
this.invalidateCache();
for (const child of this.children) {
child.invalidate?.();
}
}
handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
const contentWidth = Math.max(1, event.width - this.paddingX * 2);
const contentY = event.y - this.paddingY;
const contentX = event.x - this.paddingX;
if (contentY < 0 || contentX < 0 || contentX >= contentWidth) return undefined;
const mouseChildren =
this.mouseLayout?.width === contentWidth
? this.mouseLayout.children
: this.children.map((component) => ({ component, height: component.render(contentWidth).length }));
let childY = 0;
for (const { component: child, height: childHeight } of mouseChildren) {
if (contentY >= childY && contentY < childY + childHeight) {
return dispatchMouseEvent(child, {
...event,
x: contentX,
y: contentY - childY,
width: contentWidth,
height: childHeight,
});
}
childY += childHeight;
}
return undefined;
}
render(width: number): string[] {
if (this.children.length === 0) {
return [];
}
const contentWidth = Math.max(1, width - this.paddingX * 2);
const leftPad = " ".repeat(this.paddingX);
// Render all children
const childLines: string[] = [];
const mouseChildren: Array<{ component: Component; height: number }> = [];
for (const child of this.children) {
const lines = child.render(contentWidth);
mouseChildren.push({ component: child, height: lines.length });
for (const line of lines) {
childLines.push(leftPad + line);
}
}
this.mouseLayout = { width: contentWidth, children: mouseChildren };
if (childLines.length === 0) {
return [];
}
// Check if bgFn output changed by sampling
const bgSample = this.bgFn ? this.bgFn("test") : undefined;
// Check cache validity
if (this.matchCache(width, childLines, bgSample)) {
return this.cache!.lines;
}
// Apply background and padding
const result: string[] = [];
// Top padding
for (let i = 0; i < this.paddingY; i++) {
result.push(this.applyBg("", width));
}
// Content
for (const line of childLines) {
result.push(this.applyBg(line, width));
}
// Bottom padding
for (let i = 0; i < this.paddingY; i++) {
result.push(this.applyBg("", width));
}
// Update cache
this.cache = { childLines, width, bgSample, lines: result };
return result;
}
private applyBg(line: string, width: number): string {
const visLen = visibleWidth(line);
const padNeeded = Math.max(0, width - visLen);
const padded = line + " ".repeat(padNeeded);
if (this.bgFn) {
return applyBackgroundToLine(padded, width, this.bgFn);
}
return padded;
}
}

View File

@@ -0,0 +1,40 @@
import { getKeybindings } from "../keybindings.ts";
import { Loader } from "./loader.ts";
/**
* Loader that can be cancelled with Escape.
* Extends Loader with an AbortSignal for cancelling async operations.
*
* @example
* const loader = new CancellableLoader(tui, cyan, dim, "Working...");
* loader.onAbort = () => done(null);
* doWork(loader.signal).then(done);
*/
export class CancellableLoader extends Loader {
private abortController = new AbortController();
/** Called when user presses Escape */
onAbort?: () => void;
/** AbortSignal that is aborted when user presses Escape */
get signal(): AbortSignal {
return this.abortController.signal;
}
/** Whether the loader was aborted */
get aborted(): boolean {
return this.abortController.signal.aborted;
}
handleInput(data: string): void {
const kb = getKeybindings();
if (kb.matches(data, "tui.select.cancel")) {
this.abortController.abort();
this.onAbort?.();
}
}
dispose(): void {
this.stop();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
import { compositeTuiLine } from "../tui.ts";
import { visibleWidth } from "../utils.ts";
import { allocateStackSizes, Stack, type StackChild, type StackOptions, visibleStackEntries } from "./stack.ts";
export class HStack extends Stack {
protected readonly layoutType = "hstack" as const;
constructor(children: StackChild[] = [], options: StackOptions = {}) {
super(children, options);
}
override render(width: number): string[] {
const safeWidth = Math.max(1, width);
const viewport = { width: safeWidth, height: Number.MAX_SAFE_INTEGER };
const entries = visibleStackEntries(this.entries, viewport);
if (entries.length === 0) return [];
const intrinsicWidths = entries.map((entry) => {
const lines = entry.component.render(safeWidth);
return lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
});
const widths = allocateStackSizes(entries, intrinsicWidths, safeWidth, this.gap);
const rendered = entries.map((entry, index) =>
widths[index] === 0 ? [] : entry.component.render(widths[index]!),
);
const height = rendered.reduce((max, lines) => Math.max(max, lines.length), 0);
const result = Array.from({ length: height }, () => "");
let x = 0;
for (let index = 0; index < rendered.length; index++) {
const lines = rendered[index]!;
const childWidth = widths[index]!;
let offset = 0;
if (this.align === "center") offset = Math.floor((height - lines.length) / 2);
else if (this.align === "end") offset = height - lines.length;
for (let row = 0; row < lines.length; row++) {
const target = row + offset;
if (target < 0 || target >= result.length) continue;
result[target] = compositeTuiLine(result[target]!, lines[row]!, x, childWidth, safeWidth);
}
x += childWidth + this.gap;
}
return result;
}
}

View File

@@ -0,0 +1,127 @@
import {
allocateImageId,
getCapabilities,
getCellDimensions,
getImageDimensions,
type ImageDimensions,
imageFallback,
renderImage,
} from "../terminal-image.ts";
import type { Component } from "../tui.ts";
import { truncateToWidth } from "../utils.ts";
export interface ImageTheme {
fallbackColor: (str: string) => string;
}
export interface ImageOptions {
maxWidthCells?: number;
maxHeightCells?: number;
filename?: string;
/** Kitty image ID. If provided, reuses this ID (for animations/updates). */
imageId?: number;
}
export class Image implements Component {
private base64Data: string;
private mimeType: string;
private dimensions: ImageDimensions;
private theme: ImageTheme;
private options: ImageOptions;
private imageId?: number;
private cachedLines?: string[];
private cachedWidth?: number;
constructor(
base64Data: string,
mimeType: string,
theme: ImageTheme,
options: ImageOptions = {},
dimensions?: ImageDimensions,
) {
this.base64Data = base64Data;
this.mimeType = mimeType;
this.theme = theme;
this.options = options;
this.dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 };
this.imageId = options.imageId;
}
/** Get the Kitty image ID used by this image (if any). */
getImageId(): number | undefined {
return this.imageId;
}
invalidate(): void {
this.cachedLines = undefined;
this.cachedWidth = undefined;
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
const maxWidth = Math.max(1, Math.min(width - 2, this.options.maxWidthCells ?? 60));
const cellDimensions = getCellDimensions();
const defaultMaxHeight = Math.max(1, Math.ceil((maxWidth * cellDimensions.widthPx) / cellDimensions.heightPx));
const maxHeight = this.options.maxHeightCells ?? defaultMaxHeight;
const caps = getCapabilities();
let lines: string[];
if (caps.images) {
if (caps.images === "kitty" && this.imageId === undefined) {
this.imageId = allocateImageId();
}
const result = renderImage(this.base64Data, this.dimensions, {
maxWidthCells: maxWidth,
maxHeightCells: maxHeight,
imageId: this.imageId,
moveCursor: false,
});
if (result) {
// Store the image ID for later cleanup
if (result.imageId) {
this.imageId = result.imageId;
}
if (caps.images === "kitty") {
// For Kitty: C=1 prevents cursor movement.
// Don't need the cursor movement.
lines = [result.sequence];
// Return `rows` lines so TUI accounts for image height.
for (let i = 0; i < result.rows - 1; i++) {
lines.push("");
}
} else {
// Return `rows` lines so TUI accounts for image height.
// First (rows-1) lines are empty and cleared before the image is drawn.
// Last line: move cursor back up, draw the image, then move back down
// so TUI cursor accounting stays inside the scroll area.
lines = [];
for (let i = 0; i < result.rows - 1; i++) {
lines.push("");
}
const rowOffset = result.rows - 1;
const moveUp = rowOffset > 0 ? `\x1b[${rowOffset}A` : "";
lines.push(moveUp + result.sequence);
}
} else {
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
lines = [truncateToWidth(this.theme.fallbackColor(fallback), width)];
}
} else {
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
lines = [truncateToWidth(this.theme.fallbackColor(fallback), width)];
}
this.cachedLines = lines;
this.cachedWidth = width;
return lines;
}
}

View File

@@ -0,0 +1,494 @@
import { getKeybindings } from "../keybindings.ts";
import { decodeKittyPrintable } from "../keys.ts";
import { KillRing } from "../kill-ring.ts";
import { type Component, CURSOR_MARKER, type Focusable, type TuiMouseEvent, type TuiMouseEventResult } from "../tui.ts";
import { UndoStack } from "../undo-stack.ts";
import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, truncateToWidth, visibleWidth } from "../utils.ts";
import { findWordBackward, findWordForward } from "../word-navigation.ts";
const segmenter = getGraphemeSegmenter();
interface InputState {
value: string;
cursor: number;
}
export interface InputOptions {
prompt?: string;
placeholder?: string;
placeholderStyle?: (text: string) => string;
}
/**
* Input component - single-line text input with horizontal scrolling
*/
export class Input implements Component, Focusable {
private value: string = "";
private cursor: number = 0; // Cursor position in the value
private readonly prompt: string;
private readonly placeholder: string;
private readonly placeholderStyle: (text: string) => string;
private renderedStartColumn = 0;
public onSubmit?: (value: string) => void;
public onEscape?: () => void;
/** Focusable interface - set by TUI when focus changes */
focused: boolean = false;
// Bracketed paste mode buffering
private pasteBuffer: string = "";
private isInPaste: boolean = false;
// Kill ring for Emacs-style kill/yank operations
private killRing = new KillRing();
private lastAction: "kill" | "yank" | "type-word" | null = null;
// Undo support
private undoStack = new UndoStack<InputState>();
constructor(options: InputOptions = {}) {
this.prompt = options.prompt ?? "> ";
this.placeholder = options.placeholder ?? "";
this.placeholderStyle = options.placeholderStyle ?? ((text) => text);
}
getValue(): string {
return this.value;
}
setValue(value: string): void {
this.value = value;
this.cursor = Math.min(this.cursor, value.length);
}
handleInput(data: string): void {
// Handle bracketed paste mode
// Start of paste: \x1b[200~
// End of paste: \x1b[201~
// Check if we're starting a bracketed paste
if (data.includes("\x1b[200~")) {
this.isInPaste = true;
this.pasteBuffer = "";
data = data.replace("\x1b[200~", "");
}
// If we're in a paste, buffer the data
if (this.isInPaste) {
// Check if this chunk contains the end marker
this.pasteBuffer += data;
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
if (endIndex !== -1) {
// Extract the pasted content
const pasteContent = this.pasteBuffer.substring(0, endIndex);
// Process the complete paste
this.handlePaste(pasteContent);
// Reset paste state
this.isInPaste = false;
// Handle any remaining input after the paste marker
const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~
this.pasteBuffer = "";
if (remaining) {
this.handleInput(remaining);
}
}
return;
}
const kb = getKeybindings();
// Escape/Cancel
if (kb.matches(data, "tui.select.cancel")) {
if (this.onEscape) this.onEscape();
return;
}
// Undo
if (kb.matches(data, "tui.editor.undo")) {
this.undo();
return;
}
// Submit
if (kb.matches(data, "tui.input.submit") || data === "\n") {
if (this.onSubmit) this.onSubmit(this.value);
return;
}
// Deletion
if (kb.matches(data, "tui.editor.deleteCharBackward")) {
this.handleBackspace();
return;
}
if (kb.matches(data, "tui.editor.deleteCharForward")) {
this.handleForwardDelete();
return;
}
if (kb.matches(data, "tui.editor.deleteWordBackward")) {
this.deleteWordBackwards();
return;
}
if (kb.matches(data, "tui.editor.deleteWordForward")) {
this.deleteWordForward();
return;
}
if (kb.matches(data, "tui.editor.deleteToLineStart")) {
this.deleteToLineStart();
return;
}
if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
this.deleteToLineEnd();
return;
}
// Kill ring actions
if (kb.matches(data, "tui.editor.yank")) {
this.yank();
return;
}
if (kb.matches(data, "tui.editor.yankPop")) {
this.yankPop();
return;
}
// Cursor movement
if (kb.matches(data, "tui.editor.cursorLeft")) {
this.lastAction = null;
if (this.cursor > 0) {
const beforeCursor = this.value.slice(0, this.cursor);
const graphemes = [...segmenter.segment(beforeCursor)];
const lastGrapheme = graphemes[graphemes.length - 1];
this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1;
}
return;
}
if (kb.matches(data, "tui.editor.cursorRight")) {
this.lastAction = null;
if (this.cursor < this.value.length) {
const afterCursor = this.value.slice(this.cursor);
const graphemes = [...segmenter.segment(afterCursor)];
const firstGrapheme = graphemes[0];
this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1;
}
return;
}
if (kb.matches(data, "tui.editor.cursorLineStart")) {
this.lastAction = null;
this.cursor = 0;
return;
}
if (kb.matches(data, "tui.editor.cursorLineEnd")) {
this.lastAction = null;
this.cursor = this.value.length;
return;
}
if (kb.matches(data, "tui.editor.cursorWordLeft")) {
this.moveWordBackwards();
return;
}
if (kb.matches(data, "tui.editor.cursorWordRight")) {
this.moveWordForwards();
return;
}
// Kitty CSI-u printable character (e.g. \x1b[97u for 'a').
// Terminals with Kitty protocol flag 1 (disambiguate) send CSI-u for all keys,
// including plain printable characters. Decode before the control-char check
// since CSI-u sequences contain \x1b which would be rejected.
const kittyPrintable = decodeKittyPrintable(data);
if (kittyPrintable !== undefined) {
this.insertCharacter(kittyPrintable);
return;
}
// Regular character input - accept printable characters including Unicode,
// but reject control characters (C0: 0x00-0x1F, DEL: 0x7F, C1: 0x80-0x9F)
const hasControlChars = [...data].some((ch) => {
const code = ch.charCodeAt(0);
return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
});
if (!hasControlChars) {
this.insertCharacter(data);
}
}
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if (event.type !== "press" || event.button !== "left" || event.y !== 0) return undefined;
const visibleColumn = Math.max(0, event.x - 2);
const targetColumn = this.renderedStartColumn + visibleColumn;
let currentColumn = 0;
this.cursor = this.value.length;
for (const grapheme of segmenter.segment(this.value)) {
const nextColumn = currentColumn + visibleWidth(grapheme.segment);
if (targetColumn < nextColumn) {
this.cursor = grapheme.index;
break;
}
currentColumn = nextColumn;
}
this.lastAction = null;
return { handled: true, focus: true };
}
private insertCharacter(char: string): void {
// Undo coalescing: consecutive word chars coalesce into one undo unit
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
this.pushUndo();
}
this.lastAction = "type-word";
this.value = this.value.slice(0, this.cursor) + char + this.value.slice(this.cursor);
this.cursor += char.length;
}
private handleBackspace(): void {
this.lastAction = null;
if (this.cursor > 0) {
this.pushUndo();
const beforeCursor = this.value.slice(0, this.cursor);
const graphemes = [...segmenter.segment(beforeCursor)];
const lastGrapheme = graphemes[graphemes.length - 1];
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
this.value = this.value.slice(0, this.cursor - graphemeLength) + this.value.slice(this.cursor);
this.cursor -= graphemeLength;
}
}
private handleForwardDelete(): void {
this.lastAction = null;
if (this.cursor < this.value.length) {
this.pushUndo();
const afterCursor = this.value.slice(this.cursor);
const graphemes = [...segmenter.segment(afterCursor)];
const firstGrapheme = graphemes[0];
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength);
}
}
private deleteToLineStart(): void {
if (this.cursor === 0) return;
this.pushUndo();
const deletedText = this.value.slice(0, this.cursor);
this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" });
this.lastAction = "kill";
this.value = this.value.slice(this.cursor);
this.cursor = 0;
}
private deleteToLineEnd(): void {
if (this.cursor >= this.value.length) return;
this.pushUndo();
const deletedText = this.value.slice(this.cursor);
this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" });
this.lastAction = "kill";
this.value = this.value.slice(0, this.cursor);
}
private deleteWordBackwards(): void {
if (this.cursor === 0) return;
// Save lastAction before cursor movement (moveWordBackwards resets it)
const wasKill = this.lastAction === "kill";
this.pushUndo();
const oldCursor = this.cursor;
this.moveWordBackwards();
const deleteFrom = this.cursor;
this.cursor = oldCursor;
const deletedText = this.value.slice(deleteFrom, this.cursor);
this.killRing.push(deletedText, { prepend: true, accumulate: wasKill });
this.lastAction = "kill";
this.value = this.value.slice(0, deleteFrom) + this.value.slice(this.cursor);
this.cursor = deleteFrom;
}
private deleteWordForward(): void {
if (this.cursor >= this.value.length) return;
// Save lastAction before cursor movement (moveWordForwards resets it)
const wasKill = this.lastAction === "kill";
this.pushUndo();
const oldCursor = this.cursor;
this.moveWordForwards();
const deleteTo = this.cursor;
this.cursor = oldCursor;
const deletedText = this.value.slice(this.cursor, deleteTo);
this.killRing.push(deletedText, { prepend: false, accumulate: wasKill });
this.lastAction = "kill";
this.value = this.value.slice(0, this.cursor) + this.value.slice(deleteTo);
}
private yank(): void {
const text = this.killRing.peek();
if (!text) return;
this.pushUndo();
this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor);
this.cursor += text.length;
this.lastAction = "yank";
}
private yankPop(): void {
if (this.lastAction !== "yank" || this.killRing.length <= 1) return;
this.pushUndo();
// Delete the previously yanked text (still at end of ring before rotation)
const prevText = this.killRing.peek() || "";
this.value = this.value.slice(0, this.cursor - prevText.length) + this.value.slice(this.cursor);
this.cursor -= prevText.length;
// Rotate and insert new entry
this.killRing.rotate();
const text = this.killRing.peek() || "";
this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor);
this.cursor += text.length;
this.lastAction = "yank";
}
private pushUndo(): void {
this.undoStack.push({ value: this.value, cursor: this.cursor });
}
private undo(): void {
const snapshot = this.undoStack.pop();
if (!snapshot) return;
this.value = snapshot.value;
this.cursor = snapshot.cursor;
this.lastAction = null;
}
private moveWordBackwards(): void {
if (this.cursor === 0) return;
this.lastAction = null;
this.cursor = findWordBackward(this.value, this.cursor);
}
private moveWordForwards(): void {
if (this.cursor >= this.value.length) return;
this.lastAction = null;
this.cursor = findWordForward(this.value, this.cursor);
}
private handlePaste(pastedText: string): void {
this.lastAction = null;
this.pushUndo();
// Clean the pasted text - remove newlines and carriage returns
const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "").replace(/\t/g, " ");
// Insert at cursor position
this.value = this.value.slice(0, this.cursor) + cleanText + this.value.slice(this.cursor);
this.cursor += cleanText.length;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
// Calculate visible window
const availableWidth = width - visibleWidth(this.prompt);
if (availableWidth <= 0) {
return [truncateToWidth(this.prompt, width, "")];
}
if (this.value.length === 0 && this.placeholder) {
const placeholder = truncateToWidth(this.placeholder, availableWidth, "");
const graphemes = [...segmenter.segment(placeholder)];
const atCursor = graphemes[0]?.segment ?? " ";
const afterCursor = placeholder.slice(atCursor.length);
const marker = this.focused ? CURSOR_MARKER : "";
const cursorChar = `\x1b[7m${this.placeholderStyle(atCursor)}\x1b[27m`;
const textWithCursor = marker + cursorChar + this.placeholderStyle(afterCursor);
const padding = " ".repeat(Math.max(0, availableWidth - visibleWidth(textWithCursor)));
return [this.prompt + textWithCursor + padding];
}
let visibleText = "";
let cursorDisplay = this.cursor;
this.renderedStartColumn = 0;
const totalWidth = visibleWidth(this.value);
if (totalWidth < availableWidth) {
// Everything fits (leave room for cursor at end)
visibleText = this.value;
} else {
// Need horizontal scrolling
// Reserve one column for cursor if it's at the end
const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth;
const cursorCol = visibleWidth(this.value.slice(0, this.cursor));
if (scrollWidth > 0) {
const halfWidth = Math.floor(scrollWidth / 2);
let startCol = 0;
if (cursorCol < halfWidth) {
// Cursor near start
startCol = 0;
} else if (cursorCol > totalWidth - halfWidth) {
// Cursor near end
startCol = Math.max(0, totalWidth - scrollWidth);
} else {
// Cursor in middle
startCol = Math.max(0, cursorCol - halfWidth);
}
this.renderedStartColumn = startCol;
visibleText = sliceByColumn(this.value, startCol, scrollWidth, true);
const beforeCursor = sliceByColumn(this.value, startCol, Math.max(0, cursorCol - startCol), true);
cursorDisplay = beforeCursor.length;
} else {
visibleText = "";
cursorDisplay = 0;
}
}
// Build line with fake cursor
// Insert cursor character at cursor position
const graphemes = [...segmenter.segment(visibleText.slice(cursorDisplay))];
const cursorGrapheme = graphemes[0];
const beforeCursor = visibleText.slice(0, cursorDisplay);
const atCursor = cursorGrapheme?.segment ?? " "; // Character at cursor, or space if at end
const afterCursor = visibleText.slice(cursorDisplay + atCursor.length);
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
const marker = this.focused ? CURSOR_MARKER : "";
// Use inverse video to show cursor
const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
const textWithCursor = beforeCursor + marker + cursorChar + afterCursor;
// Calculate visual width
const visualLength = visibleWidth(textWithCursor);
const padding = " ".repeat(Math.max(0, availableWidth - visualLength));
const line = this.prompt + textWithCursor + padding;
return [line];
}
}

View File

@@ -0,0 +1,101 @@
import type { TUI } from "../tui.ts";
import { Text } from "./text.ts";
export interface LoaderIndicatorOptions {
/** Animation frames. Use an empty array to hide the indicator. */
frames?: string[];
/** Frame interval in milliseconds for animated indicators. */
intervalMs?: number;
}
const DEFAULT_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const DEFAULT_INTERVAL_MS = 80;
/**
* Loader component that updates with an optional spinning animation.
*/
export class Loader extends Text {
private frames = [...DEFAULT_FRAMES];
private intervalMs = DEFAULT_INTERVAL_MS;
private currentFrame = 0;
private intervalId: NodeJS.Timeout | null = null;
private ui: TUI | null = null;
private renderIndicatorVerbatim = false;
private spinnerColorFn: (str: string) => string;
private messageColorFn: (str: string) => string;
private message: string = "Loading...";
constructor(
ui: TUI,
spinnerColorFn: (str: string) => string,
messageColorFn: (str: string) => string,
message: string = "Loading...",
indicator?: LoaderIndicatorOptions,
) {
super("", 1, 0);
this.ui = ui;
this.spinnerColorFn = spinnerColorFn;
this.messageColorFn = messageColorFn;
this.message = message;
this.setIndicator(indicator);
}
render(width: number): string[] {
return ["", ...super.render(width)];
}
start(): void {
this.updateDisplay();
this.restartAnimation();
}
stop(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
setMessage(message: string): void {
this.message = message;
this.updateDisplay();
}
override invalidate(): void {
super.invalidate();
this.updateDisplay();
}
setIndicator(indicator?: LoaderIndicatorOptions): void {
this.renderIndicatorVerbatim = indicator !== undefined;
this.frames = indicator?.frames !== undefined ? [...indicator.frames] : [...DEFAULT_FRAMES];
this.intervalMs = indicator?.intervalMs && indicator.intervalMs > 0 ? indicator.intervalMs : DEFAULT_INTERVAL_MS;
this.currentFrame = 0;
this.start();
}
private restartAnimation(): void {
this.stop();
if (this.frames.length <= 1) {
return;
}
this.intervalId = setInterval(() => {
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
this.updateDisplay();
}, this.intervalMs);
}
protected getRenderedIndicator(): string {
const frame = this.frames[this.currentFrame] ?? "";
return this.renderIndicatorVerbatim ? frame : this.spinnerColorFn(frame);
}
private updateDisplay(): void {
const renderedFrame = this.getRenderedIndicator();
const indicator = renderedFrame.length > 0 ? `${renderedFrame} ` : "";
this.setText(`${indicator}${this.messageColorFn(this.message)}`);
if (this.ui) {
this.ui.requestRender();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
import {
type Component,
dispatchMouseEvent,
type TuiMouseDispatchResult,
type TuiMouseEvent,
type TuiMouseEventResult,
} from "../tui.ts";
export type MouseRegionHandler = (event: TuiMouseEvent) => TuiMouseEventResult | undefined;
/** Adds mouse handling to an existing component without changing its rendering. */
export class MouseRegion implements Component {
private readonly child: Component;
private readonly onMouse: MouseRegionHandler;
constructor(child: Component, onMouse: MouseRegionHandler) {
this.child = child;
this.onMouse = onMouse;
}
render(width: number): string[] {
return this.child.render(width);
}
handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | TuiMouseEventResult | undefined {
const childResult = dispatchMouseEvent(this.child, event);
return childResult ?? this.onMouse(event);
}
invalidate(): void {
this.child.invalidate();
}
}

View File

@@ -0,0 +1,224 @@
import { LAYOUT_NODE, type ScrollLayoutNode } from "../layout-node.ts";
import { type Component, Container } from "../tui.ts";
export type ScrollViewScrollbar = "hidden" | "auto" | "always";
export interface ScrollViewOptions {
axis?: "vertical";
follow?: "none" | "end";
primary?: boolean;
overscroll?: "chain" | "contain";
scrollbar?: ScrollViewScrollbar;
scrollbarTrackStyle?: (text: string) => string;
scrollbarThumbStyle?: (text: string) => string;
scrollbarHideDelayMs?: number;
}
export interface ScrollViewScrollToOptions {
/** Keep follow-end disabled even when the target is the current content end. */
disableFollow?: boolean;
}
export class ScrollView extends Container {
private readonly child: Component;
readonly followEnd: boolean;
readonly primary: boolean;
readonly overscroll: "chain" | "contain";
readonly scrollbarTrackStyle: (text: string) => string;
readonly scrollbarThumbStyle: (text: string) => string;
private currentScrollbar: ScrollViewScrollbar;
private readonly scrollbarHideDelayMs: number;
private currentScrollTop = 0;
private contentHeight = 0;
private currentViewportHeight = 0;
private followingEnd: boolean;
private followSuppressedAtEnd = false;
private requestRenderCallback: (() => void) | undefined;
private transientScrollbarVisible = false;
private scrollbarActive = false;
private scrollbarHideTimer: NodeJS.Timeout | undefined;
constructor(component: Component, options: ScrollViewOptions = {}) {
super();
if (options.axis !== undefined && options.axis !== "vertical") {
throw new Error(`Unsupported ScrollView axis: ${options.axis}`);
}
this.child = component;
this.children.push(component);
this.followEnd = (options.follow ?? "none") === "end";
this.followingEnd = this.followEnd;
this.primary = options.primary ?? false;
this.overscroll = options.overscroll ?? "chain";
this.currentScrollbar = options.scrollbar ?? "hidden";
this.scrollbarTrackStyle = options.scrollbarTrackStyle ?? ((text) => `\x1b[90m${text}\x1b[39m`);
this.scrollbarThumbStyle = options.scrollbarThumbStyle ?? ((text) => `\x1b[37m${text}\x1b[39m`);
this.scrollbarHideDelayMs = Math.max(0, Math.floor(options.scrollbarHideDelayMs ?? 1000));
}
get scrollTop(): number {
return this.currentScrollTop;
}
get isFollowingEnd(): boolean {
return this.followingEnd;
}
get viewportHeight(): number {
return this.currentViewportHeight;
}
get scrollbar(): ScrollViewScrollbar {
return this.currentScrollbar;
}
get isScrollbarVisible(): boolean {
if (this.scrollbar === "always") return this.currentViewportHeight > 0;
return (
this.scrollbar === "auto" && this.contentHeight > this.currentViewportHeight && this.transientScrollbarVisible
);
}
get isScrollbarActive(): boolean {
return this.scrollbarActive;
}
setScrollbar(scrollbar: ScrollViewScrollbar): void {
if (scrollbar === this.currentScrollbar) return;
this.currentScrollbar = scrollbar;
if (scrollbar !== "auto") this.hideTransientScrollbar();
else if (this.scrollbarActive) this.markScrollbarActivity();
this.requestRenderCallback?.();
}
getContentWidth(width: number): number {
return this.scrollbar === "always" && width > 1 ? width - 1 : width;
}
private markScrollbarActivity(): void {
if (this.scrollbar !== "auto" || this.contentHeight <= this.currentViewportHeight) return;
this.transientScrollbarVisible = true;
if (this.scrollbarHideTimer) {
clearTimeout(this.scrollbarHideTimer);
this.scrollbarHideTimer = undefined;
}
if (this.scrollbarActive) return;
this.scrollbarHideTimer = setTimeout(() => {
this.scrollbarHideTimer = undefined;
this.transientScrollbarVisible = false;
this.requestRenderCallback?.();
}, this.scrollbarHideDelayMs);
this.scrollbarHideTimer.unref();
}
private hideTransientScrollbar(): void {
this.transientScrollbarVisible = false;
if (!this.scrollbarHideTimer) return;
clearTimeout(this.scrollbarHideTimer);
this.scrollbarHideTimer = undefined;
}
setScrollbarActive(active: boolean): void {
if (active === this.scrollbarActive) return;
this.scrollbarActive = active;
this.markScrollbarActivity();
this.requestRenderCallback?.();
}
scrollTo(scrollTop: number, options: ScrollViewScrollToOptions = {}): void {
const requested = Number.isFinite(scrollTop) ? Math.trunc(scrollTop) : this.currentScrollTop;
const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight);
const next = Math.max(0, Math.min(maxScrollTop, requested));
const nextFollowSuppressedAtEnd = options.disableFollow === true && next === maxScrollTop;
const nextFollowingEnd = !nextFollowSuppressedAtEnd && this.followEnd && next === maxScrollTop;
if (
next === this.currentScrollTop &&
nextFollowingEnd === this.followingEnd &&
nextFollowSuppressedAtEnd === this.followSuppressedAtEnd
) {
return;
}
const moved = next !== this.currentScrollTop;
this.currentScrollTop = next;
this.followingEnd = nextFollowingEnd;
this.followSuppressedAtEnd = nextFollowSuppressedAtEnd;
if (moved) this.markScrollbarActivity();
this.requestRenderCallback?.();
}
scrollBy(lines: number): number {
const requested = Number.isFinite(lines) ? Math.trunc(lines) : 0;
if (requested === 0) return 0;
const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight);
const start = this.followingEnd ? maxScrollTop : this.currentScrollTop;
const next = Math.max(0, Math.min(maxScrollTop, start + requested));
const moved = next - start;
const wasFollowingEnd = this.followingEnd;
this.currentScrollTop = next;
this.followingEnd = this.followEnd && next === maxScrollTop;
this.followSuppressedAtEnd = false;
if (moved !== 0) this.markScrollbarActivity();
if (moved !== 0 || this.followingEnd !== wasFollowingEnd) this.requestRenderCallback?.();
return requested - moved;
}
scrollToStart(): void {
const changed =
this.currentScrollTop !== 0 ||
this.followingEnd !== (this.followEnd && this.contentHeight <= this.currentViewportHeight);
this.currentScrollTop = 0;
this.followingEnd = this.followEnd && this.contentHeight <= this.currentViewportHeight;
this.followSuppressedAtEnd = false;
if (changed) {
this.markScrollbarActivity();
this.requestRenderCallback?.();
}
}
scrollToEnd(): void {
const next = Math.max(0, this.contentHeight - this.currentViewportHeight);
const changed = this.currentScrollTop !== next || this.followingEnd !== this.followEnd;
this.currentScrollTop = next;
this.followingEnd = this.followEnd;
this.followSuppressedAtEnd = false;
if (changed) {
this.markScrollbarActivity();
this.requestRenderCallback?.();
}
}
updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void {
this.contentHeight = Math.max(0, Math.floor(contentHeight));
this.currentViewportHeight = Math.max(0, Math.floor(viewportHeight));
this.requestRenderCallback = requestRender;
const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight);
if (this.followingEnd) this.currentScrollTop = maxScrollTop;
else this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop));
if (this.currentScrollTop < maxScrollTop) this.followSuppressedAtEnd = false;
if (this.followEnd && this.currentScrollTop === maxScrollTop && !this.followSuppressedAtEnd) {
this.followingEnd = true;
}
if (this.contentHeight <= this.currentViewportHeight) this.hideTransientScrollbar();
}
override addChild(_component: Component): void {
throw new Error("ScrollView has exactly one child");
}
override removeChild(_component: Component): void {
throw new Error("ScrollView child cannot be removed");
}
override clear(): void {
throw new Error("ScrollView child cannot be cleared");
}
override render(width: number): string[] {
const contentWidth = this.getContentWidth(width);
const lines = this.child.render(contentWidth);
return contentWidth === width ? lines : lines.map((line) => `${line} `);
}
[LAYOUT_NODE](): ScrollLayoutNode {
return { type: "scroll", component: this.child, state: this };
}
}

View File

@@ -0,0 +1,273 @@
import { getKeybindings } from "../keybindings.ts";
import type { Component, TuiMouseEvent, TuiMouseEventResult } from "../tui.ts";
import { truncateToWidth, visibleWidth } from "../utils.ts";
const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
const PRIMARY_COLUMN_GAP = 2;
const MIN_DESCRIPTION_WIDTH = 10;
const normalizeToSingleLine = (text: string): string => text.replace(/[\r\n]+/g, " ").trim();
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max));
export interface SelectItem {
value: string;
label: string;
description?: string;
}
export interface SelectListTheme {
selectedPrefix: (text: string) => string;
selectedText: (text: string) => string;
description: (text: string) => string;
scrollInfo: (text: string) => string;
noMatch: (text: string) => string;
}
export interface SelectListTruncatePrimaryContext {
text: string;
maxWidth: number;
columnWidth: number;
item: SelectItem;
isSelected: boolean;
}
export interface SelectListLayoutOptions {
minPrimaryColumnWidth?: number;
maxPrimaryColumnWidth?: number;
truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
}
export class SelectList implements Component {
private items: SelectItem[] = [];
private filteredItems: SelectItem[] = [];
private selectedIndex: number = 0;
private mousePressedIndex: number | undefined;
private maxVisible: number = 5;
private theme: SelectListTheme;
private layout: SelectListLayoutOptions;
public onSelect?: (item: SelectItem) => void;
public onCancel?: () => void;
public onSelectionChange?: (item: SelectItem) => void;
constructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme, layout: SelectListLayoutOptions = {}) {
this.items = items;
this.filteredItems = items;
this.maxVisible = maxVisible;
this.theme = theme;
this.layout = layout;
}
setFilter(filter: string): void {
this.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase()));
// Reset selection when filter changes
this.selectedIndex = 0;
}
setSelectedIndex(index: number): void {
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const lines: string[] = [];
// If no items match filter, show message
if (this.filteredItems.length === 0) {
lines.push(this.theme.noMatch(" No matching commands"));
return lines;
}
const primaryColumnWidth = this.getPrimaryColumnWidth();
// Calculate visible range with scrolling
const { startIndex, endIndex } = this.getVisibleRange();
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
const item = this.filteredItems[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
const descriptionSingleLine = item.description ? normalizeToSingleLine(item.description) : undefined;
lines.push(this.renderItem(item, isSelected, width, descriptionSingleLine, primaryColumnWidth));
}
// Add scroll indicators if needed
if (startIndex > 0 || endIndex < this.filteredItems.length) {
const scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`;
// Truncate if too long for terminal
lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, "")));
}
return lines;
}
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if (this.filteredItems.length === 0) return undefined;
if (event.type === "wheel" && event.wheelDelta) {
const delta = event.wheelDelta < 0 ? -1 : 1;
const previousIndex = this.selectedIndex;
this.selectedIndex = Math.max(0, Math.min(this.filteredItems.length - 1, this.selectedIndex + delta));
if (this.selectedIndex !== previousIndex) this.notifySelectionChange();
return { handled: true, render: this.selectedIndex !== previousIndex };
}
// Hover must not change selection: the visible range is centered on it.
if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
const { startIndex, endIndex } = this.getVisibleRange();
const itemIndex = startIndex + event.y;
if (itemIndex < startIndex || itemIndex >= endIndex) return undefined;
if (event.type === "press") {
this.mousePressedIndex = itemIndex;
if (this.selectedIndex !== itemIndex) {
this.selectedIndex = itemIndex;
this.notifySelectionChange();
}
return { handled: true, focus: true };
}
if (event.type === "click") {
const clickedIndex = this.mousePressedIndex ?? itemIndex;
this.mousePressedIndex = undefined;
const changed = this.selectedIndex !== clickedIndex;
this.selectedIndex = clickedIndex;
if (changed) this.notifySelectionChange();
const selectedItem = this.filteredItems[this.selectedIndex];
if (selectedItem) this.onSelect?.(selectedItem);
return { handled: true };
}
return undefined;
}
handleInput(keyData: string): void {
const kb = getKeybindings();
// Up arrow - wrap to bottom when at top
if (kb.matches(keyData, "tui.select.up")) {
this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;
this.notifySelectionChange();
}
// Down arrow - wrap to top when at bottom
else if (kb.matches(keyData, "tui.select.down")) {
this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;
this.notifySelectionChange();
}
// Enter
else if (kb.matches(keyData, "tui.select.confirm")) {
const selectedItem = this.filteredItems[this.selectedIndex];
if (selectedItem && this.onSelect) {
this.onSelect(selectedItem);
}
}
// Escape or Ctrl+C
else if (kb.matches(keyData, "tui.select.cancel")) {
if (this.onCancel) {
this.onCancel();
}
}
}
private getVisibleRange(): { startIndex: number; endIndex: number } {
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
);
return {
startIndex,
endIndex: Math.min(startIndex + this.maxVisible, this.filteredItems.length),
};
}
private renderItem(
item: SelectItem,
isSelected: boolean,
width: number,
descriptionSingleLine: string | undefined,
primaryColumnWidth: number,
): string {
const prefix = isSelected ? "→ " : " ";
const prefixWidth = visibleWidth(prefix);
if (descriptionSingleLine && width > 40) {
const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));
const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP);
const truncatedValue = this.truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth);
const truncatedValueWidth = visibleWidth(truncatedValue);
const spacing = " ".repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth));
const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length;
const remainingWidth = width - descriptionStart - 2; // -2 for safety
if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, "");
if (isSelected) {
return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
}
const descText = this.theme.description(spacing + truncatedDesc);
return prefix + truncatedValue + descText;
}
}
const maxWidth = width - prefixWidth - 2;
const truncatedValue = this.truncatePrimary(item, isSelected, maxWidth, maxWidth);
if (isSelected) {
return this.theme.selectedText(`${prefix}${truncatedValue}`);
}
return prefix + truncatedValue;
}
private getPrimaryColumnWidth(): number {
const { min, max } = this.getPrimaryColumnBounds();
const widestPrimary = this.filteredItems.reduce((widest, item) => {
return Math.max(widest, visibleWidth(this.getDisplayValue(item)) + PRIMARY_COLUMN_GAP);
}, 0);
return clamp(widestPrimary, min, max);
}
private getPrimaryColumnBounds(): { min: number; max: number } {
const rawMin =
this.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
const rawMax =
this.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
return {
min: Math.max(1, Math.min(rawMin, rawMax)),
max: Math.max(1, Math.max(rawMin, rawMax)),
};
}
private truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string {
const displayValue = this.getDisplayValue(item);
const truncatedValue = this.layout.truncatePrimary
? this.layout.truncatePrimary({
text: displayValue,
maxWidth,
columnWidth,
item,
isSelected,
})
: truncateToWidth(displayValue, maxWidth, "");
return truncateToWidth(truncatedValue, maxWidth, "");
}
private getDisplayValue(item: SelectItem): string {
return item.label || item.value;
}
private notifySelectionChange(): void {
const selectedItem = this.filteredItems[this.selectedIndex];
if (selectedItem && this.onSelectionChange) {
this.onSelectionChange(selectedItem);
}
}
getSelectedItem(): SelectItem | null {
const item = this.filteredItems[this.selectedIndex];
return item || null;
}
}

View File

@@ -0,0 +1,328 @@
import { fuzzyFilter } from "../fuzzy.ts";
import { getKeybindings } from "../keybindings.ts";
import type { Component, TuiMouseEvent, TuiMouseEventResult } from "../tui.ts";
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
import { Input } from "./input.ts";
export interface SettingItem {
/** Unique identifier for this setting */
id: string;
/** Display label (left side) */
label: string;
/** Optional description shown when selected */
description?: string;
/** Current value to display (right side) */
currentValue: string;
/** If provided, Enter/Space cycles through these values */
values?: string[];
/** If provided, Enter opens this submenu. Receives current value and done callback.
* done() accepts an optional selectedValue and an optional navigateTo id to move the cursor after close. */
submenu?: (
currentValue: string,
done: (selectedValue?: string, options?: { navigateTo?: string }) => void,
) => Component;
}
export interface SettingsListTheme {
label: (text: string, selected: boolean) => string;
value: (text: string, selected: boolean) => string;
description: (text: string) => string;
cursor: string;
hint: (text: string) => string;
}
export interface SettingsListOptions {
enableSearch?: boolean;
}
export class SettingsList implements Component {
private items: SettingItem[];
private filteredItems: SettingItem[];
private theme: SettingsListTheme;
private selectedIndex = 0;
private mousePressedIndex: number | undefined;
private maxVisible: number;
private onChange: (id: string, newValue: string) => void;
private onCancel: () => void;
private searchInput?: Input;
private searchEnabled: boolean;
// Submenu state
private submenuComponent: Component | null = null;
private submenuItemIndex: number | null = null;
private navigateAfterClose: string | null = null;
constructor(
items: SettingItem[],
maxVisible: number,
theme: SettingsListTheme,
onChange: (id: string, newValue: string) => void,
onCancel: () => void,
options: SettingsListOptions = {},
) {
this.items = items;
this.filteredItems = items;
this.maxVisible = maxVisible;
this.theme = theme;
this.onChange = onChange;
this.onCancel = onCancel;
this.searchEnabled = options.enableSearch ?? false;
if (this.searchEnabled) {
this.searchInput = new Input();
}
}
/** Update an item's currentValue */
updateValue(id: string, newValue: string): void {
const item = this.items.find((i) => i.id === id);
if (item) {
item.currentValue = newValue;
}
}
/** Move selection to the item with the given id (no-op if not found). */
selectItem(id: string): void {
const items = this.searchEnabled ? this.filteredItems : this.items;
const index = items.findIndex((i) => i.id === id);
if (index !== -1) {
this.selectedIndex = index;
}
}
invalidate(): void {
this.submenuComponent?.invalidate?.();
}
render(width: number): string[] {
// If submenu is active, render it instead
if (this.submenuComponent) {
return this.submenuComponent.render(width);
}
return this.renderMainList(width);
}
private renderMainList(width: number): string[] {
const lines: string[] = [];
if (this.searchEnabled && this.searchInput) {
lines.push(...this.searchInput.render(width));
lines.push("");
}
if (this.items.length === 0) {
lines.push(this.theme.hint(" No settings available"));
if (this.searchEnabled) {
this.addHintLine(lines, width);
}
return lines;
}
const displayItems = this.getDisplayItems();
if (displayItems.length === 0) {
lines.push(truncateToWidth(this.theme.hint(" No matching settings"), width));
this.addHintLine(lines, width);
return lines;
}
// Calculate visible range with scrolling
const { startIndex, endIndex } = this.getVisibleRange(displayItems);
// Calculate max label width for alignment
const maxLabelWidth = Math.min(36, Math.max(...this.items.map((item) => visibleWidth(item.label))));
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
const item = displayItems[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
const prefix = isSelected ? this.theme.cursor : " ";
const prefixWidth = visibleWidth(prefix);
// Pad label to align values
const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
const labelText = this.theme.label(labelPadded, isSelected);
// Calculate space for value
const separator = " ";
const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
const valueMaxWidth = width - usedWidth - 2;
const valueText = this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected);
lines.push(truncateToWidth(prefix + labelText + separator + valueText, width));
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < displayItems.length) {
const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;
lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, "")));
}
// Add description for selected item
const selectedItem = displayItems[this.selectedIndex];
if (selectedItem?.description) {
lines.push("");
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
for (const line of wrappedDesc) {
lines.push(this.theme.description(` ${line}`));
}
}
// Add hint
this.addHintLine(lines, width);
return lines;
}
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if (this.submenuComponent) {
const result = this.submenuComponent.handleMouse?.(event);
return result ? { ...result, focus: true } : undefined;
}
if (this.searchEnabled && this.searchInput) {
if (event.y === 0) {
const result = this.searchInput.handleMouse?.(event);
return result ? { ...result, focus: true } : undefined;
}
if (event.y === 1) return undefined;
}
const displayItems = this.getDisplayItems();
if (displayItems.length === 0) return undefined;
if (event.type === "wheel" && event.wheelDelta) {
const delta = event.wheelDelta < 0 ? -1 : 1;
const previousIndex = this.selectedIndex;
this.selectedIndex = Math.max(0, Math.min(displayItems.length - 1, this.selectedIndex + delta));
return { handled: true, render: this.selectedIndex !== previousIndex };
}
// Hover must not change selection: the visible range is centered on it.
if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
const rowOffset = this.searchEnabled ? 2 : 0;
const { startIndex, endIndex } = this.getVisibleRange(displayItems);
const itemIndex = startIndex + event.y - rowOffset;
if (itemIndex < startIndex || itemIndex >= endIndex) return undefined;
if (event.type === "press") {
this.mousePressedIndex = itemIndex;
this.selectedIndex = itemIndex;
return { handled: true, focus: true };
}
if (event.type === "click") {
this.selectedIndex = this.mousePressedIndex ?? itemIndex;
this.mousePressedIndex = undefined;
this.activateItem();
return { handled: true };
}
return undefined;
}
handleInput(data: string): void {
// If submenu is active, delegate all input to it
// The submenu's onCancel (triggered by escape) will call done() which closes it
if (this.submenuComponent) {
this.submenuComponent.handleInput?.(data);
return;
}
// Main list input handling
const kb = getKeybindings();
const displayItems = this.getDisplayItems();
if (kb.matches(data, "tui.select.up")) {
if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
} else if (kb.matches(data, "tui.select.down")) {
if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;
} else if (
kb.matches(data, "tui.select.confirm") ||
(data === " " && (!this.searchEnabled || this.searchInput?.getValue().length === 0))
) {
this.activateItem();
} else if (kb.matches(data, "tui.select.cancel")) {
this.onCancel();
} else if (this.searchEnabled && this.searchInput) {
this.searchInput.handleInput(data);
this.applyFilter(this.searchInput.getValue());
}
}
private getDisplayItems(): SettingItem[] {
return this.searchEnabled ? this.filteredItems : this.items;
}
private getVisibleRange(displayItems: readonly SettingItem[]): { startIndex: number; endIndex: number } {
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible),
);
return { startIndex, endIndex: Math.min(startIndex + this.maxVisible, displayItems.length) };
}
private activateItem(): void {
const item = this.getDisplayItems()[this.selectedIndex];
if (!item) return;
if (item.submenu) {
// Open submenu, passing current value so it can pre-select correctly
this.submenuItemIndex = this.selectedIndex;
this.submenuComponent = item.submenu(
item.currentValue,
(selectedValue?: string, options?: { navigateTo?: string }) => {
if (selectedValue !== undefined) {
item.currentValue = selectedValue;
this.onChange(item.id, selectedValue);
}
if (options?.navigateTo) {
this.navigateAfterClose = options.navigateTo;
}
this.closeSubmenu();
},
);
} else if (item.values && item.values.length > 0) {
// Cycle through values
const currentIndex = item.values.indexOf(item.currentValue);
const nextIndex = (currentIndex + 1) % item.values.length;
const newValue = item.values[nextIndex];
item.currentValue = newValue;
this.onChange(item.id, newValue);
}
}
private closeSubmenu(): void {
this.submenuComponent = null;
if (this.navigateAfterClose !== null) {
const id = this.navigateAfterClose;
this.navigateAfterClose = null;
this.submenuItemIndex = null;
this.selectItem(id);
// Open the target item's submenu automatically
this.activateItem();
} else if (this.submenuItemIndex !== null) {
// Restore selection to the item that opened the submenu
this.selectedIndex = this.submenuItemIndex;
this.submenuItemIndex = null;
}
}
private applyFilter(query: string): void {
this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label);
this.selectedIndex = 0;
}
private addHintLine(lines: string[], width: number): void {
lines.push("");
lines.push(
truncateToWidth(
this.theme.hint(
this.searchEnabled
? " Type to search · Enter/Space to change · Esc to cancel"
: " Enter/Space to change · Esc to cancel",
),
width,
),
);
}
}

View File

@@ -0,0 +1,28 @@
import type { Component } from "../tui.ts";
/**
* Spacer component that renders empty lines
*/
export class Spacer implements Component {
private lines: number;
constructor(lines: number = 1) {
this.lines = lines;
}
setLines(lines: number): void {
this.lines = lines;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(_width: number): string[] {
const result: string[] = [];
for (let i = 0; i < this.lines; i++) {
result.push("");
}
return result;
}
}

View File

@@ -0,0 +1,154 @@
import { LAYOUT_NODE, type LayoutViewport, type StackLayoutEntry, type StackLayoutNode } from "../layout-node.ts";
import { type Component, Container } from "../tui.ts";
export interface StackEntryOptions {
basis?: number | "auto";
grow?: number;
shrink?: number;
minSize?: number;
maxSize?: number;
visible?: (viewport: LayoutViewport) => boolean;
}
export interface StackEntry extends StackEntryOptions {
component: Component;
}
export type StackChild = Component | StackEntry;
export interface StackOptions {
gap?: number;
align?: "stretch" | "start" | "center" | "end";
}
function isStackEntry(child: StackChild): child is StackEntry {
return !("render" in child);
}
function normalizeSize(value: number | undefined, fallback: number): number {
return value === undefined || !Number.isFinite(value) ? fallback : Math.max(0, Math.floor(value));
}
export abstract class Stack extends Container {
protected readonly entries: StackLayoutEntry[] = [];
protected readonly gap: number;
protected readonly align: "stretch" | "start" | "center" | "end";
protected abstract readonly layoutType: "vstack" | "hstack";
constructor(children: StackChild[] = [], options: StackOptions = {}) {
super();
this.gap = normalizeSize(options.gap, 0);
this.align = options.align ?? "stretch";
for (const child of children) {
if (isStackEntry(child)) this.addChild(child.component, child);
else this.addChild(child);
}
}
override addChild(component: Component, options: StackEntryOptions = {}): void {
super.addChild(component);
this.entries.push({
component,
...(options.basis === undefined ? {} : { basis: options.basis }),
...(options.grow === undefined ? {} : { grow: normalizeSize(options.grow, 0) }),
...(options.shrink === undefined ? {} : { shrink: normalizeSize(options.shrink, 1) }),
...(options.minSize === undefined ? {} : { minSize: normalizeSize(options.minSize, 0) }),
...(options.maxSize === undefined ? {} : { maxSize: normalizeSize(options.maxSize, Number.MAX_SAFE_INTEGER) }),
...(options.visible === undefined ? {} : { visible: options.visible }),
});
}
override removeChild(component: Component): void {
super.removeChild(component);
const index = this.entries.findIndex((entry) => entry.component === component);
if (index !== -1) this.entries.splice(index, 1);
}
override clear(): void {
super.clear();
this.entries.length = 0;
}
[LAYOUT_NODE](): StackLayoutNode {
return {
type: this.layoutType,
entries: this.entries,
gap: this.gap,
align: this.align,
};
}
}
export function visibleStackEntries(
entries: readonly StackLayoutEntry[],
viewport: LayoutViewport,
): StackLayoutEntry[] {
return entries.filter((entry) => entry.visible?.(viewport) ?? true);
}
function clampSize(size: number, entry: StackLayoutEntry): number {
const min = Math.max(0, Math.floor(entry.minSize ?? 0));
const max = Math.max(min, Math.floor(entry.maxSize ?? Number.MAX_SAFE_INTEGER));
return Math.max(min, Math.min(max, Math.max(0, Math.floor(size))));
}
function distribute(
sizes: number[],
entries: readonly StackLayoutEntry[],
amount: number,
mode: "grow" | "shrink",
): void {
let remaining = amount;
while (remaining > 0) {
const candidates = entries
.map((entry, index) => ({ entry, index }))
.filter(({ entry, index }) => {
if (mode === "grow") {
return (entry.grow ?? 0) > 0 && sizes[index]! < (entry.maxSize ?? Number.MAX_SAFE_INTEGER);
}
return (entry.shrink ?? 1) > 0 && sizes[index]! > (entry.minSize ?? 0);
});
if (candidates.length === 0) return;
const totalWeight = candidates.reduce((sum, { entry, index }) => {
return sum + (mode === "grow" ? (entry.grow ?? 0) : (entry.shrink ?? 1) * Math.max(1, sizes[index]!));
}, 0);
let distributed = 0;
for (const { entry, index } of candidates) {
if (remaining <= 0) break;
const weight = mode === "grow" ? (entry.grow ?? 0) : (entry.shrink ?? 1) * Math.max(1, sizes[index]!);
const proposed = Math.max(1, Math.floor((remaining * weight) / totalWeight));
const capacity =
mode === "grow"
? (entry.maxSize ?? Number.MAX_SAFE_INTEGER) - sizes[index]!
: sizes[index]! - (entry.minSize ?? 0);
const delta = Math.min(remaining, proposed, capacity);
if (delta <= 0) continue;
sizes[index] = sizes[index]! + (mode === "grow" ? delta : -delta);
remaining -= delta;
distributed += delta;
}
if (distributed === 0) return;
}
}
export function allocateStackSizes(
entries: readonly StackLayoutEntry[],
intrinsicSizes: readonly number[],
availableSize: number | undefined,
gap: number,
): number[] {
const sizes = entries.map((entry, index) =>
clampSize(
entry.basis === undefined || entry.basis === "auto" ? (intrinsicSizes[index] ?? 0) : entry.basis,
entry,
),
);
if (availableSize === undefined) return sizes;
const contentSize = Math.max(0, Math.floor(availableSize) - Math.max(0, entries.length - 1) * gap);
const total = sizes.reduce((sum, size) => sum + size, 0);
if (total < contentSize) distribute(sizes, entries, contentSize - total, "grow");
else if (total > contentSize) distribute(sizes, entries, total - contentSize, "shrink");
return sizes;
}

View File

@@ -0,0 +1,107 @@
import type { Component } from "../tui.ts";
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
/**
* Text component - displays multi-line text with word wrapping
*/
export class Text implements Component {
private text: string;
private paddingX: number; // Left/right padding
private paddingY: number; // Top/bottom padding
private customBgFn?: (text: string) => string;
// Cache for rendered output
private cachedText?: string;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
this.text = text;
this.paddingX = paddingX;
this.paddingY = paddingY;
this.customBgFn = customBgFn;
}
setText(text: string): void {
this.text = text;
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
setCustomBgFn(customBgFn?: (text: string) => string): void {
this.customBgFn = customBgFn;
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
invalidate(): void {
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
render(width: number): string[] {
// Check cache
if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
return this.cachedLines;
}
// Don't render anything if there's no actual text
if (!this.text || this.text.trim() === "") {
const result: string[] = [];
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result;
}
// Replace tabs with 3 spaces
const normalizedText = this.text.replace(/\t/g, " ");
// Reduce margins when necessary so content and padding fit within the available width.
const paddingX = Math.min(this.paddingX, Math.max(0, Math.floor((width - 1) / 2)));
const contentWidth = Math.max(1, width - paddingX * 2);
// Wrap text (this preserves ANSI codes but does NOT pad)
const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
// Add margins and background to each line
const leftMargin = " ".repeat(paddingX);
const rightMargin = " ".repeat(paddingX);
const contentLines: string[] = [];
for (const line of wrappedLines) {
// Add margins
const lineWithMargins = leftMargin + line + rightMargin;
// Apply background if specified (this also pads to full width)
if (this.customBgFn) {
contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgFn));
} else {
// No background - just pad to width with spaces
const visibleLen = visibleWidth(lineWithMargins);
const paddingNeeded = Math.max(0, width - visibleLen);
contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
}
}
// Add top/bottom padding (empty lines)
const emptyLine = " ".repeat(width);
const emptyLines: string[] = [];
for (let i = 0; i < this.paddingY; i++) {
const line = this.customBgFn ? applyBackgroundToLine(emptyLine, width, this.customBgFn) : emptyLine;
emptyLines.push(line);
}
const result = [...emptyLines, ...contentLines, ...emptyLines];
// Update cache
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result.length > 0 ? result : [""];
}
}

View File

@@ -0,0 +1,65 @@
import type { Component } from "../tui.ts";
import { truncateToWidth, visibleWidth } from "../utils.ts";
/**
* Text component that truncates to fit viewport width
*/
export class TruncatedText implements Component {
private text: string;
private paddingX: number;
private paddingY: number;
constructor(text: string, paddingX: number = 0, paddingY: number = 0) {
this.text = text;
this.paddingX = paddingX;
this.paddingY = paddingY;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const result: string[] = [];
// Empty line padded to width
const emptyLine = " ".repeat(width);
// Add vertical padding above
for (let i = 0; i < this.paddingY; i++) {
result.push(emptyLine);
}
// Calculate available width after horizontal padding
const availableWidth = Math.max(1, width - this.paddingX * 2);
// Take only the first line (stop at newline)
let singleLineText = this.text;
const newlineIndex = this.text.indexOf("\n");
if (newlineIndex !== -1) {
singleLineText = this.text.substring(0, newlineIndex);
}
// Truncate text if needed (accounting for ANSI codes)
const displayText = truncateToWidth(singleLineText, availableWidth);
// Add horizontal padding
const leftPadding = " ".repeat(this.paddingX);
const rightPadding = " ".repeat(this.paddingX);
const lineWithPadding = leftPadding + displayText + rightPadding;
// Pad line to exactly width characters
const lineVisibleWidth = visibleWidth(lineWithPadding);
const paddingNeeded = Math.max(0, width - lineVisibleWidth);
const finalLine = lineWithPadding + " ".repeat(paddingNeeded);
result.push(finalLine);
// Add vertical padding below
for (let i = 0; i < this.paddingY; i++) {
result.push(emptyLine);
}
return result;
}
}

View File

@@ -0,0 +1,33 @@
import { allocateStackSizes, Stack, type StackChild, type StackOptions, visibleStackEntries } from "./stack.ts";
export class VStack extends Stack {
protected readonly layoutType = "vstack" as const;
constructor(children: StackChild[] = [], options: StackOptions = {}) {
super(children, options);
}
override render(width: number): string[] {
const viewport = { width: Math.max(1, width), height: Number.MAX_SAFE_INTEGER };
const entries = visibleStackEntries(this.entries, viewport);
const rendered = entries.map((entry) => entry.component.render(viewport.width));
const sizes = allocateStackSizes(
entries,
rendered.map((lines) => lines.length),
undefined,
this.gap,
);
const lines: string[] = [];
for (let index = 0; index < entries.length; index++) {
if (index > 0) {
for (let gap = 0; gap < this.gap; gap++) lines.push("");
}
const childLines = rendered[index]!.slice(0, sizes[index]);
lines.push(...childLines);
for (let padding = childLines.length; padding < sizes[index]!; padding++) lines.push("");
}
return lines;
}
}
export type { StackChild, StackEntry, StackEntryOptions, StackOptions } from "./stack.ts";