web: add rendered terminal examples to docs (#1446)

Show actual OpenTUI output beside documentation examples so readers
can see component behavior without running an application.
This commit is contained in:
Simon Klee
2026-08-26 21:04:05 +02:00
committed by GitHub
parent 33203b5029
commit 7a0dca8d5e
46 changed files with 14283 additions and 53 deletions
+1
View File
@@ -234,6 +234,7 @@
"astro": "^7.1.6",
},
"devDependencies": {
"@opentui/qrcode": "workspace:*",
"@types/bun": "1.3.14",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/headless": "^5.5.0",
+9
View File
@@ -7,6 +7,15 @@ const copyButtonTransformer = {
pre(node) {
node.properties["data-code"] = this.source
if (this.options?.lang) node.properties["data-language"] = this.options.lang
if (this.options?.lang === "text") {
const metadata = this.options.meta?.__raw ?? ""
const visual = metadata.match(/(?:^|\s)terminal=([a-z0-9-]+)(?=\s|$)/)
if (visual) {
node.properties["data-terminal-visual"] = visual[1]
if (/(?:^|\s)surface(?=\s|$)/.test(metadata)) node.properties["data-terminal-surface"] = true
}
}
},
}
+3 -1
View File
@@ -11,13 +11,14 @@
"optimize:video": "bash scripts/optimize-landing-video.sh",
"recordings:record": "bash scripts/recordings/bin/record",
"recordings:build": "bun scripts/recordings/bin/build",
"docs:visuals": "bun scripts/generate-doc-visuals.ts",
"scaffold": "bun scripts/test-doc-examples.ts",
"validate:docs:metadata": "bun scripts/validate-doc-metadata.ts",
"validate:docs:links": "bun scripts/validate-doc-links.ts",
"validate:docs:skill": "bun scripts/validate-skill-docs.ts",
"validate:docs:examples": "bun scripts/verify-doc-examples.ts",
"validate:docs": "bun run validate:docs:metadata && bun run validate:docs:links && bun run validate:docs:skill && bun run test:docs",
"test:docs": "bun test src/lib/docs-index.test.ts src/lib/docs-search.test.ts scripts/doc-validator.test.ts",
"test:docs": "bun test src/lib/docs-index.test.ts src/lib/docs-search.test.ts scripts/doc-validator.test.ts scripts/doc-visuals.test.ts scripts/terminal-illustration.test.ts",
"validate:packages": "bun scripts/validate-packages.ts src/content/packages",
"test:packages": "bun test src/lib/package-facts.test.ts src/lib/package-schema.test.ts src/lib/remote-package-facts.test.ts"
},
@@ -29,6 +30,7 @@
"astro": "^7.1.6"
},
"devDependencies": {
"@opentui/qrcode": "workspace:*",
"@types/bun": "1.3.14",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/headless": "^5.5.0",
+287
View File
@@ -0,0 +1,287 @@
import { readFile, readdir } from "node:fs/promises"
import { LineNumberRenderable, RGBA, rgbToHex } from "@opentui/core"
import { expect, test } from "bun:test"
import visuals from "../src/data/doc-visuals.json"
import { renderDocVisual } from "./doc-visuals/shared"
import { structuredVisuals } from "./doc-visuals/structured"
import { generateDocVisuals } from "./generate-doc-visuals"
test("documentation visual preserves native terminal geometry and color intent", async () => {
const generated = await generateDocVisuals()
const frame = generated["box-borders"]
expect(JSON.stringify(generated)).toBe(JSON.stringify(visuals))
expect(frame.cols).toBe(38)
expect(frame.rows).toBe(7)
for (const visual of Object.values(generated)) {
expect(visual.lines).toHaveLength(visual.rows)
for (const line of visual.lines) {
expect(line.reduce((width, span) => width + span.width, 0)).toBe(visual.cols)
}
if (visual.cursor) {
expect(visual.cursor.column).toBeGreaterThanOrEqual(0)
expect(visual.cursor.column).toBeLessThan(visual.cols)
expect(visual.cursor.row).toBeGreaterThanOrEqual(0)
expect(visual.cursor.row).toBeLessThan(visual.rows)
}
}
const spans = frame.lines.flat()
expect(spans.some((span) => span.text.includes("single") && frame.colors[span.foreground].intent === "default")).toBe(
true,
)
expect(spans.every((span) => frame.colors[span.background].intent === "default")).toBe(true)
expect(spans.some((span) => span.text === "single line" && frame.colors[span.foreground].slot === 244)).toBe(true)
const embedded = generated["embedded-terminal-vt"]
const terminalSpans = embedded.lines.flat()
expect(terminalSpans.every((span) => embedded.colors[span.background].intent === "default")).toBe(true)
expect(
terminalSpans.some(
(span) => span.text.includes("bun test") && embedded.colors[span.foreground].intent === "default",
),
).toBe(true)
expect(terminalSpans.some((span) => span.text.includes("$") && embedded.colors[span.foreground].slot === 244)).toBe(
true,
)
expect(generated["frame-buffer-draw"].label).toContain("42 MB/s")
expect(generated["frame-buffer-draw"].label).toContain("18 MB/s")
expect(generated["frame-buffer-progress"].label).toContain("70%")
expect(generated["interaction-selection-focus"].label).toContain("deploy --check")
expect(generated["text-cell-ruler"].label).toContain("three cells")
expect(generated["text-cell-ruler"].label).toContain("columns 1 and 2")
for (const id of ["input-focused", "textarea-selection", "interaction-selection-focus"]) {
expect(generated[id].cursor).not.toBeNull()
}
function backgroundAt(visual: typeof frame, x: number, y: number) {
let column = 0
for (const span of visual.lines[y]) {
column += span.width
if (column > x) return visual.colors[span.background]
}
throw new Error(`Missing cell at ${x}, ${y}`)
}
const created = generated["renderable-created"]
const mutated = generated["renderable-mutated"]
expect([created.cols, mutated.cols]).toEqual([18, 30])
expect(created.lines[1].map((span) => span.text).join("")).toContain("Waiting")
expect(mutated.lines[1].map((span) => span.text).join("")).toContain("Ready")
const beforeMove = generated["renderable-reparent-before"]
const afterMove = generated["renderable-reparent-after"]
expect(backgroundAt(beforeMove, 2, 1).slot).toBe(243)
expect(backgroundAt(beforeMove, 20, 1).intent).toBe("default")
expect(backgroundAt(afterMove, 2, 1).intent).toBe("default")
expect(backgroundAt(afterMove, 20, 1).slot).toBe(243)
expect(generated["renderable-visibility"].lines[5].map((span) => span.text).join("")).toContain(
"children: 2 children: 2 children: 1",
)
const chunks = generated["text-styled-chunks"].lines.flat()
expect(chunks.find((span) => span.text === "Status")?.attributes).toBe(1)
expect(chunks.find((span) => span.text === "Note")?.attributes).toBe(4)
expect(chunks.find((span) => span.text === "Next")?.attributes).toBe(9)
const ruler = generated["text-cell-ruler"]
expect(ruler.lines[1].map((span) => span.text).join("")).toContain("ABC| 3 cells")
expect(ruler.lines[2].map((span) => span.text).join("")).toContain("A\u754cB| 4 cells")
expect(ruler.lines[2].find((span) => span.text === "\u754c")?.width).toBe(2)
expect(ruler.lines[2].find((span) => span.text === "B")?.width).toBe(1)
expect(backgroundAt(ruler, 11, 2).slot).toBe(235)
expect(backgroundAt(ruler, 12, 2).intent).toBe("default")
const offsets = generated["text-line-offsets"]
expect(backgroundAt(offsets, 0, 3).slot).toBe(238)
expect(backgroundAt(offsets, 19, 3).slot).toBe(238)
expect(offsets.lines[4].map((span) => span.text).join("")).toContain("range [3, 4) range [4, 5)")
const hits = generated["interaction-hit-bubbling"]
expect(hits.lines[7].map((span) => span.text).join("")).toContain("target: front")
expect(hits.lines[8].map((span) => span.text).join("")).toContain("bubble: front -> parent")
expect(hits.lines[9].map((span) => span.text).join("")).toContain("stopped: front")
const drag = generated["interaction-drag-selection"]
expect(drag.lines[3].map((span) => span.text).join("")).toContain('Selected: "the app\\nTest"')
expect(drag.lines[4].map((span) => span.text).join("")).toContain("Range: [6, 18)")
const spacing = generated["layout-flex-columns"]
for (let x = 0; x < spacing.cols; x++) {
const shaded = (x >= 2 && x < 10) || (x >= 12 && x < 32)
expect(backgroundAt(spacing, x, 2).slot).toBe(shaded ? 238 : undefined)
}
const alignment = generated["layout-alignment"]
for (const [x, y] of [
[2, 3],
[7, 3],
[13, 2],
[18, 4],
[24, 5],
[29, 5],
]) {
expect(backgroundAt(alignment, x, y).slot).toBe(238)
}
for (const [x, y] of [
[2, 2],
[13, 1],
[24, 3],
]) {
expect(backgroundAt(alignment, x, y).intent).toBe("default")
}
const rounding = generated["layout-cell-rounding"]
for (const [row, widths] of [
[2, [10, 10, 10]],
[6, [10, 11, 10]],
] as const) {
let left = 1
for (const [index, width] of widths.entries()) {
for (let x = left; x < left + width; x++) {
expect(backgroundAt(rounding, x, row).slot).toBe(index === 1 ? 235 : 238)
}
left += width
}
}
const wide = generated["layout-wrap-wide"]
const narrow = generated["layout-wrap-narrow"]
expect([wide.cols, wide.rows]).toEqual([32, 4])
expect([narrow.cols, narrow.rows]).toEqual([22, 6])
expect(wide.lines[2].map((span) => span.text).join("")).toContain("C")
expect(narrow.lines[2].map((span) => span.text).join("")).not.toContain("C")
expect(narrow.lines[4].map((span) => span.text).join("")).toContain("C")
expect(backgroundAt(wide, 22, 2).slot).toBe(238)
expect(backgroundAt(narrow, 2, 4).slot).toBe(238)
const palette = generated["color-palette"]
for (let index = 0; index < 256; index++) {
const red = Math.floor((index - 16) / 36)
const green = Math.floor((index - 16) / 6) % 6
const blue = (index - 16) % 6
const x = index < 16 ? index * 2 : index < 232 ? (red % 3) * 13 + blue * 2 : index - 232
const y = index < 16 ? 1 : index < 232 ? 5 + Math.floor(red / 3) * 8 + green : 21
const color = backgroundAt(palette, x, y)
expect(color.intent).toBe("rgb")
expect(color.value).toBe(rgbToHex(RGBA.fromIndex(index)))
}
const alpha = generated["color-alpha"]
for (const [index, [light, dark]] of [
["#e0e0e0", "#a0a0a0"],
["#b0d9bf", "#80a98f"],
["#81d29f", "#61b37f"],
["#52cc7f", "#42bc6f"],
["#22c55e", "#22c55e"],
].entries()) {
expect(backgroundAt(alpha, index * 7, 0).value).toBe(light)
expect(backgroundAt(alpha, index * 7 + 2, 0).value).toBe(dark)
}
const directory = new URL("../src/content/docs/", import.meta.url)
const files = (await readdir(directory, { recursive: true })).filter((path) => path.endsWith(".mdx"))
const referenced = new Set<string>()
for (const path of files) {
const source = await readFile(new URL(path, directory), "utf8")
for (const match of source.matchAll(/```text terminal=([a-z0-9-]+)(?:[^\S\n]+[^\n]*)?\n([\s\S]*?)\n```/g)) {
const visual = generated[match[1]]
expect(visual).toBeDefined()
const text = visual.lines
.map((line) =>
line
.map((span) => span.text)
.join("")
.trimEnd(),
)
.join("\n")
expect(match[2]).toBe(text)
referenced.add(match[1])
}
}
expect([...referenced].toSorted()).toEqual(Object.keys(generated).toSorted())
})
test("failed visual setup restores renderer globals and runs registered cleanup", async () => {
const originalWindow = globalThis.window
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
let cleaned = false
await expect(
renderDocVisual({
id: "failed-visual",
label: "Failed visual",
width: 8,
height: 2,
render(setup, registerCleanup) {
registerCleanup(() => {
cleaned = setup.renderer.isDestroyed
})
throw new Error("Fixture initialization failed")
},
}),
).rejects.toThrow("Fixture initialization failed")
expect(cleaned).toBe(true)
expect(globalThis.window).toBe(originalWindow)
expect(globalThis.requestAnimationFrame).toBe(originalRequestAnimationFrame)
expect(globalThis.cancelAnimationFrame).toBe(originalCancelAnimationFrame)
})
test("every registered visual cleanup runs when another cleanup fails", async () => {
const calls: string[] = []
await expect(
renderDocVisual({
id: "failed-cleanup",
label: "Failed cleanup",
width: 8,
height: 2,
render(setup, registerCleanup) {
registerCleanup(() => {
calls.push(setup.renderer.isDestroyed ? "first" : "renderer still active")
})
registerCleanup(() => {
calls.push("second")
throw new Error("Cleanup failed")
})
registerCleanup(() => {
calls.push("third")
})
},
}),
).rejects.toThrow("Cleanup failed")
expect(calls).toEqual(["third", "second", "first"])
})
test("detached line-number visuals destroy their owned children after setup failure", async () => {
const setLineSign = LineNumberRenderable.prototype.setLineSign
let gutter: LineNumberRenderable | undefined
let children: Array<{ isDestroyed: boolean }> = []
LineNumberRenderable.prototype.setLineSign = function (this: LineNumberRenderable) {
gutter = this
children = this.getChildren()
throw new Error("Line sign initialization failed")
}
try {
const fixture = structuredVisuals.find((visual) => visual.id === "line-number-editor")!
await expect(renderDocVisual(fixture)).rejects.toThrow("Line sign initialization failed")
} finally {
LineNumberRenderable.prototype.setLineSign = setLineSign
}
expect(gutter?.isDestroyed).toBe(true)
expect(children).toHaveLength(2)
expect(children.every((child) => child.isDestroyed)).toBe(true)
})
+221
View File
@@ -0,0 +1,221 @@
import {
BoxRenderable,
InputRenderable,
RGBA,
SelectRenderable,
TabSelectRenderable,
TextareaRenderable,
TextRenderable,
} from "@opentui/core"
import type { DocVisualFixture } from "./shared"
const foreground = RGBA.defaultForeground()
const background = RGBA.defaultBackground()
const muted = RGBA.fromIndex(244)
const selection = RGBA.fromIndex(238)
export const formVisuals: DocVisualFixture[] = [
{
id: "input-placeholder",
label: "An unfocused name input displaying the placeholder Enter your name",
width: 28,
height: 2,
render({ renderer }) {
const field = new BoxRenderable(renderer, { width: 28, height: 2 })
field.add(new TextRenderable(renderer, { content: "Name", fg: muted, bg: background }))
field.add(
new InputRenderable(renderer, {
width: 28,
placeholder: "Enter your name",
placeholderColor: muted,
backgroundColor: background,
focusedBackgroundColor: selection,
textColor: foreground,
focusedTextColor: foreground,
cursorColor: foreground,
}),
)
renderer.root.add(field)
},
},
{
id: "input-focused",
label: "A focused name input containing Ada Lovelace with its cursor visible",
width: 28,
height: 2,
cursor: true,
render({ renderer, mockInput }) {
const field = new BoxRenderable(renderer, { width: 28, height: 2 })
const input = new InputRenderable(renderer, {
width: 28,
placeholder: "Enter your name",
placeholderColor: muted,
backgroundColor: background,
focusedBackgroundColor: selection,
textColor: foreground,
focusedTextColor: foreground,
cursorColor: foreground,
})
field.add(new TextRenderable(renderer, { content: "Name", fg: muted, bg: background }))
field.add(input)
renderer.root.add(field)
input.focus()
mockInput.typeText("Ada Lovelace")
},
},
{
id: "textarea-wrap",
label: "A multiline textarea wrapping a long line at a word boundary",
width: 30,
height: 4,
render({ renderer }) {
const field = new BoxRenderable(renderer, { width: 30, height: 4 })
field.add(new TextRenderable(renderer, { content: "Notes", fg: muted, bg: background }))
field.add(
new TextareaRenderable(renderer, {
width: 30,
height: 3,
initialValue: "Long lines wrap at word boundaries.\nKeep paragraphs readable.",
wrapMode: "word",
backgroundColor: background,
focusedBackgroundColor: background,
textColor: foreground,
focusedTextColor: foreground,
cursorColor: foreground,
}),
)
renderer.root.add(field)
},
},
{
id: "textarea-selection",
label: "A focused multiline textarea with keyboard focus selected",
width: 30,
height: 4,
cursor: true,
render({ renderer }) {
const value = "Plan the release\nReview keyboard focus\nShip the update"
const field = new BoxRenderable(renderer, { width: 30, height: 4 })
const textarea = new TextareaRenderable(renderer, {
width: 30,
height: 3,
initialValue: value,
backgroundColor: background,
focusedBackgroundColor: background,
textColor: foreground,
focusedTextColor: foreground,
selectionBg: selection,
selectionFg: foreground,
cursorColor: foreground,
})
field.add(new TextRenderable(renderer, { content: "Draft", fg: muted, bg: background }))
field.add(textarea)
renderer.root.add(field)
textarea.focus()
textarea.setCursor(1, "Review keyboard focus".length)
const start = value.indexOf("keyboard focus")
textarea.setSelection(start, start + "keyboard focus".length)
},
},
{
id: "select-options",
label: "New file, Open file, and Save options with Open file selected",
width: 32,
height: 6,
render({ renderer, mockInput }) {
const select = new SelectRenderable(renderer, {
width: 32,
height: 6,
options: [
{ name: "New file", description: "Create a document" },
{ name: "Open file", description: "Browse existing files" },
{ name: "Save", description: "Write current changes" },
],
backgroundColor: background,
focusedBackgroundColor: background,
textColor: foreground,
focusedTextColor: foreground,
selectedBackgroundColor: selection,
selectedTextColor: foreground,
descriptionColor: muted,
selectedDescriptionColor: foreground,
})
renderer.root.add(select)
select.focus()
mockInput.pressArrow("down")
},
},
{
id: "tab-select-tabs",
label: "Home, Files, and Settings tabs with Files selected and underlined",
width: 36,
height: 3,
render({ renderer, mockInput }) {
const tabs = new TabSelectRenderable(renderer, {
width: 36,
tabWidth: 12,
options: [
{ name: "Home", description: "View project overview" },
{ name: "Files", description: "Browse project files" },
{ name: "Settings", description: "Configure the project" },
],
backgroundColor: background,
focusedBackgroundColor: background,
textColor: muted,
focusedTextColor: muted,
selectedBackgroundColor: selection,
selectedTextColor: foreground,
selectedDescriptionColor: muted,
})
renderer.root.add(tabs)
tabs.focus()
mockInput.pressArrow("right")
},
},
{
id: "interaction-selection-focus",
label: "The word text is selected above a focused command input containing deploy --check",
width: 32,
height: 4,
cursor: true,
async render({ renderer, mockInput, mockMouse, renderOnce }) {
const layout = new BoxRenderable(renderer, { width: 32, height: 4, gap: 1 })
const text = new TextRenderable(renderer, {
width: 32,
content: "Select text, then focus input",
fg: foreground,
bg: background,
selectionBg: selection,
selectionFg: foreground,
})
const field = new BoxRenderable(renderer, { width: 32, height: 2 })
const input = new InputRenderable(renderer, {
width: 32,
backgroundColor: background,
focusedBackgroundColor: selection,
textColor: foreground,
focusedTextColor: foreground,
cursorColor: foreground,
})
field.add(new TextRenderable(renderer, { content: "Command", fg: muted, bg: background }))
field.add(input)
layout.add(text)
layout.add(field)
renderer.root.add(layout)
await renderOnce()
await mockMouse.drag(text.x + 7, text.y, text.x + 10, text.y, undefined, { delayMs: 0 })
input.focus()
mockInput.typeText("deploy --check")
},
},
]
@@ -0,0 +1,128 @@
import {
BoxRenderable,
FrameBufferRenderable,
RGBA,
TextRenderable,
bold,
fg,
italic,
t,
underline,
} from "@opentui/core"
import type { DocVisualFixture } from "./shared"
const foreground = RGBA.defaultForeground()
const muted = RGBA.fromIndex(244)
export const foundationVisuals: DocVisualFixture[] = [
{
id: "text-attributes",
label: "Text attributes applied to bold, italic, and underlined content",
width: 30,
height: 3,
render({ renderer }) {
for (const content of [
t`${fg(muted)("bold ")}${bold("Important message")}`,
t`${fg(muted)("italic ")}${italic("Additional context")}`,
t`${fg(muted)("underline ")}${underline("Documentation")}`,
]) {
renderer.root.add(new TextRenderable(renderer, { content, fg: foreground }))
}
},
},
{
id: "box-title-alignment",
label: "Box with a centered top title and right-aligned bottom title",
width: 32,
height: 3,
render({ renderer }) {
const box = new BoxRenderable(renderer, {
width: 32,
height: 3,
border: true,
borderColor: foreground,
title: "settings",
titleAlignment: "center",
bottomTitle: "close",
bottomTitleAlignment: "right",
paddingX: 1,
})
box.add(new TextRenderable(renderer, { content: "Top and bottom titles", fg: muted }))
renderer.root.add(box)
},
},
{
id: "color-palette",
label:
"OpenTUI's 256-color fallback palette: 16 terminal colors, six red-level slices of the RGB cube, and 24 grays. Blue increases rightward and green downward within each slice.",
width: 38,
height: 22,
render({ renderer }) {
const canvas = new FrameBufferRenderable(renderer, { width: 38, height: 22 })
renderer.root.add(canvas)
const buffer = canvas.frameBuffer
const background = RGBA.defaultBackground()
buffer.clear(background)
buffer.drawText("0-15 terminal colors", 0, 0, foreground, background)
// Freeze the fallback snapshots instead of applying the page's semantic palette overrides.
for (let index = 0; index < 16; index++) {
const color = RGBA.fromInts(...RGBA.fromIndex(index).toInts())
buffer.drawText("\u2588\u2588", index * 2, 1, color, color)
}
buffer.drawText("16-231 RGB cube", 0, 3, foreground, background)
for (let red = 0; red < 6; red++) {
const left = (red % 3) * 13
const top = 5 + Math.floor(red / 3) * 8
const redLevel = RGBA.fromIndex(16 + red * 36).toInts()[0]
buffer.drawText(`R=${redLevel}`, left, top - 1, muted, background)
for (let green = 0; green < 6; green++) {
for (let blue = 0; blue < 6; blue++) {
const index = 16 + red * 36 + green * 6 + blue
const color = RGBA.fromInts(...RGBA.fromIndex(index).toInts())
buffer.drawText("\u2588\u2588", left + blue * 2, top + green, color, color)
}
}
}
buffer.drawText("232-255 grayscale", 0, 20, foreground, background)
for (let index = 232; index < 256; index++) {
const color = RGBA.fromInts(...RGBA.fromIndex(index).toInts())
buffer.drawText("\u2588", index - 232, 21, color, color)
}
},
},
{
id: "color-alpha",
label:
"Green #22c55e over a gray checkerboard at alpha 0, 0.25, 0.5, 0.75, and 1. The checkerboard remains visible through translucent tiles and disappears at alpha 1.",
width: 34,
height: 4,
render({ renderer }) {
const canvas = new FrameBufferRenderable(renderer, { width: 34, height: 4 })
renderer.root.add(canvas)
const buffer = canvas.frameBuffer
const background = RGBA.defaultBackground()
const checks = [RGBA.fromHex("#e0e0e0"), RGBA.fromHex("#a0a0a0")]
buffer.clear(background)
for (const [index, alpha] of [0, 0.25, 0.5, 0.75, 1].entries()) {
const left = index * 7
for (let y = 0; y < 3; y++) {
for (let x = 0; x < 6; x += 2) {
buffer.fillRect(left + x, y, 2, 1, checks[(x / 2 + y) % 2])
}
}
const overlay = RGBA.fromHex("#22c55e")
overlay.a = alpha
buffer.fillRect(left, 0, 6, 3, overlay)
buffer.drawText(alpha.toFixed(2), left + 1, 3, foreground, background)
}
},
},
]
@@ -0,0 +1,192 @@
import {
ASCIIFontRenderable,
EmbeddedTerminalRenderable,
FrameBufferRenderable,
ImageRenderable,
NativeImage,
RGBA,
} from "@opentui/core"
import { QRCodeRenderable } from "@opentui/qrcode"
import type { DocVisualFixture } from "./shared"
const foreground = RGBA.defaultForeground()
const background = RGBA.defaultBackground()
const muted = RGBA.fromIndex(244)
export const graphicsVisuals: DocVisualFixture[] = [
{
id: "ascii-font-tiny",
label: "OPEN drawn with OpenTUI's compact tiny ASCII font",
width: 16,
height: 2,
render({ renderer }) {
renderer.root.add(
new ASCIIFontRenderable(renderer, {
text: "OPEN",
font: "tiny",
color: foreground,
}),
)
},
},
{
id: "ascii-font-block",
label: "OPEN drawn with OpenTUI's six-row block ASCII font",
width: 38,
height: 6,
render({ renderer }) {
renderer.root.add(
new ASCIIFontRenderable(renderer, {
text: "OPEN",
font: "block",
color: foreground,
}),
)
},
},
{
id: "frame-buffer-draw",
label: "Network throughput chart showing receive at 42 MB/s and transmit at 18 MB/s",
width: 21,
height: 4,
render({ renderer }) {
const canvas = new FrameBufferRenderable(renderer, { width: 21, height: 4 })
const buffer = canvas.frameBuffer
buffer.clear(background)
buffer.drawText("network throughput", 0, 0, foreground, background)
buffer.drawText("rx", 0, 1, muted, background)
buffer.drawText("tx", 0, 2, muted, background)
buffer.drawText("42 MB/s", 14, 1, muted, background)
buffer.drawText("18 MB/s", 14, 2, muted, background)
buffer.drawText("8 seconds", 4, 3, muted, background)
buffer.drawText("now", 18, 3, muted, background)
for (const [row, bars] of ["▂▄▆█▇▅▃▂", "▃▅▇█▆▄▂▁"].entries()) {
for (const [column, bar] of [...bars].entries()) {
buffer.setCell(column + 4, row + 1, bar, foreground, background)
}
}
renderer.root.add(canvas)
},
},
{
id: "frame-buffer-progress",
label: "Package download progress at 70%, with 14 of 20 files complete",
width: 25,
height: 3,
render({ renderer }) {
const canvas = new FrameBufferRenderable(renderer, { width: 25, height: 3 })
const buffer = canvas.frameBuffer
buffer.clear(background)
buffer.drawText("Downloading package", 0, 0, foreground, background)
buffer.drawText("70%", 22, 1, foreground, background)
buffer.drawText("14 of 20 files", 0, 2, muted, background)
for (let column = 0; column < 20; column++) {
buffer.setCell(column, 1, column < 14 ? "█" : "░", column < 14 ? foreground : muted, background)
}
renderer.root.add(canvas)
},
},
{
id: "embedded-terminal-vt",
label: "Embedded terminal test run with two passed and zero failed",
width: 27,
height: 4,
inheritTerminalColors: true,
render({ renderer }) {
const terminal = new EmbeddedTerminalRenderable(renderer, { width: 27, height: 4 })
const output = [
"\x1b[38;5;244m$ \x1b[39mbun test",
"\x1b[38;5;244m✓\x1b[39m parser accepts UTF-8",
"\x1b[38;5;244m✓\x1b[39m renderer draws wide cells",
"\x1b[1m2 passed\x1b[22m, 0 failed",
].join("\r\n")
terminal.write(new TextEncoder().encode(output))
renderer.root.add(terminal)
},
},
{
id: "image-blocks",
label: "Generated RGBA landscape displayed with the Unicode block image protocol",
width: 24,
height: 8,
async render({ renderer }) {
const width = 48
const height = 32
const pixels = new Uint8Array(width * height * 4)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const ridge = Math.min(8 + Math.abs(x - 14) * 0.62, 11 + Math.abs(x - 33) * 0.5)
const nearRidge = 16 + Math.abs(x - 28) * 0.52
const sun = (x - 39) ** 2 + (y - 7) ** 2 <= 16
const color =
y >= 25
? [34, 197, 94]
: y >= nearRidge
? [30, 64, 175]
: y >= ridge
? [96, 165, 250]
: sun
? [250, 204, 21]
: [15, 23, 42]
const offset = (y * width + x) * 4
pixels[offset] = color[0]
pixels[offset + 1] = color[1]
pixels[offset + 2] = color[2]
pixels[offset + 3] = 255
}
}
const image = NativeImage.fromRgba(pixels, width, height)
let renderable: ImageRenderable | undefined
try {
renderable = new ImageRenderable(renderer, {
source: image,
protocol: "blocks",
fit: "fill",
width: 24,
height: 8,
})
renderer.root.add(renderable)
await renderable.loadPromise
return () => image.dispose()
} catch (error) {
renderable?.destroy()
image.dispose()
throw error
}
},
},
{
id: "qr-code-version-one",
label: "Scannable version-one QR code encoding OPENTUI with a four-module white quiet zone",
width: 29,
height: 15,
render({ renderer }) {
const qr = new QRCodeRenderable(renderer, {
content: "OPENTUI",
quietZone: 4,
scale: 1,
foregroundColor: "#000000",
backgroundColor: "#ffffff",
})
if (qr.version !== 1) {
qr.destroy()
throw new Error(`Expected a version-one QR code, received version ${qr.version}`)
}
renderer.root.add(qr)
},
},
]
@@ -0,0 +1,103 @@
import { BoxRenderable, RGBA, TextRenderable, type MouseEvent } from "@opentui/core"
import type { DocVisualFixture } from "./shared"
const foreground = RGBA.defaultForeground()
const background = RGBA.defaultBackground()
const muted = RGBA.fromIndex(244)
export const interactionVisuals: DocVisualFixture[] = [
{
id: "interaction-hit-bubbling",
label: "The overlapping front box receives the hit, bubbles to its parent, then stops a second down event",
width: 38,
height: 10,
async render({ renderer, mockMouse, renderOnce }) {
const trace: string[] = []
let target = ""
let stop = false
const record = (event: MouseEvent) => {
target = event.target?.id ?? ""
trace.push(event.currentTarget!.id)
if (stop && event.currentTarget!.id === "front") event.stopPropagation()
}
const parent = new BoxRenderable(renderer, {
id: "parent",
width: 38,
height: 7,
border: true,
borderColor: foreground,
title: "parent",
onMouseDown: record,
})
renderer.root.add(parent)
parent.add(
new BoxRenderable(renderer, {
id: "back",
position: "absolute",
left: 1,
top: 1,
width: 24,
height: 3,
zIndex: 0,
border: true,
borderColor: muted,
backgroundColor: background,
title: "back z=0",
onMouseDown: record,
}),
)
const front = new BoxRenderable(renderer, {
id: "front",
position: "absolute",
left: 10,
top: 2,
width: 25,
height: 3,
zIndex: 1,
border: true,
borderColor: foreground,
backgroundColor: background,
title: "front z=1",
onMouseDown: record,
})
parent.add(front)
const output = new TextRenderable(renderer, { fg: foreground, selectable: false })
renderer.root.add(output)
await renderOnce()
await mockMouse.click(front.x + 2, front.y + 1, undefined, { delayMs: 0 })
const bubbled = trace.join(" -> ")
trace.length = 0
stop = true
await mockMouse.click(front.x + 2, front.y + 1, undefined, { delayMs: 0 })
output.content = `target: ${target}\nbubble: ${bubbled}\nstopped: ${trace.join(" -> ")}`
},
},
{
id: "interaction-drag-selection",
label: "A drag selects the app on the first line and Test on the second, with text-buffer offsets [6, 18)",
width: 32,
height: 5,
async render({ renderer, mockMouse, renderOnce }) {
const layout = new BoxRenderable(renderer, { width: 32, height: 5, gap: 1 })
renderer.root.add(layout)
const text = new TextRenderable(renderer, {
content: "Build the app\nTest the input",
height: 2,
fg: foreground,
bg: background,
selectionBg: RGBA.fromIndex(238),
selectionFg: foreground,
})
layout.add(text)
const output = new TextRenderable(renderer, { height: 2, fg: muted, selectable: false })
layout.add(output)
await renderOnce()
await mockMouse.drag(text.x + 6, text.y, text.x + 3, text.y + 1, undefined, { delayMs: 0 })
const selected = renderer.getSelection()?.getSelectedText()
const range = text.getSelection()
output.content = `Selected: ${JSON.stringify(selected)}\nRange: [${range?.start}, ${range?.end})`
},
},
]
+167
View File
@@ -0,0 +1,167 @@
import { BoxRenderable, RGBA, TextRenderable } from "@opentui/core"
import type { TestRendererSetup } from "@opentui/core/testing"
import type { DocVisualFixture } from "./shared"
const foreground = RGBA.defaultForeground()
const fill = RGBA.fromIndex(238)
export const layoutVisuals: DocVisualFixture[] = [
{
id: "layout-flex-columns",
label:
"A 34-column container with a one-cell border and padding, an eight-column fixed child, a two-column gap, and a growing child filling the remaining 20 columns",
width: 34,
height: 5,
render({ renderer }) {
const row = new BoxRenderable(renderer, {
width: 34,
height: 5,
border: true,
borderColor: foreground,
padding: 1,
flexDirection: "row",
gap: 2,
})
renderer.root.add(row)
const fixed = new BoxRenderable(renderer, { width: 8, height: 1, backgroundColor: fill })
row.add(fixed)
fixed.add(new TextRenderable(renderer, { content: "fixed 8", fg: foreground }))
const growing = new BoxRenderable(renderer, {
flexGrow: 1,
flexBasis: 0,
height: 1,
backgroundColor: fill,
})
row.add(growing)
growing.add(new TextRenderable(renderer, { content: "grow 20", fg: foreground }))
},
},
{
id: "layout-alignment",
label:
"Three children distributed horizontally with space-between: one-row A and three-row B share a vertical center, while C overrides alignment to sit at the bottom",
width: 32,
height: 7,
render({ renderer }) {
const row = new BoxRenderable(renderer, {
width: 32,
height: 7,
border: true,
borderColor: foreground,
paddingX: 1,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
})
renderer.root.add(row)
for (const [label, height] of [
["A", 1],
["B", 3],
["C", 1],
] as const) {
const child = new BoxRenderable(renderer, {
width: 6,
height,
alignSelf: label === "C" ? "flex-end" : "auto",
backgroundColor: fill,
})
row.add(child)
child.add(new TextRenderable(renderer, { content: label, fg: foreground }))
}
},
},
{
id: "layout-cell-rounding",
label:
"Three equal grow factors divide 30 inner columns into 10, 10, and 10 cells; adding one terminal column changes the allocation to 10, 11, and 10 cells",
width: 33,
height: 8,
async render({ renderer, renderOnce }) {
const cells: Array<{ box: BoxRenderable; label: TextRenderable }> = []
for (const width of [32, 33]) {
renderer.root.add(
new TextRenderable(renderer, { content: `${width} columns: ${width - 2} inside`, fg: foreground }),
)
const row = new BoxRenderable(renderer, {
width,
height: 3,
border: true,
borderColor: foreground,
flexDirection: "row",
})
renderer.root.add(row)
for (let index = 0; index < 3; index++) {
const box = new BoxRenderable(renderer, {
flexGrow: 1,
flexBasis: 0,
minWidth: 0,
height: 1,
alignItems: "center",
backgroundColor: index === 1 ? RGBA.fromIndex(235) : fill,
})
row.add(box)
const label = new TextRenderable(renderer, { content: "", fg: foreground })
box.add(label)
cells.push({ box, label })
}
}
await renderOnce()
for (const { box, label } of cells) label.content = String(box.width)
},
},
{
id: "layout-wrap-wide",
label: "At 32 terminal columns, three eight-column children A, B, and C fit in one row with two-column gaps",
width: 32,
height: 4,
render(setup) {
return renderWrapping(setup, 32)
},
},
{
id: "layout-wrap-narrow",
label:
"After resizing the same terminal to 22 columns, A and B stay on the first row while C wraps to a second row and the container grows taller",
width: 22,
height: 6,
render(setup) {
return renderWrapping(setup, 22)
},
},
]
async function renderWrapping({ renderer, renderOnce, resize }: TestRendererSetup, columns: number) {
const height = columns === 32 ? 4 : 6
resize(32, height)
const label = new TextRenderable(renderer, { content: "32 columns", fg: foreground })
renderer.root.add(label)
const row = new BoxRenderable(renderer, {
width: "100%",
border: true,
borderColor: foreground,
paddingX: 1,
flexDirection: "row",
flexWrap: "wrap",
alignItems: "flex-start",
columnGap: 2,
rowGap: 1,
})
renderer.root.add(row)
for (const content of ["A", "B", "C"]) {
const child = new BoxRenderable(renderer, { width: 8, height: 1, flexShrink: 0, backgroundColor: fill })
row.add(child)
child.add(new TextRenderable(renderer, { content, fg: foreground }))
}
await renderOnce()
resize(columns, height)
label.content = `${columns} columns`
}
@@ -0,0 +1,133 @@
import { BoxRenderable, RGBA, TextRenderable } from "@opentui/core"
import type { TestRendererSetup } from "@opentui/core/testing"
import type { DocVisualFixture } from "./shared"
const foreground = RGBA.defaultForeground()
const muted = RGBA.fromIndex(244)
const fill = RGBA.fromIndex(243)
export const renderableVisuals: DocVisualFixture[] = [
{
id: "renderable-created",
label: "An 18-column shaded panel containing a status that says Waiting",
width: 18,
height: 3,
render(setup) {
return renderMutation(setup, false)
},
},
{
id: "renderable-mutated",
label:
"After changing content and width, the same shaded panel is 30 columns wide and its retained status says Ready",
width: 30,
height: 3,
render(setup) {
return renderMutation(setup, true)
},
},
{
id: "renderable-reparent-before",
label: "Before reparenting, the shaded Message subtree is a child of first; second is empty",
width: 34,
height: 3,
render(setup) {
return renderReparenting(setup, false)
},
},
{
id: "renderable-reparent-after",
label: "After second.add(message), the same shaded Message subtree is a child of second; first is empty",
width: 34,
height: 3,
render(setup) {
return renderReparenting(setup, true)
},
},
{
id: "renderable-visibility",
label:
"Visible: the panel contains Detail above Next. Hidden: Next moves up, but Detail stays attached and the panel still has two children. Detached: Next moves up, Detail has no parent, and the panel has one child. Neither operation destroys Detail.",
width: 38,
height: 7,
async render({ renderer, renderOnce }, registerCleanup) {
const row = new BoxRenderable(renderer, { width: 38, flexDirection: "row", gap: 1 })
renderer.root.add(row)
for (const state of ["visible", "hidden", "detached"]) {
const column = new BoxRenderable(renderer, { width: 12 })
row.add(column)
column.add(new TextRenderable(renderer, { content: state, fg: foreground }))
const panel = new BoxRenderable(renderer, {
width: 12,
height: 4,
border: true,
borderColor: foreground,
})
column.add(panel)
const detail = new BoxRenderable(renderer, { height: 1, backgroundColor: fill })
registerCleanup(() => detail.destroyRecursively())
panel.add(detail)
detail.add(new TextRenderable(renderer, { content: "Detail", fg: foreground }))
panel.add(new TextRenderable(renderer, { content: "Next", fg: foreground }))
await renderOnce()
if (state !== "visible") detail.visible = false
if (state === "detached") {
detail.visible = true
panel.remove(detail)
}
column.add(new TextRenderable(renderer, { content: `children: ${panel.getChildrenCount()}`, fg: muted }))
column.add(new TextRenderable(renderer, { content: `parent: ${detail.parent ? "yes" : "no"}`, fg: muted }))
}
},
},
]
async function renderMutation({ renderer, renderOnce }: TestRendererSetup, updated: boolean) {
const panel = new BoxRenderable(renderer, {
id: "panel",
width: 18,
height: 3,
paddingX: 1,
border: true,
borderColor: foreground,
backgroundColor: fill,
})
renderer.root.add(panel)
const status = new TextRenderable(renderer, { id: "status", content: "Waiting", fg: foreground })
panel.add(status)
await renderOnce()
if (updated) {
status.content = "Ready"
panel.width = 30
}
}
async function renderReparenting({ renderer, renderOnce }: TestRendererSetup, moved: boolean) {
const row = new BoxRenderable(renderer, { width: 34, flexDirection: "row", gap: 2 })
renderer.root.add(row)
const [first, second] = ["first", "second"].map((id) => {
const panel = new BoxRenderable(renderer, {
id,
title: id,
width: 16,
height: 3,
paddingX: 1,
border: true,
borderColor: foreground,
})
row.add(panel)
return panel
})
const message = new BoxRenderable(renderer, { id: "message", width: 10, height: 1, backgroundColor: fill })
first.add(message)
message.add(new TextRenderable(renderer, { content: "Message", fg: foreground }))
await renderOnce()
if (moved) second.add(message)
}
@@ -0,0 +1,251 @@
import {
BoxRenderable,
RGBA,
ScrollBarRenderable,
ScrollBoxRenderable,
SliderRenderable,
SlotRenderable,
TextRenderable,
createCoreSlotRegistry,
registerCorePlugin,
} from "@opentui/core"
import type { TestRendererSetup } from "@opentui/core/testing"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import type { DocVisualFixture } from "./shared"
const foreground = RGBA.defaultForeground()
const background = RGBA.defaultBackground()
const muted = RGBA.fromIndex(244)
const track = RGBA.fromIndex(235)
const entries = [
"01 src/index.ts",
"02 src/app.ts",
"03 src/layout.ts",
"04 src/theme.ts",
"05 src/events.ts",
"06 src/input.ts",
"07 src/scroll.ts",
"08 src/render.ts",
"09 src/state.ts",
"10 src/config.ts",
"11 src/logger.ts",
"12 src/cleanup.ts",
]
async function renderScrollBox(setup: TestRendererSetup, position: number) {
const { renderer } = setup
const layout = new BoxRenderable(renderer, { width: 32, height: 8 })
const status = new TextRenderable(renderer, { content: "", fg: muted, height: 1 })
const scrollbox = new ScrollBoxRenderable(renderer, {
width: 32,
height: 7,
border: true,
borderColor: foreground,
scrollbarOptions: {
trackOptions: { backgroundColor: track, foregroundColor: foreground },
arrowOptions: { foregroundColor: foreground, backgroundColor: background },
},
})
for (const entry of entries) {
scrollbox.add(new TextRenderable(renderer, { content: entry, fg: foreground, height: 1, flexShrink: 0 }))
}
layout.add(status)
layout.add(scrollbox)
renderer.root.add(layout)
await setup.renderOnce()
scrollbox.scrollTo(position)
status.content = `offset: ${scrollbox.scrollTop} / ${scrollbox.scrollHeight - scrollbox.viewport.height}`
}
export const scrollingVisuals: DocVisualFixture[] = [
{
id: "slider-horizontal",
label: "Horizontal slider with its thumb positioned at 25 out of 100",
width: 30,
height: 3,
render({ renderer }) {
const layout = new BoxRenderable(renderer, { width: 30, height: 3 })
const limits = new BoxRenderable(renderer, {
width: 30,
height: 1,
flexDirection: "row",
justifyContent: "space-between",
})
layout.add(new TextRenderable(renderer, { content: "value: 25", fg: foreground }))
layout.add(
new SliderRenderable(renderer, {
orientation: "horizontal",
width: 30,
height: 1,
min: 0,
max: 100,
value: 25,
backgroundColor: track,
foregroundColor: foreground,
}),
)
limits.add(new TextRenderable(renderer, { content: "0", fg: muted }))
limits.add(new TextRenderable(renderer, { content: "100", fg: muted }))
layout.add(limits)
renderer.root.add(layout)
},
},
{
id: "slider-vertical",
label: "Vertical slider with its thumb positioned at 0.5 out of 1",
width: 13,
height: 10,
render({ renderer }) {
const layout = new BoxRenderable(renderer, { width: 13, height: 10, flexDirection: "row", gap: 2 })
const labels = new BoxRenderable(renderer, { width: 9, height: 10, justifyContent: "space-between" })
labels.add(new TextRenderable(renderer, { content: "min 0", fg: muted }))
labels.add(new TextRenderable(renderer, { content: "value 0.5", fg: foreground }))
labels.add(new TextRenderable(renderer, { content: "max 1", fg: muted }))
layout.add(labels)
layout.add(
new SliderRenderable(renderer, {
orientation: "vertical",
width: 2,
height: 10,
min: 0,
max: 1,
value: 0.5,
backgroundColor: track,
foregroundColor: foreground,
}),
)
renderer.root.add(layout)
},
},
{
id: "scrollbar-arrows",
label: "Standalone vertical scrollbar with arrows at position 0, showing 20 of 200 rows",
width: 20,
height: 10,
render({ renderer }) {
const layout = new BoxRenderable(renderer, { width: 20, height: 10, flexDirection: "row", gap: 1 })
const status = new BoxRenderable(renderer, { width: 18, height: 10, justifyContent: "space-between" })
const scrollbar = new ScrollBarRenderable(renderer, {
orientation: "vertical",
width: 1,
height: 10,
showArrows: true,
arrowOptions: { foregroundColor: foreground, backgroundColor: background },
trackOptions: { backgroundColor: track, foregroundColor: foreground },
})
scrollbar.scrollSize = 200
scrollbar.viewportSize = 20
scrollbar.scrollPosition = 0
status.add(
new TextRenderable(renderer, {
content: `position: ${scrollbar.scrollPosition} / ${scrollbar.scrollSize - scrollbar.viewportSize}`,
fg: foreground,
}),
)
status.add(
new TextRenderable(renderer, {
content: `viewport: ${scrollbar.viewportSize} / ${scrollbar.scrollSize}`,
fg: muted,
}),
)
layout.add(status)
layout.add(scrollbar)
renderer.root.add(layout)
},
},
{
id: "scrollbox-top",
label: "ScrollBox at offset 0 showing index.ts, app.ts, layout.ts, theme.ts, and events.ts",
width: 32,
height: 8,
render(setup) {
return renderScrollBox(setup, 0)
},
},
{
id: "scrollbox-scrolled",
label: "ScrollBox at offset 5 showing input.ts, scroll.ts, render.ts, state.ts, and config.ts",
width: 32,
height: 8,
render(setup) {
return renderScrollBox(setup, 5)
},
},
{
id: "plugin-slot-modes",
label: "Plugin slots: append shows host, clock, and sync; replace shows clock and sync; single winner shows clock",
width: 32,
height: 3,
render({ renderer }) {
const registry = createCoreSlotRegistry<"statusbar">(renderer, {})
const layout = new BoxRenderable(renderer, { width: 32, height: 3 })
for (const name of ["clock", "sync"]) {
registerCorePlugin(registry, {
id: name,
slots: {
statusbar: () => new TextRenderable(renderer, { content: name, fg: foreground, marginRight: 1 }),
},
})
}
for (const mode of ["append", "replace", "single_winner"] as const) {
const row = new BoxRenderable(renderer, { width: 32, height: 1, flexDirection: "row", gap: 2 })
row.add(new TextRenderable(renderer, { content: mode, fg: muted, width: 13 }))
row.add(
new SlotRenderable(renderer, {
registry,
name: "statusbar",
mode,
flexDirection: "row",
fallback: () => new TextRenderable(renderer, { content: "host", fg: muted, marginRight: 1 }),
}),
)
layout.add(row)
}
renderer.root.add(layout)
},
},
{
id: "keymap-active-keys",
label: "Active key bindings: Ctrl+S saves the file; Q quits the application",
width: 18,
height: 2,
render({ renderer }) {
const keymap = createDefaultOpenTuiKeymap(renderer)
keymap.registerLayer({
commands: [
{ name: "file.save", run() {} },
{ name: "app.quit", run() {} },
],
bindings: [
{ key: "ctrl+s", cmd: "file.save" },
{ key: "q", cmd: "app.quit" },
],
})
const layout = new BoxRenderable(renderer, { width: 18, height: 2 })
for (const binding of keymap.getActiveKeys()) {
const row = new BoxRenderable(renderer, { width: 18, height: 1, flexDirection: "row", gap: 2 })
row.add(new TextRenderable(renderer, { content: binding.display, fg: foreground, width: 6 }))
row.add(new TextRenderable(renderer, { content: String(binding.command), fg: muted }))
layout.add(row)
}
renderer.root.add(layout)
},
},
]
+128
View File
@@ -0,0 +1,128 @@
import { RGBA, rgbToHex } from "@opentui/core"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
type Cleanup = () => void | Promise<void>
export interface DocVisualFixture {
id: string
label: string
width: number
height: number
cursor?: boolean
inheritTerminalColors?: boolean
render(
setup: TestRendererSetup,
registerCleanup: (cleanup: Cleanup) => void,
): void | Cleanup | Promise<void | Cleanup>
}
export async function renderDocVisual(fixture: DocVisualFixture) {
const properties: Array<{ target: object; name: string; descriptor: PropertyDescriptor | undefined }> = [
"requestAnimationFrame",
"cancelAnimationFrame",
"window",
].map((name) => ({ target: globalThis, name, descriptor: Object.getOwnPropertyDescriptor(globalThis, name) }))
if (globalThis.window) {
properties.push({
target: globalThis.window,
name: "requestAnimationFrame",
descriptor: Object.getOwnPropertyDescriptor(globalThis.window, "requestAnimationFrame"),
})
}
let setup: TestRendererSetup | undefined
const cleanups: Cleanup[] = []
try {
setup = await createTestRenderer({
width: fixture.width,
height: fixture.height,
useThread: false,
remote: false,
forwardEnvKeys: [],
})
setup.renderer.setBackgroundColor(RGBA.defaultBackground())
const cleanup = await fixture.render(setup, (callback) => cleanups.push(callback))
if (cleanup) cleanups.push(cleanup)
await setup.renderOnce()
const frame = setup.captureSpans()
const state = setup.renderer.getCursorState()
const colors: ReturnType<typeof serializeColor>[] = []
const colorIndexes = new Map<string, number>()
function colorIndex(color: RGBA, channel: "foreground" | "background") {
if (fixture.inheritTerminalColors && color.intent === "rgb") {
const value = rgbToHex(color)
if (channel === "background" && value === "#000000") color = RGBA.defaultBackground()
else if (channel === "foreground" && value === "#ffffff") color = RGBA.defaultForeground()
else if (channel === "foreground" && value === "#808080") color = RGBA.fromIndex(244)
}
const value = serializeColor(color)
const key = `${value.intent}:${value.slot ?? ""}:${value.value}`
let index = colorIndexes.get(key)
if (index === undefined) {
index = colors.push(value) - 1
colorIndexes.set(key, index)
}
return index
}
const lines = frame.lines.map(({ spans }) =>
spans.map(({ text, width, fg, bg, attributes }) => ({
text,
width,
foreground: colorIndex(fg, "foreground"),
background: colorIndex(bg, "background"),
...(attributes ? { attributes } : {}),
})),
)
return {
label: fixture.label,
cols: frame.cols,
rows: frame.rows,
colors,
cursor: fixture.cursor && state.visible ? { column: state.x - 1, row: state.y - 1, style: state.style } : null,
lines,
}
} finally {
try {
try {
setup?.renderer.destroy()
} finally {
const errors: unknown[] = []
for (const cleanup of cleanups.toReversed()) {
try {
await cleanup()
} catch (error) {
errors.push(error)
}
}
if (errors.length === 1) throw errors[0]
if (errors.length > 1) throw new AggregateError(errors, "Documentation visual cleanup failed")
}
} finally {
for (const { target, name, descriptor } of properties.toReversed()) {
if (descriptor) Object.defineProperty(target, name, descriptor)
else Reflect.deleteProperty(target, name)
}
}
}
}
function serializeColor(color: RGBA) {
return {
intent: color.intent,
value: rgbToHex(color),
...(color.intent === "indexed" ? { slot: color.slot } : {}),
}
}
@@ -0,0 +1,258 @@
import {
CodeRenderable,
DiffRenderable,
LineNumberRenderable,
MarkdownRenderable,
RGBA,
SyntaxStyle,
TextTableRenderable,
TextareaRenderable,
bold,
fg,
type TextTableContent,
} from "@opentui/core"
import { MockTreeSitterClient } from "@opentui/core/testing"
import type { DocVisualFixture } from "./shared"
const foreground = RGBA.defaultForeground()
const background = RGBA.defaultBackground()
const muted = RGBA.fromIndex(244)
const markdownTable = "| Name | Status |\n| --- | --- |\n| api | ready |\n| worker | paused |"
const patch =
"diff --git a/app.ts b/app.ts\nindex 1111111..2222222 100644\n--- a/app.ts\n+++ b/app.ts\n@@ -1,3 +1,3 @@\n setup()\n-const a = 1\n+const a = 2\n ready(a)\n"
export const structuredVisuals: DocVisualFixture[] = [
{
id: "text-table-styled",
label: "Rounded table with Service, Status, and Notes columns: api OK, worker DEGRADED",
width: 35,
height: 7,
render({ renderer }) {
const content: TextTableContent = [
[[bold("Service")], [bold("Status")], [bold("Notes")]],
[[fg(foreground)("api")], [fg(foreground)("OK")], [fg(foreground)("latency 28ms")]],
[[fg(foreground)("worker")], [fg(muted)("DEGRADED")], [fg(foreground)("queue depth: 124")]],
]
renderer.root.add(
new TextTableRenderable(renderer, {
content,
columnWidthMode: "content",
borderStyle: "rounded",
borderColor: muted,
fg: foreground,
bg: background,
backgroundColor: background,
borderBackgroundColor: background,
}),
)
},
},
{
id: "line-number-editor",
label: "Three lines of numbered code with a sign beside serve(port)",
width: 24,
height: 3,
render({ renderer }, registerCleanup) {
const textarea = new TextareaRenderable(renderer, {
width: "100%",
height: 3,
initialValue: "const port = 3000\nserve(port)\nawait ready()",
backgroundColor: background,
textColor: foreground,
})
registerCleanup(() => {
if (!textarea.isDestroyed) textarea.destroy()
})
const gutter = new LineNumberRenderable(renderer, {
target: textarea,
width: "100%",
minWidth: 3,
paddingRight: 1,
fg: muted,
bg: background,
})
registerCleanup(() => {
if (!gutter.isDestroyed) gutter.destroyRecursively()
})
gutter.setLineSign(1, { before: ">", beforeColor: foreground })
renderer.root.add(gutter)
},
},
{
id: "code-highlighted",
label: "JavaScript function hello with bold keywords, a muted string, and an italic comment",
width: 33,
height: 5,
async render({ renderer, renderOnce }, registerCleanup) {
const client = new MockTreeSitterClient()
registerCleanup(() => client.destroy())
const syntaxStyle = SyntaxStyle.fromStyles({
default: { fg: foreground },
keyword: { fg: foreground, bold: true },
string: { fg: RGBA.fromIndex(247) },
comment: { fg: muted, italic: true },
})
registerCleanup(() => syntaxStyle.destroy())
client.setMockResult({
highlights: [
[0, 8, "keyword"],
[21, 41, "comment"],
[44, 49, "keyword"],
[60, 75, "string"],
[78, 84, "keyword"],
],
})
const code = new CodeRenderable(renderer, {
width: "100%",
height: 5,
content: 'function hello() {\n // This is a comment\n const message = "Hello, world!"\n return message\n}',
filetype: "javascript",
syntaxStyle,
treeSitterClient: client,
fg: foreground,
bg: background,
})
renderer.root.add(code)
await renderOnce()
client.resolveAllHighlightOnce()
await code.highlightingDone
},
},
{
id: "markdown-table-grid",
label: "Bordered Markdown table with Name and Status columns: api ready, worker paused",
width: 19,
height: 7,
render({ renderer }, registerCleanup) {
const client = new MockTreeSitterClient()
registerCleanup(() => client.destroy())
const syntaxStyle = SyntaxStyle.fromStyles({
default: { fg: foreground },
"markup.heading": { fg: foreground, bold: true },
})
registerCleanup(() => syntaxStyle.destroy())
renderer.root.add(
new MarkdownRenderable(renderer, {
width: "100%",
content: markdownTable,
syntaxStyle,
treeSitterClient: client,
fg: foreground,
bg: background,
tableOptions: {
style: "grid",
widthMode: "content",
cellPaddingX: 1,
borderColor: muted,
},
}),
)
},
},
{
id: "markdown-table-columns",
label: "Borderless Markdown table with Name and Status columns: api ready, worker paused",
width: 14,
height: 3,
render({ renderer }, registerCleanup) {
const client = new MockTreeSitterClient()
registerCleanup(() => client.destroy())
const syntaxStyle = SyntaxStyle.fromStyles({
default: { fg: foreground },
"markup.heading": { fg: foreground, bold: true },
})
registerCleanup(() => syntaxStyle.destroy())
renderer.root.add(
new MarkdownRenderable(renderer, {
width: "100%",
content: markdownTable,
syntaxStyle,
treeSitterClient: client,
fg: foreground,
bg: background,
tableOptions: { style: "columns" },
}),
)
},
},
{
id: "diff-unified",
label: "Unified diff replacing const a = 1 with const a = 2",
width: 16,
height: 4,
render({ renderer }, registerCleanup) {
const client = new MockTreeSitterClient()
registerCleanup(() => client.destroy())
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: foreground } })
registerCleanup(() => syntaxStyle.destroy())
renderer.root.add(
new DiffRenderable(renderer, {
width: "100%",
height: 4,
diff: patch,
view: "unified",
syntaxStyle,
treeSitterClient: client,
fg: foreground,
lineNumberFg: muted,
lineNumberBg: background,
addedBg: background,
removedBg: background,
contextBg: background,
addedLineNumberBg: background,
removedLineNumberBg: background,
addedSignColor: foreground,
removedSignColor: muted,
}),
)
},
},
{
id: "diff-split",
label: "Split diff replacing const a = 1 with const a = 2",
width: 34,
height: 3,
render({ renderer }, registerCleanup) {
const client = new MockTreeSitterClient()
registerCleanup(() => client.destroy())
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: foreground } })
registerCleanup(() => syntaxStyle.destroy())
renderer.root.add(
new DiffRenderable(renderer, {
width: "100%",
height: 3,
diff: patch,
view: "split",
syntaxStyle,
treeSitterClient: client,
fg: foreground,
lineNumberFg: muted,
lineNumberBg: background,
addedBg: background,
removedBg: background,
contextBg: background,
addedLineNumberBg: background,
removedLineNumberBg: background,
addedSignColor: foreground,
removedSignColor: muted,
}),
)
},
},
]
@@ -0,0 +1,134 @@
import {
BoxRenderable,
FrameBufferRenderable,
RGBA,
TextBuffer,
TextBufferView,
TextRenderable,
bg,
bold,
italic,
t,
underline,
} from "@opentui/core"
import type { DocVisualFixture } from "./shared"
const foreground = RGBA.defaultForeground()
const background = RGBA.defaultBackground()
const muted = RGBA.fromIndex(244)
const surface = RGBA.fromIndex(235)
const selection = RGBA.fromIndex(238)
export const textCellVisuals: DocVisualFixture[] = [
{
id: "text-styled-chunks",
label: "Styled chunks: Status is bold, Note is italic, and Next combines bold and underline",
width: 22,
height: 3,
render({ renderer }) {
const content = t`${bold("Status")}: ready\n${italic("Note")}: saved locally\n${bold(underline("Next"))}: review changes`
renderer.root.add(new TextRenderable(renderer, { content, fg: foreground, bg: background }))
},
},
{
id: "text-cell-ruler",
label:
"Zero-based display columns: ASCII ABC occupies three cells; A\u754cB occupies four, with \u754c shaded across columns 1 and 2. Each marker follows the text immediately.",
width: 22,
height: 3,
render({ renderer }) {
renderer.root.add(new TextRenderable(renderer, { content: "column 01234", fg: muted, bg: background }))
for (const [label, content] of [
["ASCII", "ABC"],
["wide", t`A${bg(RGBA.fromIndex(238))("\u754c")}B`],
]) {
const row = new BoxRenderable(renderer, { flexDirection: "row", height: 1 })
renderer.root.add(row)
row.add(new TextRenderable(renderer, { content: label, width: 8, fg: muted, bg: background }))
const text = new TextRenderable(renderer, { content, wrapMode: "none", fg: foreground, bg: surface })
row.add(text)
row.add(
new TextRenderable(renderer, { content: `| ${text.textLength} cells`, fg: foreground, bg: background }),
)
}
},
},
{
id: "text-wide-wrap",
label:
"Character wrapping of A\u754cB at widths 2, 3, and 4: the two shaded cells occupied by \u754c move intact to the next row when only one cell remains",
width: 34,
height: 5,
render({ renderer }) {
const row = new BoxRenderable(renderer, { flexDirection: "row", gap: 2 })
renderer.root.add(row)
for (const width of [2, 3, 4]) {
const column = new BoxRenderable(renderer, { width: 10 })
row.add(column)
column.add(new TextRenderable(renderer, { content: `${width} columns`, fg: foreground, bg: background }))
column.add(new TextRenderable(renderer, { content: "0123".slice(0, width), fg: muted, bg: background }))
const viewport = new BoxRenderable(renderer, { width, height: 3, backgroundColor: surface })
column.add(viewport)
viewport.add(
new TextRenderable(renderer, {
content: t`A${bg(RGBA.fromIndex(238))("\u754c")}B`,
width,
wrapMode: "char",
fg: foreground,
bg: surface,
}),
)
}
},
},
{
id: "text-line-offsets",
label:
"Both three-column views select B: soft-wrapped A\u754cB uses display offsets [3, 4), while A\u754c followed by a newline and B uses [4, 5)",
width: 35,
height: 5,
render({ renderer }, registerCleanup) {
const row = new BoxRenderable(renderer, { flexDirection: "row", gap: 3 })
renderer.root.add(row)
for (const [label, content, start] of [
["soft wrap", "A\u754cB", 3],
["newline", "A\u754c\nB", 4],
] as const) {
const column = new BoxRenderable(renderer, { width: 16 })
row.add(column)
column.add(new TextRenderable(renderer, { content: label, fg: foreground, bg: background }))
column.add(new TextRenderable(renderer, { content: "012", fg: muted, bg: background }))
const text = TextBuffer.create(renderer.widthMethod)
registerCleanup(() => text.destroy())
const view = TextBufferView.create(text)
registerCleanup(() => view.destroy())
text.setText(content)
text.setDefaultFg(foreground)
text.setDefaultBg(surface)
view.setWrapMode("char")
view.setViewport(0, 0, 3, 2)
view.setSelection(start, start + 1, selection, foreground)
const canvas = new FrameBufferRenderable(renderer, { width: 3, height: 2 })
column.add(canvas)
canvas.frameBuffer.clear(surface)
canvas.frameBuffer.drawTextBuffer(view, 0, 0)
const range = view.getSelection()!
column.add(
new TextRenderable(renderer, {
content: `range [${range.start}, ${range.end})`,
fg: muted,
bg: background,
}),
)
}
},
},
]
@@ -0,0 +1,84 @@
import { writeFile } from "node:fs/promises"
import { BoxRenderable, RGBA, TextRenderable } from "@opentui/core"
import { foundationVisuals } from "./doc-visuals/foundation"
import { formVisuals } from "./doc-visuals/forms"
import { graphicsVisuals } from "./doc-visuals/graphics"
import { interactionVisuals } from "./doc-visuals/interaction"
import { layoutVisuals } from "./doc-visuals/layout"
import { renderableVisuals } from "./doc-visuals/renderables"
import { scrollingVisuals } from "./doc-visuals/scrolling"
import { renderDocVisual, type DocVisualFixture } from "./doc-visuals/shared"
import { structuredVisuals } from "./doc-visuals/structured"
import { textCellVisuals } from "./doc-visuals/text-cells"
const styles = [
{ border: "single", description: "single line" },
{ border: "double", description: "double lines" },
{ border: "rounded", description: "round corners" },
{ border: "heavy", description: "heavy strokes" },
] as const
const boxBorders: DocVisualFixture = {
id: "box-borders",
label: "Four OpenTUI box border styles: single, double, rounded, and heavy",
width: 38,
height: 7,
render({ renderer }) {
const foreground = RGBA.defaultForeground()
const muted = RGBA.fromIndex(244)
const layout = new BoxRenderable(renderer, { width: 38, height: 7, gap: 1 })
for (let index = 0; index < styles.length; index += 2) {
const row = new BoxRenderable(renderer, { width: 38, height: 3, flexDirection: "row", gap: 2 })
for (const style of styles.slice(index, index + 2)) {
const box = new BoxRenderable(renderer, {
width: 18,
height: 3,
border: true,
borderStyle: style.border,
borderColor: foreground,
title: style.border,
titleAlignment: "center",
paddingX: 1,
})
box.add(new TextRenderable(renderer, { content: style.description, fg: muted }))
row.add(box)
}
layout.add(row)
}
renderer.root.add(layout)
},
}
export async function generateDocVisuals() {
const fixtures = [
boxBorders,
...foundationVisuals,
...layoutVisuals,
...renderableVisuals,
...textCellVisuals,
...interactionVisuals,
...formVisuals,
...scrollingVisuals,
...structuredVisuals,
...graphicsVisuals,
]
const visuals: Record<string, Awaited<ReturnType<typeof renderDocVisual>>> = {}
for (const fixture of fixtures) {
if (Object.hasOwn(visuals, fixture.id)) throw new Error(`Duplicate documentation visual "${fixture.id}"`)
visuals[fixture.id] = await renderDocVisual(fixture)
}
return visuals
}
if (import.meta.main) {
const output = new URL("../src/data/doc-visuals.json", import.meta.url)
await writeFile(output, `${JSON.stringify(await generateDocVisuals(), null, 2)}\n`)
console.log("Generated src/data/doc-visuals.json")
}
@@ -0,0 +1,142 @@
import { readFile } from "node:fs/promises"
import { runInNewContext } from "node:vm"
import { expect, test } from "bun:test"
const source = await readFile(new URL("../src/scripts/terminal-illustration.js", import.meta.url), "utf8")
async function player({ reducedMotion = false } = {}) {
const animationFrames = new Map<number, (now: number) => void>()
const clicks = new Map<string, () => void>()
const attributes = new Map<string, string>()
const observers: Array<(entries: Array<{ isIntersecting: boolean }>) => void> = []
let time = 0
let nextFrame = 0
const screen = { innerHTML: "", style: { setProperty() {} } }
const viewport = { style: {}, hasAttribute: () => true, getBoundingClientRect: () => ({ width: 240, height: 30 }) }
const toggle = {
textContent: "Play",
dataset: {},
addEventListener: (name: string, callback: () => void) => clicks.set(name, callback),
setAttribute: (name: string, value: string) => attributes.set(name, value),
}
const illustration = {
querySelector(selector: string) {
if (selector === "[data-illustration-screen]") return screen
if (selector === "[data-illustration-viewport]") return viewport
if (selector === "[data-illustration-toggle]") return toggle
return null
},
getAttribute(name: string) {
return name === "data-terminal-illustration"
? JSON.stringify({ stories: [{ src: "/test.json" }], defaults: { holdMs: 0 } })
: "Test recording"
},
setAttribute() {},
}
const document = {
hidden: false,
fonts: { ready: Promise.resolve() },
body: { appendChild() {} },
querySelectorAll: () => [illustration],
addEventListener() {},
createElement(tag: string) {
return tag === "canvas"
? {
getContext: () => ({
font: "",
measureText: () => ({ actualBoundingBoxAscent: 100, actualBoundingBoxDescent: 25 }),
}),
}
: { style: {}, getBoundingClientRect: () => ({ width: 60 }), remove() {} }
},
}
class IntersectionObserver {
constructor(
private callback: (entries: Array<{ isIntersecting: boolean }>) => void,
private options: { rootMargin?: string },
) {}
observe() {
if (this.options.rootMargin) this.callback([{ isIntersecting: true }])
else observers.push(this.callback)
}
disconnect() {}
}
const window = {
IntersectionObserver,
matchMedia: () => ({ matches: reducedMotion }),
addEventListener() {},
clearTimeout() {},
requestAnimationFrame(callback: (now: number) => void) {
const id = ++nextFrame
animationFrames.set(id, callback)
return id
},
cancelAnimationFrame: (id: number) => animationFrames.delete(id),
}
runInNewContext(source, {
document,
window,
IntersectionObserver,
performance: { now: () => time },
getComputedStyle: () => ({ color: "rgb(0, 0, 0)", fontFamily: "monospace" }),
fetch: async () => ({
ok: true,
json: async () => ({
cols: 8,
rows: 1,
chapters: [
{
durationMs: 200,
frames: [
{ at: 0, rows: [[{ t: "Start" }]] },
{ at: 100, rows: [[{ t: "End" }]] },
],
},
],
}),
}),
})
await new Promise(setImmediate)
return {
screen,
toggle,
attributes,
intersect(visible: boolean) {
for (const callback of observers) callback([{ isIntersecting: visible }])
},
click() {
clicks.get("click")!()
},
advance() {
time += 100
const callbacks = [...animationFrames.values()]
animationFrames.clear()
for (const callback of callbacks) callback(time)
},
}
}
test("a single recording repaints its first frame when looping", async () => {
const recording = await player()
recording.intersect(true)
recording.advance()
expect(recording.screen.innerHTML).toContain("End")
recording.advance()
expect(recording.screen.innerHTML).toContain("Start")
})
test("manual reduced-motion playback pauses when it leaves the viewport", async () => {
const recording = await player({ reducedMotion: true })
recording.intersect(true)
expect(recording.toggle.textContent).toBe("Play")
expect(recording.attributes.get("aria-pressed")).toBe("false")
expect(recording.screen.innerHTML).toContain("End")
recording.click()
expect(recording.attributes.get("aria-pressed")).toBe("true")
expect(recording.screen.innerHTML).toContain("Start")
recording.intersect(false)
expect(recording.toggle.textContent).toBe("Play")
expect(recording.attributes.get("aria-pressed")).toBe("false")
})
+47 -1
View File
@@ -1,4 +1,6 @@
---
import visuals from "../data/doc-visuals.json"
// Wraps markdown code fences (mapped via `<Content components={{ pre: ProseCode }} />`).
// The wrapper anchors the copy button outside the scrollable pre, and the
// button reads the raw source from data-code (see astro.config.mjs).
@@ -6,11 +8,55 @@
// typeface as the article, no code frame, no copy control.
const props = Astro.props
const source = String(props["data-code"] ?? "")
const visualId = props["data-terminal-visual"]
const visual = typeof visualId === "string" ? visuals[visualId as keyof typeof visuals] : undefined
const isDiagram = /^[┌╭┏╔]/.test(source.trimStart())
if (visualId && !visual) throw new Error(`Unknown terminal visual "${visualId}"`)
if (visual) {
const text = visual.lines.map((line) => line.map((span) => span.text).join("").trimEnd()).join("\n")
if (source.trimEnd() !== text.trimEnd()) throw new Error(`Terminal visual "${visualId}" does not match its text fence`)
}
function color(value: { intent: string; value: string; slot?: number }, background = false) {
if (value.intent === "default") {
return background ? "var(--terminal-background, var(--page-background))" : "var(--page-color)"
}
if (value.intent === "indexed") return `var(--terminal-color-${value.slot}, ${value.value})`
return value.value
}
---
{
isDiagram ? (
visual ? (
<figure class="box-figure" aria-label={visual.label}>
<pre
class:list={["terminal-frame", { "terminal-frame--surface": props["data-terminal-surface"] }]}
data-terminal-visual={visualId}
aria-hidden="true"
>{visual.lines.map((line, row) => (
<span class="terminal-frame__row">{line.map((span) => (
<span
class="terminal-frame__span"
style={{
width: `${span.width}ch`,
color: color(visual.colors[span.foreground]),
backgroundColor: color(visual.colors[span.background], true),
fontWeight: (span.attributes ?? 0) & 1 ? "700" : undefined,
fontStyle: (span.attributes ?? 0) & 4 ? "italic" : undefined,
textDecoration: (span.attributes ?? 0) & 8 ? "underline" : undefined,
}}
>{span.text}</span>
))}{visual.cursor?.row === row && (
<span
class:list={["terminal-frame__cursor", `terminal-frame__cursor--${visual.cursor.style}`]}
style={{ insetInlineStart: `${visual.cursor.column}ch` }}
/>
)}</span>
))}</pre>
</figure>
) : isDiagram ? (
<figure
class="box-figure"
aria-label="Diagram"
@@ -77,6 +77,24 @@ OpenTUI includes several ASCII art font styles:
}
```
The `tiny` font draws `OPEN` in two rows:
```text terminal=ascii-font-tiny
█▀█ █▀█ █▀▀ █▄ █
█▄█ █▀▀ ██▄ █ ▀█
```
The `block` font draws the same text in six rows:
```text terminal=ascii-font-block
██████╗ ██████╗ ███████╗ ███╗ ██╗
██╔═══██╗ ██╔══██╗ ██╔════╝ ████╗ ██║
██║ ██║ ██████╔╝ █████╗ ██╔██╗ ██║
██║ ██║ ██╔═══╝ ██╔══╝ ██║╚██╗██║
╚██████╔╝ ██║ ███████╗ ██║ ╚████║
╚═════╝ ╚═╝ ╚══════╝ ╚═╝ ╚═══╝
```
## Positioning
ASCIIFont inherits the standard layout positioning options. To position it at coordinates relative to its parent, use absolute positioning with `left` and `top`:
@@ -66,6 +66,16 @@ renderer.root.add(panel)
} // Heavy lines: ┏━┓┃┗━┛
```
```text terminal=box-borders
┌─────single─────┐ ╔═════double═════╗
│ single line │ ║ double lines ║
└────────────────┘ ╚════════════════╝
╭────rounded─────╮ ┏━━━━━heavy━━━━━━┓
│ round corners │ ┃ heavy strokes ┃
╰────────────────╯ ┗━━━━━━━━━━━━━━━━┛
```
## Titles
Add a title and bottom title to the box border:
@@ -108,6 +118,12 @@ const panel = new BoxRenderable(renderer, {
} // └────────── Title ┘
```
```text terminal=box-title-alignment
┌───────────settings───────────┐
│ Top and bottom titles │
└────────────────────────close─┘
```
## Layout container
Box works as a flex container for child elements:
@@ -27,12 +27,10 @@ import { CodeRenderable, createCliRenderer, SyntaxStyle, RGBA } from "@opentui/c
const renderer = await createCliRenderer()
const syntaxStyle = SyntaxStyle.fromStyles({
keyword: { fg: RGBA.fromHex("#FF7B72"), bold: true },
string: { fg: RGBA.fromHex("#A5D6FF") },
comment: { fg: RGBA.fromHex("#8B949E"), italic: true },
number: { fg: RGBA.fromHex("#79C0FF") },
function: { fg: RGBA.fromHex("#D2A8FF") },
default: { fg: RGBA.fromHex("#E6EDF3") },
default: { fg: RGBA.defaultForeground() },
keyword: { fg: RGBA.defaultForeground(), bold: true },
string: { fg: RGBA.fromIndex(247) },
comment: { fg: RGBA.fromIndex(244), italic: true },
})
const code = new CodeRenderable(renderer, {
@@ -51,6 +49,14 @@ const code = new CodeRenderable(renderer, {
renderer.root.add(code)
```
```text terminal=code-highlighted
function hello() {
// This is a comment
const message = "Hello, world!"
return message
}
```
## Creating syntax styles
Use `SyntaxStyle.fromStyles()` to define colors and attributes for syntax tokens:
@@ -32,11 +32,22 @@ const syntaxStyle = SyntaxStyle.fromStyles({
keyword: { fg: RGBA.fromHex("#FF7B72"), bold: true },
})
const patch = `diff --git a/app.ts b/app.ts
index 1111111..2222222 100644
--- a/app.ts
+++ b/app.ts
@@ -1,3 +1,3 @@
setup()
-const a = 1
+const a = 2
ready(a)
`
const diff = new DiffRenderable(renderer, {
id: "diff",
width: "100%",
height: 16,
diff: `diff --git a/app.ts b/app.ts\nindex 1111111..2222222 100644\n--- a/app.ts\n+++ b/app.ts\n@@ -1,3 +1,3 @@\n-const a = 1\n+const a = 2\n`,
diff: patch,
view: "split",
filetype: "typescript",
syntaxStyle,
@@ -46,6 +57,25 @@ const diff = new DiffRenderable(renderer, {
renderer.root.add(diff)
```
Monochrome views of the same patch:
Unified view:
```text terminal=diff-unified
1 setup()
2 - const a = 1
2 + const a = 2
3 ready(a)
```
Split view:
```text terminal=diff-split
1 setup() 1 setup()
2 - const a = 1 2 + const a = 2
3 ready(a) 3 ready(a)
```
For multi-file input, Diff currently displays only `patches[0]`. Create one `DiffRenderable` for each file patch that you need to show.
## Split view scroll sync
@@ -75,6 +75,15 @@ terminal.focus()
`write()` accepts a string or `Uint8Array`. The parser keeps incomplete escape sequences across calls. After `write()` or a resize, the renderable drains generated replies. Nonempty reply bytes go to `onData` with source `"response"`.
Parsed child output can produce this screen:
```text terminal=embedded-terminal-vt
$ bun test
✓ parser accepts UTF-8
✓ renderer draws wide cells
2 passed, 0 failed
```
`screen()` reads the private cell buffer from the last paint. Call it after a render pass. It trims the end of each line and drops trailing empty rows. `columns` and `rows` are the renderable's current layout size. Cursor state also comes from the last compose, not from `write()` alone.
## Attach a process
@@ -61,6 +61,15 @@ canvas.frameBuffer.setCell(10, 5, "@", RGBA.fromHex("#FFFF00"), RGBA.fromHex("#0
`setCell()` uses only the first code point and does not reserve continuation cells. Use it for one-cell scalars. Use `drawText()` for wide or joined graphemes.
Draw text and block characters directly to build a compact chart:
```text terminal=frame-buffer-draw
network throughput
rx ▂▄▆█▇▅▃▂ 42 MB/s
tx ▃▅▇█▆▄▂▁ 18 MB/s
8 seconds now
```
### setCellWithAlphaBlending
Set a cell with alpha blending for transparency effects:
@@ -264,6 +273,14 @@ function drawProgressBar(fb, x, y, width, progress, color) {
drawProgressBar(canvas.frameBuffer, 5, 10, 30, 0.75, RGBA.fromHex("#00FF00"))
```
A 20-cell bar with 14 filled cells renders as:
```text terminal=frame-buffer-progress
Downloading package
██████████████░░░░░░ 70%
14 of 20 files
```
## Related APIs
Read the [Buffer API](/docs/reference/buffer-api) for `OptimizedBuffer` ownership and drawing operations. Use [`NativeImage`](/docs/reference/native-image) to decode and transform image data. The [color matrix reference](/docs/reference/color-matrix) covers post-processing matrix and mask formats.
@@ -90,6 +90,19 @@ Sizing uses terminal pixel resolution when available and a 2:1 cell-height fallb
| `sixel` | Force Sixel. Falls back to blocks without terminal pixel resolution |
| `blocks` | Portable Unicode quadrant-block rendering |
The `blocks` protocol renders generated RGBA pixels as terminal cells:
```text terminal=image-blocks
████████████████████████
██████████████████▘██▜██
█████▛▀▝▀█████████▖██▟██
██▛▀██████▝▀█▛▀▘██▀▀████
▀███████████▗▄▙▄██████▀▀
████████▗▄▟███████▄▄████
▄▄▄▄▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄
████████████████████████
```
With global and per-image protocols set to `auto`, tmux uses blocks. Explicit Kitty, or Sixel with pixel resolution, uses tmux passthrough.
Overlapping images must use the same effective protocol. OpenTUI does not support layering or alpha composition across different effective protocols. Leave overlapping images on `auto` or give them the same explicit `protocol`. Non-overlapping images can use different protocols.
@@ -45,6 +45,13 @@ input.focus()
renderer.root.add(input)
```
An empty input displays its placeholder:
```text terminal=input-placeholder surface
Name
Enter your name
```
## Focus states
The input changes appearance when focused:
@@ -61,6 +68,13 @@ const input = new InputRenderable(renderer, {
})
```
After focusing the input and typing a name:
```text terminal=input-focused surface
Name
Ada Lovelace
```
## Events
### Input event
@@ -39,7 +39,7 @@ const syntaxStyle = SyntaxStyle.fromStyles({
const code = new CodeRenderable(renderer, {
id: "code",
content: "const x = 1\nconst y = 2\n",
content: "const port = 3000\nserve(port)\nawait ready()",
filetype: "typescript",
syntaxStyle,
width: "100%",
@@ -60,10 +60,17 @@ const scrollbox = new ScrollBoxRenderable(renderer, {
height: 18,
})
lineNumbers.setLineSign(1, { before: ">" })
scrollbox.add(lineNumbers)
renderer.root.add(scrollbox)
```
```text terminal=line-number-editor surface
1 const port = 3000
> 2 serve(port)
3 await ready()
```
## Line signs and colors
Set a single background for a line, or split gutter and content colors separately with a `LineColorConfig` object:
@@ -169,6 +169,28 @@ const markdown = new MarkdownRenderable(renderer, {
- **`"grid"`**: boxed table with visible borders. Defaults to `borders: true`, `widthMode: "full"`. This is the normal markdown-in-a-box rendering.
- **`"columns"`**: borderless columns with a 2-column gap, defaults to `widthMode: "content"`. Useful for append-only output where a full-width grid feels heavy.
A separate Name/Status sample compares both presets:
Grid:
```text terminal=markdown-table-grid
┌────────┬────────┐
│ Name │ Status │
├────────┼────────┤
│ api │ ready │
├────────┼────────┤
│ worker │ paused │
└────────┴────────┘
```
Columns:
```text terminal=markdown-table-columns
Name Status
api ready
worker paused
```
If you do not pass `style`, it defaults to `"columns"` when `internalBlockMode` is `"top-level"`, and `"grid"` otherwise. You can still override individual fields (for example, `borders: true`) to pull toward a different look.
## Custom node rendering
@@ -103,6 +103,26 @@ Use `fit: "none"` when you want the configured scale to be the only rendered siz
`quietZone` must be at least 4 modules for a standard QR code. OpenTUI validates the matrix and quiet-zone geometry, but scanner reliability still depends on terminal font, cell aspect ratio, display contrast, and camera conditions.
The content `OPENTUI` and a four-module quiet zone fit a version-one symbol:
```text terminal=qr-code-version-one
█▀▀▀▀▀█ ▀█▄▀█ █▀▀▀▀▀█
█ ███ █ ▀ ▀▀█ █ ███ █
█ ▀▀▀ █ ▀ ▀█ █ ▀▀▀ █
▀▀▀▀▀▀▀ █ ▀▄█ ▀▀▀▀▀▀▀
▄▄ ▄ ▀▀▄ █ ▄▀▄▀▄█▄█▄█
▄█▀▀█▄▀ █ ▀▀▀▄▄█ ▀█▄
▀ ▀ ▀▀█ ██▄ ▀▀ ▀ ▀
█▀▀▀▀▀█ ▄▄▀ ▀▀▄ ▀█▄█▀
█ ███ █ ▀███▀█ ▀▄▀
█ ▀▀▀ █ █ ▀▀▀▀▀█▄▀▀█
▀▀▀▀▀▀▀ ▀ ▀ ▀ ▀▀
```
## Fallback content
When a container is too small to render even a scale-1 QR code, show fallback text instead of an empty area:
@@ -49,6 +49,19 @@ renderer.root.add(scrollbar)
scrollbar.focus()
```
```text terminal=scrollbar-arrows
position: 0 / 180 ▲
viewport: 20 / 200 ▼
```
## Keyboard controls
When focused, the scrollbar responds to:
@@ -47,6 +47,19 @@ for (let i = 0; i < 100; i++) {
renderer.root.add(scrollbox)
```
A bordered list of source files initially shows its first five rows:
```text terminal=scrollbox-top
offset: 0 / 7
┌──────────────────────────────┐
│01 src/index.ts ▀│
│02 src/app.ts │
│03 src/layout.ts │
│04 src/theme.ts │
│05 src/events.ts │
└──────────────────────────────┘
```
## Sticky scroll
Enable sticky scroll to keep content pinned to an edge as new content arrives. Set both `stickyScroll` and `stickyStart` because `stickyStart` has no default.
@@ -180,6 +193,19 @@ scrollbox.scrollTo(0)
scrollbox.scrollTo({ x: 0, y: 100 })
```
The same list after `scrollTo(5)`:
```text terminal=scrollbox-scrolled
offset: 5 / 7
┌──────────────────────────────┐
│06 src/input.ts │
│07 src/scroll.ts │
│08 src/render.ts │
│09 src/state.ts ▀│
│10 src/config.ts │
└──────────────────────────────┘
```
### scrollChildIntoView
Scroll the minimum distance needed to show a nested child in the viewport. The method uses DOM-style "nearest"
@@ -30,13 +30,12 @@ const renderer = await createCliRenderer()
const menu = new SelectRenderable(renderer, {
id: "menu",
width: 30,
height: 8,
width: 32,
height: 6,
options: [
{ name: "New File", description: "Create a new file" },
{ name: "Open File", description: "Open an existing file" },
{ name: "Save", description: "Save current file" },
{ name: "Exit", description: "Exit the application" },
{ name: "New file", description: "Create a document" },
{ name: "Open file", description: "Browse existing files" },
{ name: "Save", description: "Write current changes" },
],
})
@@ -48,6 +47,17 @@ menu.focus()
renderer.root.add(menu)
```
After pressing Down, the next option is selected:
```text terminal=select-options surface
New file
Create a document
▶ Open file
Browse existing files
Save
Write current changes
```
## Keyboard navigation
When focused, the select responds to these keys:
@@ -42,6 +42,12 @@ const slider = new SliderRenderable(renderer, {
renderer.root.add(slider)
```
```text terminal=slider-horizontal
value: 25
██▌
0 100
```
## Vertical slider
```typescript
@@ -55,6 +61,19 @@ const slider = new SliderRenderable(renderer, {
})
```
```text terminal=slider-vertical
min 0
▄▄
██
value 0.5 ██
██
██
▀▀
max 1
```
## Properties
| Property | Type | Default | Description |
@@ -30,13 +30,13 @@ const renderer = await createCliRenderer()
const tabs = new TabSelectRenderable(renderer, {
id: "tabs",
width: 60,
width: 36,
options: [
{ name: "Home", description: "Dashboard and overview" },
{ name: "Files", description: "File management" },
{ name: "Settings", description: "Application settings" },
{ name: "Home", description: "View project overview" },
{ name: "Files", description: "Browse project files" },
{ name: "Settings", description: "Configure the project" },
],
tabWidth: 20,
tabWidth: 12,
})
tabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index, option) => {
@@ -47,6 +47,14 @@ tabs.focus()
renderer.root.add(tabs)
```
After pressing Right, Files is selected:
```text terminal=tab-select-tabs surface
Home Files Settings
▬▬▬▬▬▬▬▬▬▬▬▬
Browse project files
```
## Keyboard navigation
When focused, the tab select responds to these keys:
@@ -45,6 +45,18 @@ const table = new TextTableRenderable(renderer, {
renderer.root.add(table)
```
Monochrome preview of the same table:
```text terminal=text-table-styled
╭───────┬────────┬────────────────╮
│Service│Status │Notes │
├───────┼────────┼────────────────┤
│api │OK │latency 28ms │
├───────┼────────┼────────────────┤
│worker │DEGRADED│queue depth: 124│
╰───────┴────────┴────────────────╯
```
## Content
The public content types are:
@@ -53,6 +53,14 @@ const styledText = new TextRenderable(renderer, {
})
```
Bold, italic, and underline can also be applied independently:
```text terminal=text-attributes
bold Important message
italic Additional context
underline Documentation
```
### Available attributes
| Attribute | Description |
@@ -41,6 +41,20 @@ renderer.root.add(textarea)
textarea.focus()
```
At 30 columns, longer text wraps at word boundaries:
```typescript
textarea.width = 30
textarea.setText("Long lines wrap at word boundaries.\nKeep paragraphs readable.")
```
```text terminal=textarea-wrap surface
Notes
Long lines wrap at word
boundaries.
Keep paragraphs readable.
```
## Submit handling
Bind a submit action and listen for `onSubmit`:
@@ -151,6 +165,15 @@ textarea.clearSelection()
textarea.deleteSelection()
```
Selecting `keyboard focus` in a draft:
```text terminal=textarea-selection surface
Draft
Plan the release
Review keyboard focus
Ship the update
```
### Editing
```typescript
@@ -62,6 +62,21 @@ Both constructors convert a non-finite channel to `0`. Channel setters apply the
An alpha value of zero is transparent. Intermediate alpha values blend during supported buffer-composition operations.
```typescript
const overlay = RGBA.fromHex("#22c55e")
overlay.a = 0.5
buffer.fillRect(x, y, width, height, overlay)
```
These tiles draw the same green over a checkerboard. Only alpha changes:
```text terminal=color-alpha
0.00 0.25 0.50 0.75 1.00
```
After a blend, the result is a literal RGB color. It no longer identifies a terminal default or indexed palette slot.
See [FrameBuffer](/docs/components/frame-buffer) and the [Buffer API](/docs/reference/buffer-api) for direct alpha drawing.
@@ -89,6 +104,33 @@ console.log(foreground.intent, background.intent)
`fromIndex()` requires an integer from `0` through `255` and throws `RangeError` otherwise. Its default RGB snapshot comes from the built-in ANSI 256-color table.
The fallback palette contains 16 terminal colors, a 216-color RGB cube, and 24 grays. Within each cube slice, blue increases to the right and green increases downward:
```text terminal=color-palette
0-15 terminal colors
████████████████████████████████
16-231 RGB cube
R=0 R=95 R=135
████████████ ████████████ ████████████
████████████ ████████████ ████████████
████████████ ████████████ ████████████
████████████ ████████████ ████████████
████████████ ████████████ ████████████
████████████ ████████████ ████████████
R=175 R=215 R=255
████████████ ████████████ ████████████
████████████ ████████████ ████████████
████████████ ████████████ ████████████
████████████ ████████████ ████████████
████████████ ████████████ ████████████
████████████ ████████████ ████████████
232-255 grayscale
████████████████████████
```
Default foreground uses `[255, 255, 255]` as its snapshot. Default background uses `[0, 0, 0]`.
An optional snapshot supplies RGBA data for blending and early frames. It does not change the indexed or default intent.
@@ -71,6 +71,22 @@ The hit grid obeys clipping from `overflow: "hidden"` and `"scroll"`. A layout c
Mouse events start at the hit target and bubble through `parent` links. `event.stopPropagation()` prevents later ancestors from receiving that event.
In this overlapping pair, the front box receives both mouse downs. The first bubbles to `parent`; the second calls
`stopPropagation()` in the front box's handler.
```text terminal=interaction-hit-bubbling
┌─parent─────────────────────────────┐
│ │
│ ┌─back z=0─────────────┐ │
│ │ ┌─front z=1─────────────┐ │
│ └────────│ │ │
│ └───────────────────────┘ │
└────────────────────────────────────┘
target: front
bubble: front -> parent
stopped: front
```
`event.preventDefault()` has only defined renderer defaults. On left-button `down`, it prevents automatic focus and the post-dispatch selection clear.
It does not stop propagation. It also does not undo a new selection that already started on selectable text.
@@ -95,6 +111,20 @@ Each renderer tracks at most one focused renderable. Focusing another renderable
Use `focus()` and `blur()` for explicit control. Listen for `RenderableEvents.FOCUSED` and `RenderableEvents.BLURRED` on the instance.
Focusing an input does not clear a selection in another renderable:
```typescript
input.focus()
input.value = "deploy --check"
```
```text terminal=interaction-selection-focus surface
Select text, then focus input
Command
deploy --check
```
By default, a left-button down focuses the nearest focusable target or ancestor. Set renderer `autoFocus: false` to disable this behavior.
OpenTUI Core has no automatic Tab traversal or focus-order property. Your application must choose the next renderable and call `focus()`.
@@ -174,6 +204,16 @@ These offsets form a half-open range from the start of that text buffer. They co
The offsets are not UTF-16 indexes. Selection boundaries snap around complete grapheme clusters, including a cluster that spans multiple cells.
A drag can span lines. Here, the selected text includes the line break after `app`, and the local range ends after `Test`.
```text terminal=interaction-drag-selection surface
Build the app
Test the input
Selected: "the app\nTest"
Range: [6, 18)
```
Read [Text and terminal cells](/docs/core-concepts/text-and-cells) before you combine selection offsets with JavaScript string methods.
## Test interaction
@@ -12,24 +12,51 @@ OpenTUI uses Yoga to compute a renderable tree on a grid of terminal cells. It s
A horizontal size is a count of terminal columns. A vertical size is a count of terminal rows. These values are never character counts.
## Spacing and growth
```typescript
import { BoxRenderable, TextRenderable, createCliRenderer } from "@opentui/core"
import { BoxRenderable, RGBA, TextRenderable, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const foreground = RGBA.defaultForeground()
const fill = RGBA.fromIndex(238)
const row = new BoxRenderable(renderer, {
width: "100%",
width: 34,
height: 5,
border: true,
borderColor: foreground,
padding: 1,
flexDirection: "row",
alignItems: "center",
gap: 1,
gap: 2,
})
row.add(new TextRenderable(renderer, { content: "Status" }))
row.add(new TextRenderable(renderer, { content: "Ready", flexGrow: 1 }))
const fixed = new BoxRenderable(renderer, { width: 8, height: 1, backgroundColor: fill })
fixed.add(new TextRenderable(renderer, { content: "fixed 8", fg: foreground }))
const growing = new BoxRenderable(renderer, {
flexGrow: 1,
flexBasis: 0,
height: 1,
backgroundColor: fill,
})
growing.add(new TextRenderable(renderer, { content: "grow 20", fg: foreground }))
row.add(fixed)
row.add(growing)
renderer.root.add(row)
```
The shaded children occupy 8 and 20 columns. The border and padding consume four columns; the gap consumes two more.
```text terminal=layout-flex-columns
┌────────────────────────────────┐
│ │
│ fixed 8 grow 20 │
│ │
└────────────────────────────────┘
```
The same layout properties work across the built-in renderable classes.
## Supported options
@@ -63,6 +90,51 @@ Yoga align values are `"auto"`, `"flex-start"`, `"center"`, `"flex-end"`, `"stre
The public TypeScript interface includes `"auto"` for min and max dimensions. The current runtime ignores that value for those four options.
## Alignment
In a row, `justifyContent` distributes horizontal space and `alignItems` positions children vertically. A child can override the vertical alignment with `alignSelf`.
```typescript
const row = new BoxRenderable(renderer, {
width: 32,
height: 7,
border: true,
borderColor: foreground,
paddingX: 1,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
})
for (const [label, height] of [
["A", 1],
["B", 3],
["C", 1],
] as const) {
const child = new BoxRenderable(renderer, {
width: 6,
height,
alignSelf: label === "C" ? "flex-end" : "auto",
backgroundColor: fill,
})
child.add(new TextRenderable(renderer, { content: label, fg: foreground }))
row.add(child)
}
renderer.root.add(row)
```
A and B share a vertical center despite their different heights. C sits at the bottom.
```text terminal=layout-alignment
┌──────────────────────────────┐
│ │
│ B │
│ A │
│ │
│ C │
└──────────────────────────────┘
```
## Automatic and intrinsic size
An `"auto"` dimension lets Yoga derive size from children or a renderable's measure function. Text and editor renderables supply native measure targets.
@@ -91,12 +163,81 @@ Yoga uses a point scale factor of `1`. It rounds computed edges to whole termina
Percentage and flex calculations can produce fractions before this step. Adjacent children can therefore receive different rounded widths.
These three children use the same grow factor and zero basis:
```typescript
const child = new BoxRenderable(renderer, {
flexGrow: 1,
flexBasis: 0,
minWidth: 0,
})
```
A 32-column bordered row leaves 30 inner columns. Adding one column gives the middle child an extra cell, without gaps or overlaps:
```text terminal=layout-cell-rounding
32 columns: 30 inside
┌──────────────────────────────┐
│ 10 10 10 │
└──────────────────────────────┘
33 columns: 31 inside
┌───────────────────────────────┐
│ 10 11 10 │
└───────────────────────────────┘
```
Renderable getters expose computed integer geometry after a layout pass. OpenTUI clamps exposed `width` and `height` to at least one cell.
## Resize behavior
A terminal resize keeps the same renderable instances. The renderer resizes its root and runs Yoga again with the new column and row counts.
With `flexWrap: "wrap"`, whole children move to the next row when they no longer fit. The container's automatic height grows to include them:
```typescript
const row = new BoxRenderable(renderer, {
width: "100%",
border: true,
borderColor: foreground,
paddingX: 1,
flexDirection: "row",
flexWrap: "wrap",
alignItems: "flex-start",
columnGap: 2,
rowGap: 1,
})
for (const content of ["A", "B", "C"]) {
const child = new BoxRenderable(renderer, {
width: 8,
height: 1,
flexShrink: 0,
backgroundColor: fill,
})
child.add(new TextRenderable(renderer, { content, fg: foreground }))
row.add(child)
}
renderer.root.add(row)
```
The same children before and after narrowing the terminal:
```text terminal=layout-wrap-wide
32 columns
┌──────────────────────────────┐
│ A B C │
└──────────────────────────────┘
```
```text terminal=layout-wrap-narrow
22 columns
┌────────────────────┐
│ A B │
│ │
│ C │
└────────────────────┘
```
Computed `x`, `y`, `width`, and `height` can all change. Buffered renderables resize their frame buffers before `onResize(width, height)` runs.
`FrameBufferRenderable` resizes its public `frameBuffer` in its `onResize()` override, then calls the base hook.
@@ -7,28 +7,57 @@ description: Create, mutate, reparent, and destroy imperative renderable tree no
A renderable is an imperative node in OpenTUI's retained tree. It stores layout, visual state, children, event handlers, and native resources.
Retained means the same objects stay in the tree between frames. Change their properties instead of rebuilding them for each update.
## Create and update
Create a renderable with a render context. A `CliRenderer` implements that context.
```typescript
import { BoxRenderable, TextRenderable, createCliRenderer } from "@opentui/core"
import { BoxRenderable, RGBA, TextRenderable, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const foreground = RGBA.defaultForeground()
const fill = RGBA.fromIndex(243)
const panel = new BoxRenderable(renderer, {
id: "panel",
width: 30,
padding: 1,
width: 18,
height: 3,
paddingX: 1,
border: true,
borderColor: foreground,
backgroundColor: fill,
})
const status = new TextRenderable(renderer, {
id: "status",
content: "Waiting",
fg: foreground,
})
panel.add(status)
renderer.root.add(panel)
```
The shaded panel contains a text child:
```text terminal=renderable-created
┌────────────────┐
│ Waiting │
└────────────────┘
```
Update the existing nodes. The wider panel also gives its text child more space:
```typescript
status.content = "Ready"
panel.width = 30
```
```text terminal=renderable-mutated
┌────────────────────────────┐
│ Ready │
└────────────────────────────┘
```
Setters such as `content`, `width`, `visible`, and `zIndex` request another render when their state changes.
@@ -38,17 +67,53 @@ Setters such as `content`, `width`, `visible`, and `zIndex` request another rend
Each renderable has at most one parent. `add()` reparents an existing node when necessary.
```typescript
const first = new BoxRenderable(renderer, { id: "first" })
const second = new BoxRenderable(renderer, { id: "second" })
const child = new TextRenderable(renderer, { id: "message", content: "Moved" })
const row = new BoxRenderable(renderer, { width: 34, flexDirection: "row", gap: 2 })
renderer.root.add(row)
first.add(child)
second.add(child)
const [first, second] = ["first", "second"].map((id) => {
const parent = new BoxRenderable(renderer, {
id,
title: id,
width: 16,
height: 3,
paddingX: 1,
border: true,
borderColor: foreground,
})
row.add(parent)
return parent
})
const message = new BoxRenderable(renderer, {
id: "message",
width: 10,
height: 1,
backgroundColor: fill,
})
message.add(new TextRenderable(renderer, { content: "Message", fg: foreground }))
first.add(message)
```
console.log(child.parent === second) // true
```text terminal=renderable-reparent-before
┌─first────────┐ ┌─second───────┐
│ Message │ │ │
└──────────────┘ └──────────────┘
```
Add the same subtree to `second`. You do not need to remove it from `first`:
```typescript
second.add(message)
console.log(message.parent === second) // true
console.log(first.getChildrenCount()) // 0
```
```text terminal=renderable-reparent-after
┌─first────────┐ ┌─second───────┐
│ │ │ Message │
└──────────────┘ └──────────────┘
```
`add(child, index)` inserts at an index. `insertBefore(child, anchor)` inserts before a direct child. Both methods return the inserted index, or `-1` when they cannot add the value.
Use these methods to inspect the tree:
@@ -58,18 +123,60 @@ Use these methods to inspect the tree:
- `getRenderable(id)` finds a direct child.
- `findDescendantById(id)` searches descendants recursively.
`remove(child)` only detaches a direct child. It does not destroy that child, so you can add the child elsewhere.
Reparenting calls `remove()` on the old parent. As a result, `onRemove()` runs for a temporary detach and for a reparent.
Do not release final owned resources in `onRemove()`. Release them in `destroySelf()`, which runs only during destruction. See [Lifecycle and cleanup](/docs/core-concepts/lifecycle).
## Hide or detach
Hiding a child keeps it in the tree. `remove(child)` detaches a direct child without destroying it.
Start with two children:
```typescript
const panel = new BoxRenderable(renderer, {
width: 12,
height: 4,
border: true,
borderColor: foreground,
})
const detail = new BoxRenderable(renderer, { height: 1, backgroundColor: fill })
detail.add(new TextRenderable(renderer, { content: "Detail", fg: foreground }))
const next = new TextRenderable(renderer, { content: "Next", fg: foreground })
panel.add(detail)
panel.add(next)
renderer.root.add(panel)
```
Both hiding and detaching move `Next` up. Only detaching changes tree membership:
```typescript
detail.visible = false
console.log(panel.getChildrenCount(), detail.parent === panel) // 2, true
detail.visible = true
panel.remove(detail)
console.log(panel.getChildrenCount(), detail.parent) // 1, null
```
```text terminal=renderable-visibility
visible hidden detached
┌──────────┐ ┌──────────┐ ┌──────────┐
│Detail │ │Next │ │Next │
│Next │ │ │ │ │
└──────────┘ └──────────┘ └──────────┘
children: 2 children: 2 children: 1
parent: yes parent: yes parent: no
```
Neither operation destroys `detail`. Set `visible = true` to show a hidden child, or call `add(detail)` to reattach a detached child. Reattaching does not change its visibility.
`visible = false` sets the Yoga node to `display: none`. The node does not receive layout or render work while hidden. Hiding a focused node also blurs it.
## Layout properties
Renderables participate in the [Yoga-based layout model](/docs/core-concepts/layout). Their computed `x`, `y`, `width`, and `height` can change after layout or terminal resize.
`visible = false` sets the Yoga node to `display: none`. The node does not receive layout or render work while hidden. Hiding a focused node also blurs it.
`zIndex` changes sibling render and hit-test order without changing layout order. `translateX` and `translateY` move drawing and hit bounds without changing the Yoga result.
`opacity` applies to the node and its descendants. `overflow: "hidden"` or `"scroll"` clips rendering and mouse hit bounds to the node.
@@ -83,10 +190,13 @@ Shared mouse, focus, and selection behavior belongs to [Interaction, focus, and
`destroy()` detaches direct children but does not destroy them. Use `destroyRecursively()` when this node owns the complete subtree.
```typescript
detail.destroyRecursively()
panel.destroyRecursively()
```
The renderer destroys its root recursively during renderer cleanup. Do not add a destroyed renderable to another parent.
The renderer destroys its root recursively during renderer cleanup. Destroy detached subtrees yourself when you no longer need them.
Do not add a destroyed renderable to another parent.
Subclass render hooks, measurement, buffering, and resource examples belong to [Custom renderables](/docs/extend/custom-renderables).
@@ -16,13 +16,21 @@ String length and terminal width are different values. Keep that distinction whe
Use `t` as a template tag. Style helpers return chunks that you can insert into the template.
```typescript
import { TextRenderable, bold, fg, link, t, underline, createCliRenderer } from "@opentui/core"
import { RGBA, TextRenderable, bold, italic, t, underline, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
const content = t`${bold("Status")}: ${fg("#22c55e")("ready")} ${link("https://opentui.dev")(underline("docs"))}`
const content = t`${bold("Status")}: ready\n${italic("Note")}: saved locally\n${bold(underline("Next"))}: review changes`
renderer.root.add(new TextRenderable(renderer, { content }))
renderer.root.add(new TextRenderable(renderer, { content, fg: RGBA.defaultForeground() }))
```
`Status` is bold, `Note` is italic, and `Next` combines bold and underline. The rest of each line keeps its default attributes:
```text terminal=text-styled-chunks surface
Status: ready
Note: saved locally
Next: review changes
```
The color helpers include normal, bright, and background named colors. The text helpers include `bold`, `italic`, `underline`, `strikethrough`, `dim`, `blink`, and `reverse`.
@@ -68,11 +76,46 @@ The first occupied cell stores the grapheme reference and its extent. Remaining
Continuation cells prevent later drawing and diffing from treating the tail of a wide grapheme as independent text. They contain no separate printable character.
Both `"ABC".length` and `"A界B".length` are `3`. On this zero-based cell ruler, `界` occupies columns 1 and 2. It pushes `B` to column 3 and the following `|` to column 4:
```text terminal=text-cell-ruler
column 01234
ASCII ABC| 3 cells
wide A界B| 4 cells
```
The stronger background shade marks the two cells occupied by `界`.
The measured width is a column count, not a JavaScript string length. A string with length `2` can still occupy one or two terminal cells.
## Wrap text
Wrapping and truncation operate on display width. They do not split a grapheme to make it fit at a line boundary.
Text renderables support `wrapMode: "word"`, `"char"`, or `"none"`. The default is `"word"`.
The measured width is a column count. For example, a JavaScript string with length `2` can still occupy one or two terminal cells.
```typescript
const text = new TextRenderable(renderer, {
content: "A界B",
width: 2,
wrapMode: "char",
fg: RGBA.defaultForeground(),
})
renderer.root.add(text)
```
The same text at widths of two, three, and four columns:
```text terminal=text-wide-wrap
2 columns 3 columns 4 columns
01 012 0123
A A界 A界B
界 B
B
```
At two columns, `界` cannot fit beside `A`, so it moves intact to the next row. The shaded area marks each view's cell bounds.
## Use buffer text operations
@@ -100,6 +143,18 @@ Text-buffer selection uses a half-open range `{ start, end }`. Both values are g
Each logical line break adds one offset unit. Soft wrapping does not add a unit because it does not add text.
These three-column views look the same, but they select `B` at different offsets. `"A界B"` uses `[3, 4)`; `"A界\nB"` uses `[4, 5)` because the newline adds one unit:
```text terminal=text-line-offsets
soft wrap newline
012 012
A界 A界
B B
range [3, 4) range [4, 5)
```
The highlighted cell marks the selection. Both views return `"B"` from `getSelectedText()`.
Selection extraction snaps boundaries to complete grapheme clusters. A boundary inside a wide grapheme moves to include or exclude the complete cluster.
These offsets are not UTF-16 indexes. Do not pass them directly to `String.prototype.slice()` for text that can contain wide or combined graphemes.
@@ -59,6 +59,11 @@ keymap.registerLayer({
Each registration returns a disposer. Destroying the renderer also ends the host lifecycle and releases keymap resources.
See [Lifecycle and cleanup](/docs/core-concepts/lifecycle) for application shutdown.
```text terminal=keymap-active-keys
ctrl+s file.save
q app.quit
```
## Register, dispatch, and query
Keymap uses one short model:
@@ -162,6 +162,12 @@ Every slot mount or `<Slot>` component accepts a mode.
| `replace` | Show contribution output. Show the fallback when no contribution has output. |
| `single_winner` | Show only the first contribution. Show the fallback when it has no output. |
```text terminal=plugin-slot-modes
append host clock sync
replace clock sync
single_winner clock
```
## Resolve contributions
```typescript
File diff suppressed because it is too large Load Diff
@@ -541,10 +541,6 @@
updateToggle()
function advanceStory() {
if (stories.length < 2) {
setupStory(storyIndex)
return
}
setupStory((storyIndex + 1) % stories.length)
paint()
renderCaption()
@@ -642,8 +638,6 @@
}
})
if (reduceMotion) return
if ("IntersectionObserver" in window) {
new IntersectionObserver(
function (entries) {
@@ -651,7 +645,7 @@
return entry.isIntersecting
})
if (isIntersecting) {
if (!hasStarted || resumeAfterIntersection) {
if ((!hasStarted && !reduceMotion) || resumeAfterIntersection) {
resumeAfterIntersection = false
play()
}
@@ -664,7 +658,7 @@
).observe(illustration)
} else {
isIntersecting = true
play()
if (!reduceMotion) play()
}
}
+52
View File
@@ -156,6 +156,7 @@
min-width: min(100%, var(--content-width));
max-width: 100%;
margin-block: 0 1.5rem;
margin-inline: 0;
overflow-x: auto;
}
@@ -171,6 +172,57 @@
font-variant-ligatures: none;
}
.prose .terminal-frame {
/* Keep ambiguous-width symbols in the same single cells as the terminal. */
font-feature-settings: "NWID";
--terminal-color-235: color-mix(in srgb, var(--page-color) 7%, var(--page-background));
--terminal-color-238: color-mix(in srgb, var(--page-color) 16%, var(--page-background));
--terminal-color-243: var(--terminal-color-238);
--terminal-color-244: color-mix(in srgb, var(--page-color) 65%, var(--page-background));
--terminal-color-247: color-mix(in srgb, var(--page-color) 82%, var(--page-background));
}
:root[data-theme="blue"] .prose .terminal-frame {
--terminal-color-244: color-mix(in srgb, var(--page-color) 80%, var(--page-background));
--terminal-color-247: color-mix(in srgb, var(--page-color) 90%, var(--page-background));
}
.prose .box-figure pre.terminal-frame--surface {
--terminal-background: var(--terminal-color-235);
padding: 1.25em 2ch;
background: var(--terminal-background);
}
.prose .terminal-frame__row {
position: relative;
display: block;
height: 1.25em;
line-height: 1.25em;
}
.prose .terminal-frame__span {
display: inline-block;
height: 100%;
line-height: inherit;
vertical-align: top;
}
.prose .terminal-frame__cursor {
position: absolute;
inset-block: 0;
width: 1ch;
background: var(--page-color);
}
.prose .terminal-frame__cursor--line {
width: 1px;
}
.prose .terminal-frame__cursor--underline {
inset-block-start: auto;
height: 2px;
}
.prose .code-block pre {
width: 100%;
min-width: 0;