Skip to content

Instantly share code, notes, and snippets.

@cowboyd
Last active October 7, 2026 22:04
Show Gist options
  • Select an option

  • Save cowboyd/112a9833e91a89b426a92cd7ab981d5b to your computer and use it in GitHub Desktop.

Select an option

Save cowboyd/112a9833e91a89b426a92cd7ab981d5b to your computer and use it in GitHub Desktop.
Hypothetical TUI Component Syntax
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);
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);
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