Last active
October 7, 2026 22:04
-
-
Save cowboyd/112a9833e91a89b426a92cd7ab981d5b to your computer and use it in GitHub Desktop.
Hypothetical TUI Component Syntax
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { | |
| ClackElement, | |
| Focusable, | |
| type TemplateExpansion, | |
| type KeyDown, | |
| type KeyRepeat, | |
| fit, | |
| percent, | |
| reflects, | |
| template, | |
| } from "@clack/ui"; | |
| import { text } from "./03-text.ts"; | |
| import { box } from "./02-box.ts"; | |
| export interface InputAttributes { | |
| value?: string; | |
| } | |
| export class InputElement extends Focusable(ClackElement<InputAttributes>) { | |
| // map element attributes, to instance properties. | |
| // this can be done with less noise using decorators, | |
| // but then that would require decorators, so this is a lowest | |
| // common denominator | |
| // - sets(): one way bind from attr -> prop | |
| // - reads(): one way bind from prop -> attr | |
| // - reflects(): two way bind, so attr === prop | |
| static attributes = { | |
| value: reflects("value"), | |
| }; | |
| private _value = ""; | |
| // Internal editing state: a Unicode code-point offset, not a terminal column. | |
| caret = 0; | |
| get value(): string { | |
| return this._value; | |
| } | |
| set value(next: string) { | |
| // remove newlines and carriage return line feeds (those are text Area only) | |
| const value = next.replace(/[\r\n]/g, ""); | |
| this._value = value; | |
| // If the new value is smaller, then adjust the caret. | |
| this.caret = Math.max(0, Math.min(this.caret, [...value].length)); | |
| } | |
| // Focusable supplies this.focused property and also hooks in these methods. No event registration or cleanup is necessary | |
| keydown(event: KeyDown): void { | |
| if (!this.edit(event)) { | |
| return super.keydown(event); | |
| } | |
| } | |
| keyrepeat(event: KeyRepeat): void { | |
| if (!this.edit(event)) { | |
| return super.keyrepeat(event); | |
| } | |
| } | |
| edit(event: KeyDown | KeyRepeat): boolean { | |
| if (event.text && event.text?.length > 0) { | |
| this.insert(event.text); | |
| return true; | |
| } else if (event.code === 'Backspace') { | |
| this.deleteBackward(); | |
| return true; | |
| } else if (event.code === 'Delete') { | |
| this.deleteForward(); | |
| return true; | |
| } else if (event.code === 'ArrowLeft') { | |
| this.moveBack(); | |
| return true; | |
| } else if (event.code === 'ArrowRight') { | |
| this.moveForward(); | |
| return true; | |
| } else if (event.code === 'Home') { | |
| this.moveMin(); | |
| return true; | |
| } else if (event.code === 'End') { | |
| this.moveMax(); | |
| return true; | |
| } | |
| return false; | |
| } | |
| insert(text: string): void { | |
| this.update(() => { | |
| // Inputs are single-line, so discard CR/LF. Spreading the string produces | |
| // Unicode code points rather than UTF-16 code units. | |
| const inserted = [...text.replace(/[\r\n]/g, '')]; | |
| const codepoints = [...this.value]; | |
| const caret = this.caret; | |
| // The caret is a code-point index. Insert each new code point independently, | |
| // then advance by exactly the number that survived normalization. | |
| codepoints.splice(caret, 0, ...inserted); | |
| this.value = codepoints.join(''); | |
| this.caret = caret + inserted.length; | |
| }); | |
| } | |
| deleteBackward(): void { | |
| this.update(() => { | |
| const codepoints = [...this.value]; | |
| let caret = this.caret; | |
| const length = codepoints.length; | |
| if (caret > length) { | |
| console.warn('caret mismatch: ${caret}, value length: ${length}'); | |
| caret = length; | |
| } | |
| if (caret > 0) { | |
| codepoints.splice(caret - 1, 1); | |
| this.value = codepoints.join(''); | |
| this.caret = caret - 1; | |
| } | |
| }); | |
| } | |
| deleteForward(): void { | |
| this.update(() => { | |
| const codepoints = [...this.value]; | |
| let caret = this.caret; | |
| const length = codepoints.length; | |
| if (caret > length) { | |
| console.warn('caret mismatch: ${caret}, value length: ${length}'); | |
| caret = length; | |
| } | |
| if (caret < length) { | |
| codepoints.splice(caret, 1); | |
| this.value = codepoints.join(''); | |
| } | |
| }); | |
| } | |
| moveBack(): void { | |
| const caret = this.caret; | |
| this.caret = Math.max(0, caret - 1); | |
| } | |
| moveForward(): void { | |
| const caret = this.caret; | |
| const codepoints = [...this.value]; | |
| this.caret = Math.min(codepoints.length, caret + 1); | |
| } | |
| moveMin(): void { | |
| this.caret = 0; | |
| } | |
| moveMax(): void { | |
| this.caret = [...this.value].length; | |
| } | |
| update<T>(fn: () => T): T { | |
| const original = this.value; | |
| const result = fn(); | |
| if (original !== this.value) { | |
| this.emit({ | |
| type: 'input', | |
| value: this.value, | |
| }); | |
| } | |
| return result; | |
| } | |
| render() { | |
| const shade = this.focused ? 255 : 100; | |
| const color = { r: shade, g: shade, b: shade, a: 255 }; | |
| const border = { color, top: 1, right: 1, bottom: 1, left: 1 }; | |
| const layout = { | |
| height: fit(3), | |
| width: percent(0.3), | |
| padding: { top: 1, right: 1, bottom: 1, left: 1 }, | |
| }; | |
| const clip = { horizontal: true }; | |
| const wrap = "none"; | |
| const caret = this.focused ? this.caret : undefined; | |
| return box({ border, layout, clip }, [ | |
| text({ wrap, caret }, this.value), | |
| ]); | |
| } | |
| } | |
| // use <input> elements in a template | |
| export const input = template(InputElement); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { ClackElement, type RGBA, template, tty, sets } from "@clack/ui"; | |
| export type BoxSize = | |
| | { type: "fit"; min?: number; max?: number } | |
| | { type: "grow"; min?: number; max?: number } | |
| | { type: "percent"; value: number } // 0.3 means 30%. | |
| | { type: "fixed"; value: number }; // Terminal cells. | |
| export interface BoxInsets { | |
| top?: number; | |
| right?: number; | |
| bottom?: number; | |
| left?: number; | |
| } | |
| export interface BoxLayout { | |
| // Both dimensions default to fitting their contents. | |
| width?: BoxSize; | |
| height?: BoxSize; | |
| // Padding and gap are in cells, defaulting to zero. | |
| padding?: BoxInsets; | |
| gap?: number; | |
| direction?: "row" | "column"; // Defaults to "row". | |
| alignX?: "left" | "center" | "right"; // Defaults to "left". | |
| alignY?: "top" | "center" | "bottom"; // Defaults to "top". | |
| } | |
| export interface BoxBorder { | |
| color: RGBA; | |
| background?: RGBA; | |
| // Each side is enabled independently; omitted or zero means no border. | |
| top?: number; | |
| right?: number; | |
| bottom?: number; | |
| left?: number; | |
| } | |
| export interface BoxCorners { | |
| topLeft?: number; | |
| topRight?: number; | |
| bottomLeft?: number; | |
| bottomRight?: number; | |
| } | |
| export interface BoxClip { | |
| horizontal?: boolean; | |
| vertical?: boolean; | |
| } | |
| export interface BoxAttributes { | |
| layout?: BoxLayout; | |
| background?: RGBA; | |
| border?: BoxBorder; | |
| cornerRadius?: BoxCorners; | |
| clip?: BoxClip; | |
| } | |
| export class BoxElement extends ClackElement<BoxAttributes> { | |
| layoutConfig: BoxLayout = {}; | |
| background: RGBA | undefined; | |
| border: BoxBorder | undefined; | |
| cornerRadius: BoxCorners | undefined; | |
| clip: BoxClip | undefined; | |
| // map element attributes, to instance properties. | |
| // this can be done with less noise using decorators, | |
| // but then that would require decorators, so this is a lowest | |
| // common denominator | |
| // - sets(): one way bind from attr -> prop | |
| // - reads(): one way bind from prop -> attr | |
| // - reflects(): two way bind, so attr === prop | |
| static attributes = { | |
| layout: sets("layoutConfig"), | |
| background: sets("background"), | |
| border: sets("border"), | |
| cornerRadius: sets("cornerRadius"), | |
| clip: sets("clip"), | |
| }; | |
| // Layout is lower level and runs _after_ a tree has been rendered, and converts it into a flat iteration of TTY operations | |
| // pretty much only <box> and <text> will ever use this, unless tty gets another, low level element. | |
| // Most element will use a render() method to define the tree that is laid out. | |
| *layout(): Iterator<TTYOp> { | |
| const layout = this.layoutConfig ?? {}; | |
| const border = this.border; | |
| const corners = this.cornerRadius; | |
| // tty `OPEN` directive | |
| yield tty.open(this.id, { | |
| layout: { | |
| width: layout.width, | |
| height: layout.height, | |
| padding: layout.padding, | |
| gap: layout.gap, | |
| direction: layout.direction === "column" ? "ttb" : "ltr", | |
| alignX: layout.alignX, | |
| alignY: layout.alignY, | |
| }, | |
| bg: this.background === undefined | |
| ? undefined | |
| : tty.color(this.background), | |
| border: border === undefined ? undefined : { | |
| color: tty.color(border.color), | |
| bg: border.background === undefined | |
| ? undefined | |
| : tty.color(border.background), | |
| top: border.top, | |
| right: border.right, | |
| bottom: border.bottom, | |
| left: border.left, | |
| }, | |
| cornerRadius: corners === undefined ? undefined : { | |
| tl: corners.topLeft, | |
| tr: corners.topRight, | |
| bl: corners.bottomLeft, | |
| br: corners.bottomRight, | |
| }, | |
| clip: this.clip === undefined ? undefined : { | |
| horizontal: this.clip.horizontal, | |
| vertical: this.clip.vertical, | |
| }, | |
| }); | |
| // base layout just lays out children, contributing nothing itself. | |
| yield* super.layout(); | |
| // tty `CLOSE` directive | |
| yield tty.close(); | |
| } | |
| } | |
| export const box = template(BoxElement); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { ClackElement, type RGBA, type TTYOp, template, tty, sets } from "@clack/ui"; | |
| export type TextWrap = | |
| | "word" // Break at whitespace to fit available width. | |
| | "newline" // Break only at explicit newlines. | |
| | "none"; // Disable wrapping. | |
| export interface TextAttributes { | |
| color?: RGBA; | |
| background?: RGBA; | |
| // Defaults to "word". | |
| wrap?: TextWrap; | |
| // All default to false. | |
| bold?: boolean; | |
| dim?: boolean; | |
| italic?: boolean; | |
| underline?: boolean; | |
| blink?: boolean; | |
| inverse?: boolean; | |
| strikethrough?: boolean; | |
| // Unicode code-point offset, not a terminal column or UTF-16 index. | |
| // Undefined means this text does not declare a caret. | |
| caret?: number; | |
| } | |
| export class TextElement extends ClackElement<TextAttributes> { | |
| color: RGBA | undefined; | |
| background: RGBA | undefined; | |
| wrap: TextWrap = "word"; | |
| bold = false; | |
| dim = false; | |
| italic = false; | |
| underline = false; | |
| blink = false; | |
| inverse = false; | |
| strikethrough = false; | |
| caret: number | undefined = undefined; | |
| // map element attributes, to instance properties. | |
| // this can be done with less noise using decorators, | |
| // but then that would require decorators, so this is a lowest | |
| // common denominator | |
| // - sets(): one way bind from attr -> prop | |
| // - reads(): one way bind from prop -> attr | |
| // - reflects(): two way bind, so attr === prop | |
| static attributes = { | |
| color: sets("color"), | |
| background: sets("background"), | |
| wrap: sets("wrap"), | |
| bold: sets("bold"), | |
| dim: sets("dim"), | |
| italic: sets("italic"), | |
| underline: sets("underline"), | |
| blink: sets("blink"), | |
| inverse: sets("inverse"), | |
| strikethrough: sets("strikethrough"), | |
| caret: sets("caret"), | |
| }; | |
| get content(): string { | |
| let content = ""; | |
| for (const child of this.children) { | |
| if (child.type === "literal") { | |
| content += child.content; | |
| } | |
| } | |
| return content; | |
| } | |
| // Layout is lower level and runs _after_ a tree has been rendered, and converts it into a flat iteration of TTY operations | |
| // pretty much only <box> and <text> will ever use this, unless tty gets another, low level element. | |
| // Most element will use a render() method to define the tree that is laid out. | |
| *layout(): Iterator<TTYOp> { | |
| const attrs = | |
| (this.bold ? 0x01 : 0) | | |
| (this.dim ? 0x02 : 0) | | |
| (this.italic ? 0x04 : 0) | | |
| (this.underline ? 0x08 : 0) | | |
| (this.blink ? 0x10 : 0) | | |
| (this.inverse ? 0x20 : 0) | | |
| (this.strikethrough ? 0x40 : 0); | |
| const wrap = this.wrap ?? "word"; | |
| yield tty.text(this.content, { | |
| color: this.color === undefined ? undefined : tty.color(this.color), | |
| bg: this.background === undefined ? undefined : tty.color(this.background), | |
| wrap: wrap === "word" ? 0 : wrap === "newline" ? 1 : 2, | |
| attrs, | |
| // Requires the caret-capable backend; not in installed tty 0.7.0. | |
| caret: this.caret, | |
| }); | |
| } | |
| } | |
| // this makes it so that you can use it from a template in other elements | |
| export const text = template(TextElement); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment