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:
51
third_party/pi/packages/tui/src/components/alt-screen-flash.ts
vendored
Normal file
51
third_party/pi/packages/tui/src/components/alt-screen-flash.ts
vendored
Normal 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`;
|
||||
});
|
||||
}
|
||||
}
|
||||
167
third_party/pi/packages/tui/src/components/box.ts
vendored
Normal file
167
third_party/pi/packages/tui/src/components/box.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
40
third_party/pi/packages/tui/src/components/cancellable-loader.ts
vendored
Normal file
40
third_party/pi/packages/tui/src/components/cancellable-loader.ts
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
2461
third_party/pi/packages/tui/src/components/editor.ts
vendored
Normal file
2461
third_party/pi/packages/tui/src/components/editor.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
44
third_party/pi/packages/tui/src/components/h-stack.ts
vendored
Normal file
44
third_party/pi/packages/tui/src/components/h-stack.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
127
third_party/pi/packages/tui/src/components/image.ts
vendored
Normal file
127
third_party/pi/packages/tui/src/components/image.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
494
third_party/pi/packages/tui/src/components/input.ts
vendored
Normal file
494
third_party/pi/packages/tui/src/components/input.ts
vendored
Normal 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];
|
||||
}
|
||||
}
|
||||
101
third_party/pi/packages/tui/src/components/loader.ts
vendored
Normal file
101
third_party/pi/packages/tui/src/components/loader.ts
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
1015
third_party/pi/packages/tui/src/components/markdown.ts
vendored
Normal file
1015
third_party/pi/packages/tui/src/components/markdown.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
33
third_party/pi/packages/tui/src/components/mouse-region.ts
vendored
Normal file
33
third_party/pi/packages/tui/src/components/mouse-region.ts
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
224
third_party/pi/packages/tui/src/components/scroll-view.ts
vendored
Normal file
224
third_party/pi/packages/tui/src/components/scroll-view.ts
vendored
Normal 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 };
|
||||
}
|
||||
}
|
||||
273
third_party/pi/packages/tui/src/components/select-list.ts
vendored
Normal file
273
third_party/pi/packages/tui/src/components/select-list.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
328
third_party/pi/packages/tui/src/components/settings-list.ts
vendored
Normal file
328
third_party/pi/packages/tui/src/components/settings-list.ts
vendored
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
28
third_party/pi/packages/tui/src/components/spacer.ts
vendored
Normal file
28
third_party/pi/packages/tui/src/components/spacer.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
154
third_party/pi/packages/tui/src/components/stack.ts
vendored
Normal file
154
third_party/pi/packages/tui/src/components/stack.ts
vendored
Normal 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;
|
||||
}
|
||||
107
third_party/pi/packages/tui/src/components/text.ts
vendored
Normal file
107
third_party/pi/packages/tui/src/components/text.ts
vendored
Normal 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 : [""];
|
||||
}
|
||||
}
|
||||
65
third_party/pi/packages/tui/src/components/truncated-text.ts
vendored
Normal file
65
third_party/pi/packages/tui/src/components/truncated-text.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
33
third_party/pi/packages/tui/src/components/v-stack.ts
vendored
Normal file
33
third_party/pi/packages/tui/src/components/v-stack.ts
vendored
Normal 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";
|
||||
Reference in New Issue
Block a user