mirror of
https://github.com/anomalyco/opentui.git
synced 2026-09-19 01:26:03 +08:00
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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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})`
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user