introduce TextTable renderable (#731)

---------

Co-authored-by: Simon Klee <hello@simonklee.dk>
This commit is contained in:
Sebastian
2026-02-23 22:00:59 +01:00
committed by GitHub
parent ba8822eec7
commit 50f1252977
13 changed files with 3413 additions and 1 deletions
+1
View File
@@ -20,6 +20,7 @@
"build:examples": "bun src/examples/build.ts",
"test:native": "cd src/zig && zig build test --summary all",
"bench:native": "cd src/zig && zig build bench -Dbench-optimize=ReleaseFast --",
"bench:text-table": "bun src/benchmark/text-table-benchmark.ts",
"bench:ts": "bun src/benchmark/native-span-feed-benchmark.ts --suite=quick --json=src/benchmark/latest-quick-bench-run.json && bun src/benchmark/native-span-feed-benchmark.ts --suite=default --json=src/benchmark/latest-default-bench-run.json && bun src/benchmark/native-span-feed-benchmark.ts --suite=large --json=src/benchmark/latest-large-bench-run.json && bun src/benchmark/native-span-feed-benchmark.ts --suite=all --json=src/benchmark/latest-all-bench-run.json && bun src/benchmark/native-span-feed-async-benchmark.ts --json=src/benchmark/latest-async-bench-run.json",
"publish": "bun scripts/publish.ts",
"test:js": "bun test",
@@ -0,0 +1,846 @@
#!/usr/bin/env bun
import { TextTableRenderable, type TextTableCellContent, type TextTableContent, type CliRenderer } from "../index"
import { createTestRenderer } from "../testing"
import { Command } from "commander"
import { existsSync } from "node:fs"
import { mkdir } from "node:fs/promises"
import path from "node:path"
const realStdoutWrite = process.stdout.write.bind(process.stdout)
const WORDS = [
"alpha",
"bravo",
"charlie",
"delta",
"echo",
"foxtrot",
"golf",
"hotel",
"india",
"juliet",
"kilo",
"lima",
"mango",
"nectar",
"oscar",
"papa",
"quartz",
"romeo",
"sierra",
"tango",
"uniform",
"vector",
"whiskey",
"xray",
"yankee",
"zulu",
"matrix",
"signal",
"tensor",
"render",
"schema",
"buffer",
"layout",
"stream",
"parser",
"syntax",
"viewport",
"cursor",
]
type MemorySample = {
rss: number
heapTotal: number
heapUsed: number
external: number
arrayBuffers: number
}
type MemoryStats = {
samples: number
start: MemorySample
end: MemorySample
delta: MemorySample
peak: MemorySample
}
type TimingStats = {
count: number
averageMs: number
medianMs: number
p95Ms: number
minMs: number
maxMs: number
stdDevMs: number
}
type ScenarioResult = {
name: string
description: string
category: "replace" | "incremental" | "selection"
timingMode: "content-set-and-render" | "selection-update-and-render"
iterations: number
warmupIterations: number
elapsedMs: number
updateStats: TimingStats
memoryStats?: MemoryStats
tableStats: {
initialRows: number
finalRows: number
maxRows: number
columns: number
updates: number
datasetVariants: number
}
settings: Record<string, unknown>
}
type ReplaceScenarioPlan = {
kind: "replace"
name: string
description: string
iterations: number
warmupIterations: number
rows: number
cols: number
variants: TextTableContent[]
}
type IncrementalScenarioPlan = {
kind: "incremental"
name: string
description: string
iterations: number
warmupIterations: number
cols: number
header: TextTableCellContent[]
baseRows: TextTableCellContent[][]
rowPool: TextTableCellContent[][]
maxRows: number
}
type SelectionScenarioPlan = {
kind: "selection"
name: string
description: string
iterations: number
warmupIterations: number
rows: number
cols: number
content: TextTableContent
dragSteps: number
}
type ScenarioPlan = ReplaceScenarioPlan | IncrementalScenarioPlan | SelectionScenarioPlan
type RunContext = {
renderer: CliRenderer
table: TextTableRenderable
renderOnce: () => Promise<void>
memSampleEvery: number
}
type SuiteConfig = {
iterations: number
warmupIterations: number
longIterations: number
scale: number
}
type OutputMeta = {
suiteName: string
width: number
height: number
iterations: number
warmupIterations: number
longIterations: number
scale: number
seed: number
memSampleEvery: number
}
type IncrementalState = {
rows: TextTableCellContent[][]
cursor: number
maxRowsSeen: number
}
const program = new Command()
program
.name("text-table-benchmark")
.description("TextTableRenderable benchmark scenarios")
.option("-s, --suite <name>", "benchmark suite: quick, default, long", "default")
.option("-i, --iterations <count>", "iterations per scenario", "800")
.option("--warmup-iterations <count>", "warmup iterations per scenario", "80")
.option("--long-iterations <count>", "iterations for long suite", "3000")
.option("--scale <n>", "scale rows and dataset size", "1")
.option("--seed <n>", "seed for deterministic content", "1337")
.option("--width <n>", "test renderer width", "140")
.option("--height <n>", "test renderer height", "48")
.option("--mem-sample-every <count>", "sample memory every N iterations (0 disables)", "10")
.option("--scenario <name>", "run a single scenario")
.option("--json [path]", "write JSON results to file")
.option("--no-output", "suppress stdout output")
.parse(process.argv)
const options = program.opts()
const suiteName = String(options.suite)
const iterations = Math.max(1, Math.floor(toNumber(options.iterations, 800)))
const warmupIterations = Math.max(0, Math.floor(toNumber(options.warmupIterations, 80)))
const longIterations = Math.max(iterations, Math.floor(toNumber(options.longIterations, 3000)))
const scale = Math.max(0.25, toNumber(options.scale, 1))
const seed = Math.max(1, Math.floor(toNumber(options.seed, 1337)))
const width = Math.max(40, Math.floor(toNumber(options.width, 140)))
const height = Math.max(12, Math.floor(toNumber(options.height, 48)))
const memSampleEvery = Math.max(0, Math.floor(toNumber(options.memSampleEvery, 10)))
const scenarioFilter = options.scenario ? String(options.scenario) : null
const outputEnabled = options.output !== false
const jsonArg = options.json
const jsonPath =
typeof jsonArg === "string"
? path.resolve(process.cwd(), jsonArg)
: jsonArg
? path.resolve(process.cwd(), "latest-text-table-bench-run.json")
: null
if (jsonPath) {
const dir = path.dirname(jsonPath)
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true })
}
if (existsSync(jsonPath)) {
console.error(`Error: output file already exists: ${jsonPath}`)
process.exit(1)
}
}
const scenarios = createScenarios(
suiteName,
{
iterations,
warmupIterations,
longIterations,
scale,
},
seed,
)
if (scenarios.length === 0) {
console.error(`Unknown suite: ${suiteName}`)
process.exit(1)
}
const filteredScenarios = scenarioFilter ? scenarios.filter((scenario) => scenario.name === scenarioFilter) : scenarios
if (scenarioFilter && filteredScenarios.length === 0) {
writeLine(`Unknown scenario: ${scenarioFilter}`)
process.exit(1)
}
const { renderer, renderOnce } = await createTestRenderer({
width,
height,
useAlternateScreen: false,
useConsole: false,
})
renderer.requestRender = () => {}
const table = new TextTableRenderable(renderer, {
id: "text-table-bench",
width: "100%",
wrapMode: "word",
content: [],
})
renderer.root.add(table)
await renderOnce()
const ctx: RunContext = {
renderer,
table,
renderOnce,
memSampleEvery,
}
const results: ScenarioResult[] = []
const scenarioLines: string[] = []
try {
for (const plan of filteredScenarios) {
const result = await runScenario(plan, ctx)
results.push(result)
scenarioLines.push(formatScenarioResult(result))
}
} finally {
renderer.destroy()
}
await outputResults(
{
suiteName,
width,
height,
iterations,
warmupIterations,
longIterations,
scale,
seed,
memSampleEvery,
},
results,
scenarioLines,
outputEnabled,
jsonPath,
)
function createScenarios(suite: string, config: SuiteConfig, runSeed: number): ScenarioPlan[] {
const quick = {
replaceRows: scaled(24, config.scale),
replaceCols: 4,
replaceVariants: scaled(6, config.scale),
incrementalCols: 4,
incrementalBaseRows: scaled(8, config.scale),
incrementalPoolRows: scaled(220, config.scale),
incrementalMaxRows: scaled(120, config.scale),
}
const defaultSuite = {
replaceRows: scaled(72, config.scale),
replaceCols: 6,
replaceVariants: scaled(10, config.scale),
incrementalCols: 6,
incrementalBaseRows: scaled(16, config.scale),
incrementalPoolRows: scaled(480, config.scale),
incrementalMaxRows: scaled(320, config.scale),
}
const long = {
replaceRows: scaled(140, config.scale),
replaceCols: 8,
replaceVariants: scaled(14, config.scale),
incrementalCols: 8,
incrementalBaseRows: scaled(24, config.scale),
incrementalPoolRows: scaled(960, config.scale),
incrementalMaxRows: scaled(720, config.scale),
}
let shape: typeof quick
let runIterations = config.iterations
if (suite === "quick") {
shape = quick
} else if (suite === "default") {
shape = defaultSuite
} else if (suite === "long") {
shape = long
runIterations = config.longIterations
} else {
return []
}
const replaceRng = createRng((runSeed ^ 0x9e3779b9) >>> 0)
const variants: TextTableContent[] = []
for (let i = 0; i < shape.replaceVariants; i += 1) {
variants.push(buildTableContent(replaceRng, shape.replaceRows, shape.replaceCols))
}
const incrementalRng = createRng((runSeed ^ 0x85ebca6b) >>> 0)
const header = makeHeader(shape.incrementalCols)
const baseRows = buildRows(incrementalRng, shape.incrementalBaseRows, shape.incrementalCols, 0)
const rowPool = buildRows(
incrementalRng,
Math.max(shape.incrementalPoolRows, shape.incrementalBaseRows + 1),
shape.incrementalCols,
shape.incrementalBaseRows,
)
const replaceScenario: ReplaceScenarioPlan = {
kind: "replace",
name: "replace_tables",
description: "Replace full table content with prebuilt variants",
iterations: runIterations,
warmupIterations: config.warmupIterations,
rows: shape.replaceRows,
cols: shape.replaceCols,
variants,
}
const incrementalScenario: IncrementalScenarioPlan = {
kind: "incremental",
name: "incremental_table_rows",
description: "Append table rows and periodically reset to base size",
iterations: runIterations,
warmupIterations: config.warmupIterations,
cols: shape.incrementalCols,
header,
baseRows,
rowPool,
maxRows: Math.max(shape.incrementalMaxRows, shape.incrementalBaseRows + 1),
}
const selectionRng = createRng((runSeed ^ 0xa2f9c6d1) >>> 0)
const selectionContent = buildTableContent(selectionRng, shape.replaceRows, shape.replaceCols)
const selectionScenario: SelectionScenarioPlan = {
kind: "selection",
name: "selection_update",
description: "Update selection focus across rows and render",
iterations: runIterations,
warmupIterations: config.warmupIterations,
rows: shape.replaceRows,
cols: shape.replaceCols,
content: selectionContent,
dragSteps: 5,
}
return [replaceScenario, incrementalScenario, selectionScenario]
}
async function runScenario(plan: ScenarioPlan, ctx: RunContext): Promise<ScenarioResult> {
if (plan.kind === "replace") {
return runReplaceScenario(plan, ctx)
}
if (plan.kind === "incremental") {
return runIncrementalScenario(plan, ctx)
}
return runSelectionScenario(plan, ctx)
}
async function runReplaceScenario(plan: ReplaceScenarioPlan, ctx: RunContext): Promise<ScenarioResult> {
for (let i = 0; i < plan.warmupIterations; i += 1) {
const variant = plan.variants[i % plan.variants.length]
ctx.table.content = variant
await ctx.renderOnce()
}
const durations: number[] = []
const measurementStart = Date.now()
const memStart = shouldSampleMemory(ctx.memSampleEvery) ? readMemorySample() : null
const memSamples: MemorySample[] = []
for (let i = 0; i < plan.iterations; i += 1) {
const variant = plan.variants[i % plan.variants.length]
const start = performance.now()
ctx.table.content = variant
await ctx.renderOnce()
durations.push(performance.now() - start)
if (ctx.memSampleEvery > 0 && (i + 1) % ctx.memSampleEvery === 0) {
memSamples.push(readMemorySample())
}
}
const elapsedMs = Date.now() - measurementStart
const memEnd = shouldSampleMemory(ctx.memSampleEvery) ? readMemorySample() : null
return {
name: plan.name,
description: plan.description,
category: "replace",
timingMode: "content-set-and-render",
iterations: plan.iterations,
warmupIterations: plan.warmupIterations,
elapsedMs,
updateStats: computeTimingStats(durations),
memoryStats: memStart && memEnd ? computeMemoryStats(memSamples, memStart, memEnd) : undefined,
tableStats: {
initialRows: plan.rows,
finalRows: plan.rows,
maxRows: plan.rows,
columns: plan.cols,
updates: plan.iterations,
datasetVariants: plan.variants.length,
},
settings: {
rows: plan.rows,
cols: plan.cols,
variants: plan.variants.length,
mode: "replace",
},
}
}
async function runIncrementalScenario(plan: IncrementalScenarioPlan, ctx: RunContext): Promise<ScenarioResult> {
const state: IncrementalState = {
rows: [...plan.baseRows],
cursor: 0,
maxRowsSeen: plan.baseRows.length,
}
ctx.table.content = [plan.header, ...state.rows]
await ctx.renderOnce()
for (let i = 0; i < plan.warmupIterations; i += 1) {
const next = nextIncrementalContent(plan, state)
ctx.table.content = next
await ctx.renderOnce()
}
const durations: number[] = []
const measurementStart = Date.now()
const memStart = shouldSampleMemory(ctx.memSampleEvery) ? readMemorySample() : null
const memSamples: MemorySample[] = []
for (let i = 0; i < plan.iterations; i += 1) {
const next = nextIncrementalContent(plan, state)
const start = performance.now()
ctx.table.content = next
await ctx.renderOnce()
durations.push(performance.now() - start)
if (ctx.memSampleEvery > 0 && (i + 1) % ctx.memSampleEvery === 0) {
memSamples.push(readMemorySample())
}
}
const elapsedMs = Date.now() - measurementStart
const memEnd = shouldSampleMemory(ctx.memSampleEvery) ? readMemorySample() : null
return {
name: plan.name,
description: plan.description,
category: "incremental",
timingMode: "content-set-and-render",
iterations: plan.iterations,
warmupIterations: plan.warmupIterations,
elapsedMs,
updateStats: computeTimingStats(durations),
memoryStats: memStart && memEnd ? computeMemoryStats(memSamples, memStart, memEnd) : undefined,
tableStats: {
initialRows: plan.baseRows.length,
finalRows: state.rows.length,
maxRows: state.maxRowsSeen,
columns: plan.cols,
updates: plan.iterations,
datasetVariants: plan.rowPool.length,
},
settings: {
cols: plan.cols,
baseRows: plan.baseRows.length,
rowPool: plan.rowPool.length,
maxRows: plan.maxRows,
mode: "incremental",
},
}
}
async function runSelectionScenario(plan: SelectionScenarioPlan, ctx: RunContext): Promise<ScenarioResult> {
ctx.table.content = plan.content
await ctx.renderOnce()
const tableX = ctx.table.x
const tableY = ctx.table.y
const tableH = ctx.table.height
const anchorX = tableX + 2
const anchorY = tableY + 2
const maxFocusY = tableY + tableH - 2
const focusRange = Math.max(1, maxFocusY - anchorY)
for (let i = 0; i < plan.warmupIterations; i += 1) {
const focusY = anchorY + (i % focusRange)
ctx.renderer.startSelection(ctx.table, anchorX, anchorY)
for (let step = 1; step <= plan.dragSteps; step += 1) {
const stepY = anchorY + Math.round(((focusY - anchorY) * step) / plan.dragSteps)
ctx.renderer.updateSelection(ctx.table, anchorX + 4, stepY)
}
await ctx.renderOnce()
ctx.renderer.clearSelection()
await ctx.renderOnce()
}
const durations: number[] = []
const measurementStart = Date.now()
const memStart = shouldSampleMemory(ctx.memSampleEvery) ? readMemorySample() : null
const memSamples: MemorySample[] = []
for (let i = 0; i < plan.iterations; i += 1) {
const focusY = anchorY + (i % focusRange)
const start = performance.now()
ctx.renderer.startSelection(ctx.table, anchorX, anchorY)
for (let step = 1; step <= plan.dragSteps; step += 1) {
const stepY = anchorY + Math.round(((focusY - anchorY) * step) / plan.dragSteps)
ctx.renderer.updateSelection(ctx.table, anchorX + 4, stepY)
}
await ctx.renderOnce()
ctx.renderer.clearSelection()
await ctx.renderOnce()
durations.push(performance.now() - start)
if (ctx.memSampleEvery > 0 && (i + 1) % ctx.memSampleEvery === 0) {
memSamples.push(readMemorySample())
}
}
const elapsedMs = Date.now() - measurementStart
const memEnd = shouldSampleMemory(ctx.memSampleEvery) ? readMemorySample() : null
return {
name: plan.name,
description: plan.description,
category: "selection",
timingMode: "selection-update-and-render",
iterations: plan.iterations,
warmupIterations: plan.warmupIterations,
elapsedMs,
updateStats: computeTimingStats(durations),
memoryStats: memStart && memEnd ? computeMemoryStats(memSamples, memStart, memEnd) : undefined,
tableStats: {
initialRows: plan.rows,
finalRows: plan.rows,
maxRows: plan.rows,
columns: plan.cols,
updates: plan.iterations * (plan.dragSteps + 1),
datasetVariants: 1,
},
settings: {
rows: plan.rows,
cols: plan.cols,
dragSteps: plan.dragSteps,
mode: "selection",
},
}
}
function nextIncrementalContent(plan: IncrementalScenarioPlan, state: IncrementalState): TextTableContent {
if (state.rows.length >= plan.maxRows) {
state.rows = [...plan.baseRows]
}
const fallbackRow = plan.rowPool[0] ?? makeDataRow(createRng(1), 0, plan.cols)
const nextRow = plan.rowPool[state.cursor] ?? fallbackRow
state.cursor += 1
if (state.cursor >= plan.rowPool.length) {
state.cursor = 0
}
state.rows = [...state.rows, nextRow]
state.maxRowsSeen = Math.max(state.maxRowsSeen, state.rows.length)
return [plan.header, ...state.rows]
}
function makeHeader(cols: number): TextTableCellContent[] {
const header: TextTableCellContent[] = []
for (let c = 0; c < cols; c += 1) {
header.push(chunkCell(`Column ${c + 1}`))
}
return header
}
function buildTableContent(rng: () => number, rows: number, cols: number): TextTableContent {
return [makeHeader(cols), ...buildRows(rng, rows, cols, 0)]
}
function buildRows(rng: () => number, rows: number, cols: number, rowOffset: number): TextTableCellContent[][] {
const out: TextTableCellContent[][] = []
for (let r = 0; r < rows; r += 1) {
out.push(makeDataRow(rng, rowOffset + r, cols))
}
return out
}
function makeDataRow(rng: () => number, rowIndex: number, cols: number): TextTableCellContent[] {
const row: TextTableCellContent[] = []
for (let c = 0; c < cols; c += 1) {
row.push(chunkCell(makeCellText(rng, rowIndex, c)))
}
return row
}
function chunkCell(text: string): TextTableCellContent {
return [
{
__isChunk: true,
text,
},
]
}
function makeCellText(rng: () => number, row: number, col: number): string {
const a = pick(rng, WORDS)
const b = pick(rng, WORDS)
const roll = rng()
if (roll < 0.2) {
return `${a}-${b}-${row + col}`
}
if (roll < 0.4) {
return `${a} ${Math.floor(rng() * 1000)}`
}
if (roll < 0.6) {
return `${a} ${b} ${pick(rng, WORDS)}`
}
if (roll < 0.8) {
return `${a}_${b}_${Math.floor(rng() * 100)}`
}
return `${a} ${b} r${row}c${col}`
}
function pick<T>(rng: () => number, list: T[]): T {
return list[Math.floor(rng() * list.length)]
}
function createRng(initialSeed: number): () => number {
let state = initialSeed >>> 0
return () => {
state = (state * 1664525 + 1013904223) >>> 0
return state / 0x100000000
}
}
function scaled(value: number, scaleValue: number): number {
return Math.max(1, Math.round(value * scaleValue))
}
function toNumber(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) return value
if (typeof value === "string") {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return fallback
}
function shouldSampleMemory(memSampleEvery: number): boolean {
return memSampleEvery > 0
}
function readMemorySample(): MemorySample {
const usage = process.memoryUsage()
return {
rss: usage.rss ?? 0,
heapTotal: usage.heapTotal ?? 0,
heapUsed: usage.heapUsed ?? 0,
external: usage.external ?? 0,
arrayBuffers: usage.arrayBuffers ?? 0,
}
}
function computeMemoryStats(samples: MemorySample[], start: MemorySample, end: MemorySample): MemoryStats {
const all = [start, ...samples, end]
const peak = { ...start }
for (const sample of all) {
peak.rss = Math.max(peak.rss, sample.rss)
peak.heapTotal = Math.max(peak.heapTotal, sample.heapTotal)
peak.heapUsed = Math.max(peak.heapUsed, sample.heapUsed)
peak.external = Math.max(peak.external, sample.external)
peak.arrayBuffers = Math.max(peak.arrayBuffers, sample.arrayBuffers)
}
return {
samples: all.length,
start,
end,
delta: diffMemory(start, end),
peak,
}
}
function diffMemory(start: MemorySample, end: MemorySample): MemorySample {
return {
rss: end.rss - start.rss,
heapTotal: end.heapTotal - start.heapTotal,
heapUsed: end.heapUsed - start.heapUsed,
external: end.external - start.external,
arrayBuffers: end.arrayBuffers - start.arrayBuffers,
}
}
function computeTimingStats(durations: number[]): TimingStats {
const sorted = [...durations].sort((a, b) => a - b)
const count = sorted.length
const sum = sorted.reduce((acc, value) => acc + value, 0)
const average = count > 0 ? sum / count : 0
const min = sorted[0] ?? 0
const max = sorted[count - 1] ?? 0
const median = count > 0 ? (sorted[Math.floor(count / 2)] ?? 0) : 0
const p95 = count > 0 ? (sorted[Math.floor(count * 0.95)] ?? 0) : 0
const stdDev = count > 0 ? Math.sqrt(sorted.reduce((acc, v) => acc + Math.pow(v - average, 2), 0) / count) : 0
return {
count,
averageMs: average,
medianMs: median,
p95Ms: p95,
minMs: min,
maxMs: max,
stdDevMs: stdDev,
}
}
async function outputResults(
meta: OutputMeta,
results: ScenarioResult[],
scenarioLines: string[],
outputEnabled: boolean,
outputPath: string | null,
): Promise<void> {
const runId = new Date().toISOString()
const payload = {
runId,
suite: meta.suiteName,
config: {
width: meta.width,
height: meta.height,
iterations: meta.iterations,
warmupIterations: meta.warmupIterations,
longIterations: meta.longIterations,
scale: meta.scale,
seed: meta.seed,
memSampleEvery: meta.memSampleEvery,
},
results,
}
if (outputEnabled) {
writeLine(
`text-table-benchmark suite=${meta.suiteName} mode=content-set-and-render iters=${meta.iterations} warmup=${meta.warmupIterations}`,
)
for (const line of scenarioLines) {
writeLine(line)
}
}
if (outputPath) {
try {
const json = JSON.stringify(payload, null, 2)
await Bun.write(outputPath, json)
} catch (error: any) {
writeLine(`Error writing results to ${outputPath}: ${error.message}`)
}
}
}
function formatBytes(value: number): string {
return `${(value / (1024 * 1024)).toFixed(2)}MB`
}
function formatScenarioResult(result: ScenarioResult): string {
const mem = result.memoryStats
const memSummary = mem
? ` memDeltaRss=${formatBytes(mem.delta.rss)}` +
` memDeltaHeap=${formatBytes(mem.delta.heapUsed)}` +
` memDeltaExt=${formatBytes(mem.delta.external)}` +
` memDeltaAB=${formatBytes(mem.delta.arrayBuffers)}` +
` memPeakRss=${formatBytes(mem.peak.rss)}`
: ""
return `scenario=${result.name} category=${result.category} mode=${result.timingMode} iters=${result.updateStats.count} elapsedMs=${result.elapsedMs} avgMs=${result.updateStats.averageMs.toFixed(3)} medianMs=${result.updateStats.medianMs.toFixed(3)} p95Ms=${result.updateStats.p95Ms.toFixed(3)} minMs=${result.updateStats.minMs.toFixed(3)} maxMs=${result.updateStats.maxMs.toFixed(3)} rows=${result.tableStats.finalRows} maxRows=${result.tableStats.maxRows} cols=${result.tableStats.columns}${memSummary}`
}
function writeLine(line: string): void {
realStdoutWrite(`${line}\n`)
}
+30
View File
@@ -482,6 +482,36 @@ export class OptimizedBuffer {
this.lib.freeUnicode(encoded)
}
public drawGrid(options: {
borderChars: Uint32Array
borderFg: RGBA
borderBg: RGBA
columnOffsets: Int32Array
rowOffsets: Int32Array
drawInner: boolean
drawOuter: boolean
}): void {
this.guard()
const columnCount = Math.max(0, options.columnOffsets.length - 1)
const rowCount = Math.max(0, options.rowOffsets.length - 1)
this.lib.bufferDrawGrid(
this.bufferPtr,
options.borderChars,
options.borderFg,
options.borderBg,
options.columnOffsets,
columnCount,
options.rowOffsets,
rowCount,
{
drawInner: options.drawInner,
drawOuter: options.drawOuter,
},
)
}
public drawChar(char: number, x: number, y: number, fg: RGBA, bg: RGBA, attributes: number = 0): void {
this.guard()
this.lib.bufferDrawChar(this.bufferPtr, char, x, y, fg, bg, attributes)
+7
View File
@@ -42,6 +42,7 @@ import * as inputExample from "./input-demo"
import * as layoutExample from "./simple-layout-example"
import * as inputSelectLayoutExample from "./input-select-layout-demo"
import * as styledTextExample from "./styled-text-demo"
import * as textTableExample from "./text-table-demo"
import * as mouseInteractionExample from "./mouse-interaction-demo"
import * as textSelectionExample from "./text-selection-demo"
import * as asciiFontSelectionExample from "./ascii-font-selection-demo"
@@ -180,6 +181,12 @@ const examples: Example[] = [
run: styledTextExample.run,
destroy: styledTextExample.destroy,
},
{
name: "TextTable Demo",
description: "TextTable renderable with styled chunks, Unicode content, and wrap/border toggles",
run: textTableExample.run,
destroy: textTableExample.destroy,
},
{
name: "Link Demo",
description: "Hyperlink support with OSC 8 - clickable links and link inheritance in styled text",
@@ -0,0 +1,411 @@
import {
BoxRenderable,
CliRenderer,
ScrollBoxRenderable,
TextTableRenderable,
TextRenderable,
bold,
createCliRenderer,
fg,
green,
red,
t,
type BorderStyle,
type KeyEvent,
yellow,
} from "../index"
import type { Selection } from "../lib/selection"
import type { TextTableColumnWidthMode, TextTableContent } from "../renderables/TextTable"
import type { TextChunk } from "../text-buffer"
import { setupCommonDemoKeys } from "./lib/standalone-keys"
let container: BoxRenderable | null = null
let primaryTable: TextTableRenderable | null = null
let unicodeTable: TextTableRenderable | null = null
let controlsText: TextRenderable | null = null
let tableAreaScrollBox: ScrollBoxRenderable | null = null
let selectionStatusText: TextRenderable | null = null
let selectionMetaText: TextRenderable | null = null
let selectionScrollBox: ScrollBoxRenderable | null = null
let keyboardHandler: ((key: KeyEvent) => void) | null = null
let selectionHandler: ((selection: Selection) => void) | null = null
let contentIndex = 0
let wrapIndex = 1
let borderIndex = 0
let columnWidthModeIndex = 0
let cellPaddingIndex = 0
let borderEnabled = true
let outerBorderEnabled = true
let showBordersEnabled = true
const WRAP_MODES: Array<"none" | "word" | "char"> = ["none", "word", "char"]
const BORDER_STYLES: BorderStyle[] = ["single", "rounded", "double", "heavy"]
const COLUMN_WIDTH_MODES: TextTableColumnWidthMode[] = ["content", "fill"]
const CELL_PADDING_VALUES: number[] = [0, 1, 2]
function cell(text: string): TextChunk[] {
return [
{
__isChunk: true,
text,
},
]
}
const primaryContentSets: TextTableContent[] = [
[
[[bold("Service")], [bold("Status")], [bold("Notes")]],
[cell("api"), [green("OK")], [fg("#94a3b8")("latency"), ...cell(" 28ms")]],
[cell("worker"), [yellow("DEGRADED")], cell("queue depth: 124")],
[cell("billing"), [red("ERROR")], cell("retrying payment provider")],
],
[
[[bold("Region")], [bold("Requests")], [bold("Trend")]],
[cell("us-east-1"), cell("1.2M"), [green("+12.4%")]],
[cell("eu-west-1"), cell("890K"), [green("+5.1%")]],
[cell("ap-south-1"), cell("540K"), [red("-2.0%")]],
],
[
[[bold("Task")], [bold("Owner")], [bold("ETA")]],
[cell("Wrap regression"), cell("core"), [green("done")]],
[cell("Unicode layout"), cell("render"), cell("in review")],
[cell("Snapshot pass"), cell("qa"), cell("today")],
],
]
const unicodeContentSets: TextTableContent[] = [
[
[[bold("Locale")], [bold("Sample")]],
[cell("ja-JP"), cell("東京の夜景と絵文字 🌃✨")],
[cell("zh-CN"), cell("你好世界,布局检查中 🚀")],
[cell("ko-KR"), cell("한글과 이모지 조합 테스트 😄")],
],
[
[[bold("Expression")], [bold("Meaning")]],
[cell("山川异域"), cell("Different lands, shared sky 🌏")],
[cell("꽃길만 걷자"), cell("Walk only flower paths 🌸")],
[cell("加油"), cell("Keep pushing forward 💪")],
],
[
[[bold("Column")], [bold("Wrapped Text")]],
[cell("mixed"), cell("CJK and emoji wrapping: こんにちは世界 🌍 followed by long english text for width checks")],
[cell("emoji"), cell("Faces 😀😃😄😁😆 and symbols 🧪📦🛰️ across constrained columns")],
],
]
function currentWrapMode(): "none" | "word" | "char" {
return WRAP_MODES[wrapIndex] ?? "word"
}
function currentBorderStyle(): BorderStyle {
return BORDER_STYLES[borderIndex] ?? "single"
}
function currentColumnWidthMode(): TextTableColumnWidthMode {
return COLUMN_WIDTH_MODES[columnWidthModeIndex] ?? "content"
}
function currentCellPadding(): number {
return CELL_PADDING_VALUES[cellPaddingIndex] ?? 0
}
function updateControlsText(): void {
if (!controlsText) return
controlsText.content = t`${bold("TextTable Demo")} ${fg("#94a3b8")("1/2/3 dataset • W wrap • B style • M width • P padding • N inner • O outer • H draw • drag to select • C clear")}
Current: dataset ${fg("#7dd3fc")(String(contentIndex + 1))} | wrap ${fg("#a5b4fc")(currentWrapMode())} | style ${fg("#f9a8d4")(currentBorderStyle())} | width ${fg("#fcd34d")(currentColumnWidthMode())} | padding ${fg("#fda4af")(String(currentCellPadding()))} | inner ${fg("#93c5fd")(borderEnabled ? "on" : "off")} | outer ${fg("#86efac")(outerBorderEnabled ? "on" : "off")} | draw ${fg("#67e8f9")(showBordersEnabled ? "on" : "off")}`
}
function clearSelectionStatus(message: string): void {
if (!selectionMetaText || !selectionStatusText) return
selectionMetaText.content = message
selectionStatusText.content = ""
if (selectionScrollBox) {
selectionScrollBox.scrollTop = 0
}
}
function applyTableState(): void {
if (!primaryTable || !unicodeTable) return
primaryTable.content = primaryContentSets[contentIndex] ?? primaryContentSets[0]
unicodeTable.content = unicodeContentSets[contentIndex] ?? unicodeContentSets[0]
primaryTable.wrapMode = currentWrapMode()
unicodeTable.wrapMode = currentWrapMode()
primaryTable.borderStyle = currentBorderStyle()
unicodeTable.borderStyle = currentBorderStyle()
primaryTable.columnWidthMode = currentColumnWidthMode()
unicodeTable.columnWidthMode = currentColumnWidthMode()
primaryTable.cellPadding = currentCellPadding()
unicodeTable.cellPadding = currentCellPadding()
primaryTable.border = borderEnabled
unicodeTable.border = borderEnabled
primaryTable.outerBorder = outerBorderEnabled
unicodeTable.outerBorder = outerBorderEnabled
primaryTable.showBorders = showBordersEnabled
unicodeTable.showBorders = showBordersEnabled
updateControlsText()
}
export function run(renderer: CliRenderer): void {
renderer.setBackgroundColor("#0b1020")
container = new BoxRenderable(renderer, {
id: "text-table-demo-container",
width: "100%",
height: "100%",
flexDirection: "column",
padding: 1,
gap: 1,
backgroundColor: "#0b1020",
})
renderer.root.add(container)
controlsText = new TextRenderable(renderer, {
id: "text-table-demo-controls",
content: "",
fg: "#e2e8f0",
wrapMode: "word",
selectable: false,
})
tableAreaScrollBox = new ScrollBoxRenderable(renderer, {
id: "text-table-demo-table-area-scroll",
width: "100%",
flexGrow: 1,
flexShrink: 1,
scrollY: true,
scrollX: false,
border: false,
backgroundColor: "transparent",
contentOptions: {
flexDirection: "column",
gap: 1,
},
})
const primaryLabel = new TextRenderable(renderer, {
id: "text-table-demo-primary-label",
content: t`${bold("Operational Table")}`,
fg: "#cbd5e1",
selectable: false,
})
primaryTable = new TextTableRenderable(renderer, {
id: "text-table-demo-primary",
width: "100%",
wrapMode: currentWrapMode(),
borderStyle: currentBorderStyle(),
borderColor: "#7aa2f7",
fg: "#e2e8f0",
bg: "transparent",
content: primaryContentSets[contentIndex] ?? primaryContentSets[0],
})
const unicodeLabel = new TextRenderable(renderer, {
id: "text-table-demo-unicode-label",
content: t`${bold("Unicode/CJK/Emoji Table")}`,
fg: "#cbd5e1",
selectable: false,
})
unicodeTable = new TextTableRenderable(renderer, {
id: "text-table-demo-unicode",
width: "100%",
wrapMode: currentWrapMode(),
borderStyle: currentBorderStyle(),
borderColor: "#34d399",
fg: "#e2e8f0",
bg: "transparent",
content: unicodeContentSets[contentIndex] ?? unicodeContentSets[0],
})
const selectionBox = new BoxRenderable(renderer, {
id: "text-table-demo-selection-box",
width: "100%",
height: 10,
flexGrow: 0,
flexShrink: 0,
border: true,
borderStyle: "single",
borderColor: "#64748b",
title: "Selected Text",
titleAlignment: "left",
padding: 1,
backgroundColor: "#111827",
})
selectionMetaText = new TextRenderable(renderer, {
id: "text-table-demo-selection-meta",
content: "No selection yet",
fg: "#93c5fd",
selectable: false,
})
selectionScrollBox = new ScrollBoxRenderable(renderer, {
id: "text-table-demo-selection-scroll",
width: "100%",
flexGrow: 1,
flexShrink: 1,
scrollY: true,
scrollX: false,
border: false,
backgroundColor: "transparent",
})
tableAreaScrollBox.verticalScrollbarOptions = { visible: false }
selectionScrollBox.verticalScrollbarOptions = { visible: false }
selectionStatusText = new TextRenderable(renderer, {
id: "text-table-demo-selection-text",
content: "",
fg: "#e2e8f0",
wrapMode: "word",
width: "100%",
selectable: false,
})
selectionBox.add(selectionMetaText)
selectionBox.add(selectionScrollBox)
selectionScrollBox.add(selectionStatusText)
tableAreaScrollBox.add(controlsText)
tableAreaScrollBox.add(primaryLabel)
tableAreaScrollBox.add(primaryTable)
tableAreaScrollBox.add(unicodeLabel)
tableAreaScrollBox.add(unicodeTable)
container.add(tableAreaScrollBox)
container.add(selectionBox)
selectionHandler = (selection: Selection) => {
if (!selectionMetaText || !selectionStatusText) return
const selectedText = selection.getSelectedText()
if (!selectedText) {
clearSelectionStatus("Empty selection")
return
}
const lines = selectedText.split("\n").length
const chars = selectedText.length
selectionMetaText.content = `Selected ${lines} line${lines === 1 ? "" : "s"} (${chars} chars)`
selectionStatusText.content = selectedText
if (selectionScrollBox) {
selectionScrollBox.scrollTop = 0
}
}
renderer.on("selection", selectionHandler)
keyboardHandler = (key: KeyEvent) => {
if (key.ctrl || key.meta) return
if (key.name === "1" || key.name === "2" || key.name === "3") {
contentIndex = Number(key.name) - 1
applyTableState()
return
}
if (key.name === "w") {
wrapIndex = (wrapIndex + 1) % WRAP_MODES.length
applyTableState()
return
}
if (key.name === "b") {
borderIndex = (borderIndex + 1) % BORDER_STYLES.length
applyTableState()
return
}
if (key.name === "m") {
columnWidthModeIndex = (columnWidthModeIndex + 1) % COLUMN_WIDTH_MODES.length
applyTableState()
return
}
if (key.name === "p") {
cellPaddingIndex = (cellPaddingIndex + 1) % CELL_PADDING_VALUES.length
applyTableState()
return
}
if (key.name === "n") {
borderEnabled = !borderEnabled
applyTableState()
return
}
if (key.name === "o") {
outerBorderEnabled = !outerBorderEnabled
applyTableState()
return
}
if (key.name === "h") {
showBordersEnabled = !showBordersEnabled
applyTableState()
return
}
if (key.name === "c") {
renderer.clearSelection()
clearSelectionStatus("Selection cleared")
}
}
renderer.keyInput.on("keypress", keyboardHandler)
applyTableState()
}
export function destroy(renderer: CliRenderer): void {
if (keyboardHandler) {
renderer.keyInput.off("keypress", keyboardHandler)
keyboardHandler = null
}
if (selectionHandler) {
renderer.off("selection", selectionHandler)
selectionHandler = null
}
container?.destroyRecursively()
container = null
primaryTable = null
unicodeTable = null
controlsText = null
tableAreaScrollBox = null
selectionStatusText = null
selectionMetaText = null
selectionScrollBox = null
contentIndex = 0
wrapIndex = 1
borderIndex = 0
columnWidthModeIndex = 0
cellPaddingIndex = 0
borderEnabled = true
outerBorderEnabled = true
showBordersEnabled = true
}
if (import.meta.main) {
const renderer = await createCliRenderer({
exitOnCtrlC: true,
targetFps: 60,
enableMouseMovement: true,
})
run(renderer)
setupCommonDemoKeys(renderer)
}
@@ -0,0 +1,705 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { OptimizedBuffer } from "../buffer"
import { RGBA } from "../lib/RGBA"
import { bold, green, red } from "../lib/styled-text"
import { createTestRenderer, type MockMouse, type TestRenderer } from "../testing/test-renderer"
import type { CapturedFrame } from "../types"
import { TextTableRenderable, type TextTableCellContent, type TextTableContent } from "./TextTable"
const VERTICAL_BORDER_CP = "│".codePointAt(0)!
const BORDER_CHAR_PATTERN = /[┌┐└┘├┤┬┴┼│─]/
let renderer: TestRenderer
let renderOnce: () => Promise<void>
let captureFrame: () => string
let captureSpans: () => CapturedFrame
let mockMouse: MockMouse
function getCharAt(buffer: TestRenderer["currentRenderBuffer"], x: number, y: number): number {
return buffer.buffers.char[y * buffer.width + x] ?? 0
}
function getFgAt(buffer: TestRenderer["currentRenderBuffer"], x: number, y: number): RGBA {
const index = (y * buffer.width + x) * 4
return RGBA.fromValues(
buffer.buffers.fg[index] ?? 0,
buffer.buffers.fg[index + 1] ?? 0,
buffer.buffers.fg[index + 2] ?? 0,
buffer.buffers.fg[index + 3] ?? 0,
)
}
function getBgAt(buffer: TestRenderer["currentRenderBuffer"], x: number, y: number): RGBA {
const index = (y * buffer.width + x) * 4
return RGBA.fromValues(
buffer.buffers.bg[index] ?? 0,
buffer.buffers.bg[index + 1] ?? 0,
buffer.buffers.bg[index + 2] ?? 0,
buffer.buffers.bg[index + 3] ?? 0,
)
}
function findVerticalBorderXs(buffer: TestRenderer["currentRenderBuffer"], y: number): number[] {
const xs: number[] = []
for (let x = 0; x < buffer.width; x++) {
if (getCharAt(buffer, x, y) === VERTICAL_BORDER_CP) {
xs.push(x)
}
}
return xs
}
function countChar(text: string, target: string): number {
return [...text].filter((char) => char === target).length
}
function cell(text: string): TextTableCellContent {
return [
{
__isChunk: true,
text,
},
]
}
beforeEach(async () => {
const testRenderer = await createTestRenderer({ width: 60, height: 16 })
renderer = testRenderer.renderer
renderOnce = testRenderer.renderOnce
captureFrame = testRenderer.captureCharFrame
captureSpans = testRenderer.captureSpans
mockMouse = testRenderer.mockMouse
})
afterEach(() => {
renderer.destroy()
})
describe("TextTableRenderable", () => {
test("renders a basic table with styled cell chunks", async () => {
const content: TextTableContent = [
[[bold("Name")], [bold("Status")], [bold("Notes")]],
[cell("Alpha"), [green("OK")], cell("All systems nominal")],
[cell("Bravo"), [red("WARN")], cell("Pending checks")],
]
const table = new TextTableRenderable(renderer, {
left: 1,
top: 1,
content,
})
renderer.root.add(table)
await renderOnce()
const frame = captureFrame()
expect(frame).toMatchSnapshot("basic table")
expect(frame).toContain("Alpha")
expect(frame).toContain("WARN")
const spans = captureSpans().lines.flatMap((line) => line.spans)
const okSpan = spans.find((span) => span.text.includes("OK"))
expect(okSpan).toBeDefined()
expect(okSpan?.fg.equals(RGBA.fromHex("#008000"))).toBe(true)
})
test("wraps content and fits columns when width is constrained", async () => {
const content: TextTableContent = [
[[bold("ID")], [bold("Description")]],
[cell("1"), cell("This is a long sentence that should wrap across multiple visual lines")],
[cell("2"), cell("Short")],
]
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
width: 34,
wrapMode: "word",
content,
})
renderer.root.add(table)
await renderOnce()
const frame = captureFrame()
expect(frame).toMatchSnapshot("wrapped constrained width")
expect(frame).toContain("Description")
})
test("keeps intrinsic width by default when extra space is available", async () => {
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
width: 34,
wrapMode: "word",
content: [
[cell("A"), cell("B")],
[cell("1"), cell("2")],
],
})
renderer.root.add(table)
await renderOnce()
const lines = captureFrame().split("\n")
const headerY = lines.findIndex((line) => line.includes("A") && line.includes("B"))
expect(headerY).toBeGreaterThanOrEqual(0)
const buffer = renderer.currentRenderBuffer
const borderXs = findVerticalBorderXs(buffer, headerY)
expect(borderXs.length).toBe(3)
expect(borderXs[0]).toBe(0)
expect(borderXs[borderXs.length - 1]).toBeLessThan(33)
})
test("fills available width when columnWidthMode is fill", async () => {
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
width: 34,
wrapMode: "word",
columnWidthMode: "fill",
content: [
[cell("A"), cell("B")],
[cell("1"), cell("2")],
],
})
renderer.root.add(table)
await renderOnce()
const lines = captureFrame().split("\n")
const headerY = lines.findIndex((line) => line.includes("A") && line.includes("B"))
expect(headerY).toBeGreaterThanOrEqual(0)
const buffer = renderer.currentRenderBuffer
const borderXs = findVerticalBorderXs(buffer, headerY)
expect(borderXs).toEqual([0, 17, 33])
})
test("fills available width in no-wrap mode when columnWidthMode is fill", async () => {
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
width: 24,
wrapMode: "none",
columnWidthMode: "fill",
content: [
[cell("Key"), cell("Value")],
[cell("A"), cell("B")],
],
})
renderer.root.add(table)
await renderOnce()
const lines = captureFrame().split("\n")
const headerY = lines.findIndex((line) => line.includes("Key") && line.includes("Value"))
expect(headerY).toBeGreaterThanOrEqual(0)
const buffer = renderer.currentRenderBuffer
const borderXs = findVerticalBorderXs(buffer, headerY)
expect(borderXs).toEqual([0, 11, 23])
})
test("preserves bordered layout when border glyphs are hidden", async () => {
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
border: true,
outerBorder: true,
showBorders: false,
content: [[cell("A"), cell("B")]],
})
renderer.root.add(table)
await renderOnce()
const frame = captureFrame()
expect(BORDER_CHAR_PATTERN.test(frame)).toBe(false)
const row = frame.split("\n").find((line) => line.includes("A") && line.includes("B"))
expect(row).toBeDefined()
expect(row?.indexOf("A")).toBe(1)
expect(row?.indexOf("B")).toBe(3)
})
test("applies cell padding when provided", async () => {
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
cellPadding: 1,
content: [
[cell("A"), cell("B")],
[cell("1"), cell("2")],
],
})
renderer.root.add(table)
await renderOnce()
const frame = captureFrame()
expect(frame).toContain("│ │ │")
expect(frame).toContain("│ A │ B │")
const lines = frame.split("\n")
const headerY = lines.findIndex((line) => line.includes(" A ") && line.includes(" B "))
expect(headerY).toBeGreaterThanOrEqual(0)
const borderXs = findVerticalBorderXs(renderer.currentRenderBuffer, headerY)
expect(borderXs).toEqual([0, 4, 8])
})
test("reflows when columnWidthMode is changed after initial render", async () => {
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
width: 34,
wrapMode: "word",
content: [
[cell("A"), cell("B")],
[cell("1"), cell("2")],
],
})
renderer.root.add(table)
await renderOnce()
let lines = captureFrame().split("\n")
let headerY = lines.findIndex((line) => line.includes("A") && line.includes("B"))
expect(headerY).toBeGreaterThanOrEqual(0)
let borderXs = findVerticalBorderXs(renderer.currentRenderBuffer, headerY)
expect(borderXs[borderXs.length - 1]).toBeLessThan(33)
table.columnWidthMode = "fill"
await renderOnce()
lines = captureFrame().split("\n")
headerY = lines.findIndex((line) => line.includes("A") && line.includes("B"))
expect(headerY).toBeGreaterThanOrEqual(0)
borderXs = findVerticalBorderXs(renderer.currentRenderBuffer, headerY)
expect(borderXs).toEqual([0, 17, 33])
})
test("uses native border draw for inner-only mode", async () => {
const originalDrawGrid = OptimizedBuffer.prototype.drawGrid
let nativeCalls = 0
OptimizedBuffer.prototype.drawGrid = function (...args: Parameters<OptimizedBuffer["drawGrid"]>) {
nativeCalls += 1
return originalDrawGrid.apply(this, args)
}
try {
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
border: true,
outerBorder: false,
content: [
[cell("A"), cell("B")],
[cell("1"), cell("2")],
],
})
renderer.root.add(table)
await renderOnce()
const frame = captureFrame()
expect(frame).not.toContain("┌")
expect(frame).not.toContain("┐")
expect(frame).not.toContain("└")
expect(frame).not.toContain("┘")
expect(frame).toContain("┼")
expect(nativeCalls).toBe(1)
const lines = frame.split("\n")
const rowY = lines.findIndex((line) => line.includes("A") && line.includes("B"))
expect(rowY).toBeGreaterThanOrEqual(0)
const borderXs = findVerticalBorderXs(renderer.currentRenderBuffer, rowY)
expect(borderXs).toEqual([1])
} finally {
OptimizedBuffer.prototype.drawGrid = originalDrawGrid
}
})
test("defaults outerBorder to false when border is false", async () => {
const originalDrawGrid = OptimizedBuffer.prototype.drawGrid
let nativeCalls = 0
OptimizedBuffer.prototype.drawGrid = function (...args: Parameters<OptimizedBuffer["drawGrid"]>) {
nativeCalls += 1
return originalDrawGrid.apply(this, args)
}
try {
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
border: false,
content: [
[cell("A"), cell("B")],
[cell("1"), cell("2")],
],
})
renderer.root.add(table)
await renderOnce()
const frame = captureFrame()
expect(table.outerBorder).toBe(false)
expect(BORDER_CHAR_PATTERN.test(frame)).toBe(false)
expect(frame).toContain("AB")
expect(nativeCalls).toBe(0)
} finally {
OptimizedBuffer.prototype.drawGrid = originalDrawGrid
}
})
test("allows outer border even when inner border is off", async () => {
const originalDrawGrid = OptimizedBuffer.prototype.drawGrid
let nativeCalls = 0
OptimizedBuffer.prototype.drawGrid = function (...args: Parameters<OptimizedBuffer["drawGrid"]>) {
nativeCalls += 1
return originalDrawGrid.apply(this, args)
}
try {
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
border: false,
outerBorder: true,
content: [
[cell("A"), cell("B")],
[cell("1"), cell("2")],
],
})
renderer.root.add(table)
await renderOnce()
const frame = captureFrame()
expect(frame).toContain("┌")
expect(frame).toContain("┐")
expect(frame).toContain("└")
expect(frame).toContain("┘")
expect(frame).not.toContain("┼")
expect(nativeCalls).toBe(1)
} finally {
OptimizedBuffer.prototype.drawGrid = originalDrawGrid
}
})
test("rebuilds table when content setter is used", async () => {
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
content: [[cell("A"), cell("B")]],
})
renderer.root.add(table)
await renderOnce()
const before = captureFrame()
table.content = [
[[bold("Col 1")], [bold("Col 2")]],
[cell("row-1"), cell("updated")],
[cell("row-2"), [green("active")]],
]
await renderOnce()
const after = captureFrame()
expect(before).not.toBe(after)
expect(after).toMatchSnapshot("content setter update")
})
test("renders a final bottom border", async () => {
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
content: [
[[bold("A")], [bold("B")]],
[cell("1"), cell("2")],
],
})
renderer.root.add(table)
await renderOnce()
const frame = captureFrame()
const lines = frame
.split("\n")
.map((line) => line.trimEnd())
.filter((line) => line.length > 0)
const lastLine = lines[lines.length - 1] ?? ""
expect(lastLine).toContain("└")
expect(lastLine).toContain("┴")
expect(lastLine).toContain("┘")
})
test("keeps borders aligned with CJK and emoji content", async () => {
const content: TextTableContent = [
[[bold("Locale")], [bold("Sample")]],
[cell("ja-JP"), cell("東京で寿司 🍣")],
[cell("zh-CN"), cell("你好世界 🚀")],
[cell("ko-KR"), cell("한글 테스트 😄")],
]
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
width: 36,
wrapMode: "none",
content,
})
renderer.root.add(table)
await renderOnce()
const frame = captureFrame()
expect(frame).toMatchSnapshot("unicode border alignment")
expect(frame).toContain("東京で寿司")
expect(frame).toContain("🚀")
expect(frame).toContain("😄")
const lines = frame.split("\n")
const headerY = lines.findIndex((line) => line.includes("Locale"))
expect(headerY).toBeGreaterThanOrEqual(0)
const buffer = renderer.currentRenderBuffer
const borderXs = findVerticalBorderXs(buffer, headerY)
expect(borderXs.length).toBe(3)
const sampleRowYs = [
lines.findIndex((line) => line.includes("ja-JP")),
lines.findIndex((line) => line.includes("zh-CN")),
lines.findIndex((line) => line.includes("ko-KR")),
]
for (const y of sampleRowYs) {
expect(y).toBeGreaterThanOrEqual(0)
for (const x of borderXs) {
expect(getCharAt(buffer, x, y)).toBe(VERTICAL_BORDER_CP)
}
}
})
test("wraps CJK and emoji without grapheme duplication", async () => {
const content: TextTableContent = [
[[bold("Item")], [bold("Details")]],
[cell("mixed"), cell("東京界 🌍 emoji wrapping continues across lines for width checks")],
[cell("emoji"), cell("Faces 😀😃😄 should remain stable")],
]
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
width: 30,
wrapMode: "word",
content,
})
renderer.root.add(table)
await renderOnce()
const frame = captureFrame()
expect(frame).toMatchSnapshot("unicode wrapping")
expect(frame).not.toContain("<22>")
expect(countChar(frame, "界")).toBe(1)
expect(countChar(frame, "🌍")).toBe(1)
const lines = frame.split("\n")
const wrappedRowStartY = lines.findIndex((line) => line.includes("mix") && line.includes("東京界"))
const wrappedRowEndBorderY = lines.findIndex((line, idx) => idx > wrappedRowStartY && line.includes("├"))
expect(wrappedRowStartY).toBeGreaterThanOrEqual(0)
expect(wrappedRowEndBorderY).toBeGreaterThan(wrappedRowStartY)
const wrappedRowYs: number[] = []
for (let y = wrappedRowStartY; y < wrappedRowEndBorderY; y++) {
wrappedRowYs.push(y)
}
expect(wrappedRowYs.length).toBeGreaterThan(1)
const headerY = lines.findIndex((line) => line.includes("Ite") && line.includes("Details"))
expect(headerY).toBeGreaterThanOrEqual(0)
const buffer = renderer.currentRenderBuffer
const borderXs = findVerticalBorderXs(buffer, headerY)
expect(borderXs.length).toBe(3)
for (const y of wrappedRowYs) {
for (const x of borderXs) {
expect(getCharAt(buffer, x, y)).toBe(VERTICAL_BORDER_CP)
}
}
})
test("starts selection only on table cell content", async () => {
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
content: [
[[bold("A")], [bold("B")]],
[cell("1"), cell("2")],
],
})
renderer.root.add(table)
await renderOnce()
expect(table.shouldStartSelection(table.x, table.y)).toBe(false)
expect(table.shouldStartSelection(table.x + 1, table.y)).toBe(false)
expect(table.shouldStartSelection(table.x, table.y + 1)).toBe(false)
expect(table.shouldStartSelection(table.x + 1, table.y + 1)).toBe(true)
})
test("selection text excludes border glyphs", async () => {
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
content: [
[[bold("c1")], [bold("c2")]],
[cell("aa"), cell("bb")],
[cell("cc"), cell("dd")],
],
})
renderer.root.add(table)
await renderOnce()
await mockMouse.drag(table.x + 1, table.y + 1, table.x + 5, table.y + 3)
await renderOnce()
expect(table.hasSelection()).toBe(true)
const selected = table.getSelectedText()
expect(selected).toContain("c1\tc2")
expect(selected).toContain("aa\tb")
expect(selected).not.toContain("│")
expect(selected).not.toContain("┌")
expect(selected).not.toContain("┼")
const rendererSelection = renderer.getSelection()
expect(rendererSelection).not.toBeNull()
expect(rendererSelection?.getSelectedText()).not.toContain("│")
})
test("selection colors reset when drag retracts back to the anchor", async () => {
const defaultFg = RGBA.fromHex("#111111")
const defaultBg = RGBA.fromValues(0, 0, 0, 0)
const selectionFg = RGBA.fromHex("#fefefe")
const selectionBg = RGBA.fromHex("#cc5500")
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
fg: defaultFg,
bg: "transparent",
selectionFg,
selectionBg,
content: [
["A", "B"],
["C", "D"],
],
})
renderer.root.add(table)
await renderOnce()
const anchorX = table.x + 1
const anchorY = table.y + 1
const farX = table.x + 3
const farY = table.y + 3
await mockMouse.pressDown(anchorX, anchorY)
await mockMouse.moveTo(farX, farY)
await renderOnce()
expect(table.hasSelection()).toBe(true)
let buffer = renderer.currentRenderBuffer
const selectedCells: Array<{ x: number; y: number }> = []
for (let y = table.y; y < table.y + table.height; y++) {
for (let x = table.x; x < table.x + table.width; x++) {
if (getBgAt(buffer, x, y).equals(selectionBg)) {
selectedCells.push({ x, y })
}
}
}
expect(selectedCells.length).toBeGreaterThan(1)
await mockMouse.moveTo(anchorX, anchorY)
await renderOnce()
const assertDeselectedCellsRestored = (frameBuffer: TestRenderer["currentRenderBuffer"]): void => {
const mismatches: string[] = []
for (const { x, y } of selectedCells) {
if (x === anchorX && y === anchorY) continue
const cp = getCharAt(frameBuffer, x, y)
if (cp === 0 || cp === VERTICAL_BORDER_CP) continue
if (!getFgAt(frameBuffer, x, y).equals(defaultFg)) {
mismatches.push(`fg@${x},${y}`)
}
if (!getBgAt(frameBuffer, x, y).equals(defaultBg)) {
mismatches.push(`bg@${x},${y}`)
}
}
expect(mismatches).toEqual([])
}
buffer = renderer.currentRenderBuffer
expect(table.getSelectedText()).toBe("")
assertDeselectedCellsRestored(buffer)
await mockMouse.release(anchorX, anchorY)
await renderOnce()
buffer = renderer.currentRenderBuffer
assertDeselectedCellsRestored(buffer)
expect(getCharAt(buffer, farX, farY)).toBe("D".codePointAt(0))
})
test("does not start selection when drag begins on border", async () => {
const table = new TextTableRenderable(renderer, {
left: 0,
top: 0,
content: [
[[bold("A")], [bold("B")]],
[cell("1"), cell("2")],
],
})
renderer.root.add(table)
await renderOnce()
await mockMouse.drag(table.x, table.y, table.x + 4, table.y + 1)
await renderOnce()
expect(table.hasSelection()).toBe(false)
expect(table.getSelectedText()).toBe("")
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`TextTableRenderable renders a basic table with styled cell chunks: basic table 1`] = `
"
┌─────┬──────┬───────────────────┐
│Name │Status│Notes │
├─────┼──────┼───────────────────┤
│Alpha│OK │All systems nominal│
├─────┼──────┼───────────────────┤
│Bravo│WARN │Pending checks │
└─────┴──────┴───────────────────┘
"
`;
exports[`TextTableRenderable wraps content and fits columns when width is constrained: wrapped constrained width 1`] = `
"┌──┬─────────────────────────────┐
│ID│Description │
├──┼─────────────────────────────┤
│1 │This is a long sentence that │
│ │should wrap across multiple │
│ │visual lines │
├──┼─────────────────────────────┤
│2 │Short │
└──┴─────────────────────────────┘
"
`;
exports[`TextTableRenderable rebuilds table when content setter is used: content setter update 1`] = `
"┌─────┬───────┐
│Col 1│Col 2 │
├─────┼───────┤
│row-1│updated│
├─────┼───────┤
│row-2│active │
└─────┴───────┘
"
`;
exports[`TextTableRenderable keeps borders aligned with CJK and emoji content: unicode border alignment 1`] = `
"┌──────┬──────────────┐
│Locale│Sample │
├──────┼──────────────┤
│ja-JP │東京で寿司 🍣 │
├──────┼──────────────┤
│zh-CN │你好世界 🚀 │
├──────┼──────────────┤
│ko-KR │한글 테스트 😄│
└──────┴──────────────┘
"
`;
exports[`TextTableRenderable wraps CJK and emoji without grapheme duplication: unicode wrapping 1`] = `
"┌───┬────────────────────────┐
│Ite│Details │
│m │ │
├───┼────────────────────────┤
│mix│東京界 🌍 emoji │
│ed │wrapping continues │
│ │across lines for width │
│ │checks │
├───┼────────────────────────┤
│emo│Faces 😀😃😄 should │
│ji │remain stable │
└───┴────────────────────────┘
"
`;
+1
View File
@@ -13,6 +13,7 @@ export * from "./ScrollBar"
export * from "./ScrollBox"
export * from "./Select"
export * from "./Slider"
export * from "./TextTable"
export * from "./TabSelect"
export * from "./Text"
export * from "./TextBufferRenderable"
+5
View File
@@ -125,6 +125,11 @@ export const CursorStyleOptionsStruct = defineStruct([
["cursor", "u8", { default: 255 }],
])
export const GridDrawOptionsStruct = defineStruct([
["drawInner", "bool_u8", { default: true }],
["drawOuter", "bool_u8", { default: true }],
])
export type GrowthPolicy = "grow" | "block"
export type NativeSpanFeedOptions = {
+45
View File
@@ -27,6 +27,7 @@ import {
MeasureResultStruct,
CursorStateStruct,
CursorStyleOptionsStruct,
GridDrawOptionsStruct,
NativeSpanFeedOptionsStruct,
NativeSpanFeedStatsStruct,
ReserveInfoStruct,
@@ -341,6 +342,10 @@ function getOpenTUILib(libPath?: string) {
args: ["ptr", "i32", "i32", "ptr", "u32", "u32", "ptr", "ptr"],
returns: "void",
},
bufferDrawGrid: {
args: ["ptr", "ptr", "ptr", "ptr", "ptr", "u32", "ptr", "u32", "ptr"],
returns: "void",
},
bufferDrawBox: {
args: ["ptr", "i32", "i32", "u32", "u32", "ptr", "u32", "ptr", "ptr", "ptr", "u32"],
returns: "void",
@@ -1468,6 +1473,17 @@ export interface RenderLib {
fg: RGBA | null,
bg: RGBA | null,
) => void
bufferDrawGrid: (
buffer: Pointer,
borderChars: Uint32Array,
borderFg: RGBA,
borderBg: RGBA,
columnOffsets: Int32Array,
columnCount: number,
rowOffsets: Int32Array,
rowCount: number,
options: { drawInner: boolean; drawOuter: boolean },
) => void
bufferDrawBox: (
buffer: Pointer,
x: number,
@@ -2232,6 +2248,35 @@ class FFIRenderLib implements RenderLib {
)
}
public bufferDrawGrid(
buffer: Pointer,
borderChars: Uint32Array,
borderFg: RGBA,
borderBg: RGBA,
columnOffsets: Int32Array,
columnCount: number,
rowOffsets: Int32Array,
rowCount: number,
options: { drawInner: boolean; drawOuter: boolean },
): void {
const optionsBuffer = GridDrawOptionsStruct.pack({
drawInner: options.drawInner,
drawOuter: options.drawOuter,
})
this.opentui.symbols.bufferDrawGrid(
buffer,
borderChars,
borderFg.buffer,
borderBg.buffer,
columnOffsets,
columnCount,
rowOffsets,
rowCount,
ptr(optionsBuffer),
)
}
public bufferDrawBox(
buffer: Pointer,
x: number,
+128
View File
@@ -1500,6 +1500,134 @@ pub const OptimizedBuffer = struct {
try self.drawTextBufferInternal(EditorView, editor_view, x, y);
}
/// Draw a complete border grid in a single call.
/// columnOffsets and rowOffsets include an extra trailing entry so that
/// the range for column `i` is `[columnOffsets[i]+1 .. columnOffsets[i+1]-1]`.
pub fn drawGrid(
self: *OptimizedBuffer,
borderChars: [*]const u32,
borderFg: RGBA,
borderBg: RGBA,
columnOffsets: [*]const i32,
columnCount: u32,
rowOffsets: [*]const i32,
rowCount: u32,
drawInner: bool,
drawOuter: bool,
) void {
if (rowCount == 0 or columnCount == 0) return;
if (!drawInner and !drawOuter) return;
const hChar = borderChars[@intFromEnum(BorderCharIndex.horizontal)];
const vChar = borderChars[@intFromEnum(BorderCharIndex.vertical)];
const bufWidth = self.width;
const bufHeight = self.height;
const bufWidthI32 = @as(i32, @intCast(bufWidth));
const bufHeightI32 = @as(i32, @intCast(bufHeight));
// Draw row-by-row: horizontal border line, then vertical borders for the row's content area
var rowIdx: u32 = 0;
while (rowIdx <= rowCount) : (rowIdx += 1) {
const is_outer_row = rowIdx == 0 or rowIdx == rowCount;
const should_draw_horizontal = if (is_outer_row) drawOuter else drawInner;
const borderY = rowOffsets[rowIdx];
if (borderY >= bufHeightI32) break;
// --- horizontal border line: intersections + fills ---
if (should_draw_horizontal and borderY >= 0) {
var colBorderIdx: u32 = 0;
while (colBorderIdx <= columnCount) : (colBorderIdx += 1) {
const is_outer_col = colBorderIdx == 0 or colBorderIdx == columnCount;
const should_draw_vertical = if (is_outer_col) drawOuter else drawInner;
if (!should_draw_vertical) continue;
const bx = columnOffsets[colBorderIdx];
if (bx >= bufWidthI32) break;
if (bx < 0) continue;
const has_up = rowIdx > 0 and should_draw_vertical;
const has_down = rowIdx < rowCount and should_draw_vertical;
const has_left = colBorderIdx > 0;
const has_right = colBorderIdx < columnCount;
const intersection = tableBorderIntersectionByConnections(borderChars, has_up, has_down, has_left, has_right);
self.setRaw(@as(u32, @intCast(bx)), @as(u32, @intCast(borderY)), Cell{ .char = intersection, .fg = borderFg, .bg = borderBg, .attributes = 0 });
}
var colIdx: u32 = 0;
while (colIdx < columnCount) : (colIdx += 1) {
const has_boundary_after = if (colIdx < columnCount - 1) drawInner else drawOuter;
const boundary_padding: i32 = if (has_boundary_after) 0 else 1;
const startX = columnOffsets[colIdx] + 1;
const endX = columnOffsets[colIdx + 1] + boundary_padding;
if (startX >= bufWidthI32) break;
if (endX <= 0) continue;
const clampedStart = @as(u32, @intCast(@max(@as(i32, 0), startX)));
const clampedEnd = @as(u32, @intCast(@min(bufWidthI32, endX)));
if (clampedStart < clampedEnd) {
const borderYU32 = @as(u32, @intCast(borderY));
@memset(self.buffer.char[borderYU32 * bufWidth + clampedStart .. borderYU32 * bufWidth + clampedEnd], hChar);
@memset(self.buffer.fg[borderYU32 * bufWidth + clampedStart .. borderYU32 * bufWidth + clampedEnd], borderFg);
@memset(self.buffer.bg[borderYU32 * bufWidth + clampedStart .. borderYU32 * bufWidth + clampedEnd], borderBg);
@memset(self.buffer.attributes[borderYU32 * bufWidth + clampedStart .. borderYU32 * bufWidth + clampedEnd], 0);
}
}
}
if (rowIdx >= rowCount) break;
// --- vertical borders for each content line in this row ---
const has_row_boundary_after = if (rowIdx < rowCount - 1) drawInner else drawOuter;
const row_boundary_padding: i32 = if (has_row_boundary_after) 0 else 1;
const contentStartY = borderY + 1;
const contentEndY = rowOffsets[rowIdx + 1] + row_boundary_padding;
var cy = contentStartY;
while (cy < contentEndY and cy < bufHeightI32) : (cy += 1) {
if (cy < 0) continue;
const rowBase = @as(u32, @intCast(cy)) * bufWidth;
var colBorderIdx: u32 = 0;
while (colBorderIdx <= columnCount) : (colBorderIdx += 1) {
const is_outer_col = colBorderIdx == 0 or colBorderIdx == columnCount;
const should_draw_vertical = if (is_outer_col) drawOuter else drawInner;
if (!should_draw_vertical) continue;
const bx = columnOffsets[colBorderIdx];
if (bx >= bufWidthI32) break;
if (bx < 0) continue;
const idx = rowBase + @as(u32, @intCast(bx));
self.buffer.char[idx] = vChar;
self.buffer.fg[idx] = borderFg;
self.buffer.bg[idx] = borderBg;
self.buffer.attributes[idx] = 0;
}
}
}
}
fn tableBorderIntersectionByConnections(borderChars: [*]const u32, hasUp: bool, hasDown: bool, hasLeft: bool, hasRight: bool) u32 {
if (hasUp and hasDown and hasLeft and hasRight) return borderChars[@intFromEnum(BorderCharIndex.cross)];
if (!hasUp and hasDown and !hasLeft and hasRight) return borderChars[@intFromEnum(BorderCharIndex.topLeft)];
if (!hasUp and hasDown and hasLeft and !hasRight) return borderChars[@intFromEnum(BorderCharIndex.topRight)];
if (hasUp and !hasDown and !hasLeft and hasRight) return borderChars[@intFromEnum(BorderCharIndex.bottomLeft)];
if (hasUp and !hasDown and hasLeft and !hasRight) return borderChars[@intFromEnum(BorderCharIndex.bottomRight)];
if (hasUp and hasDown and !hasLeft and hasRight) return borderChars[@intFromEnum(BorderCharIndex.leftT)];
if (hasUp and hasDown and hasLeft and !hasRight) return borderChars[@intFromEnum(BorderCharIndex.rightT)];
if (!hasUp and hasDown and hasLeft and hasRight) return borderChars[@intFromEnum(BorderCharIndex.topT)];
if (hasUp and !hasDown and hasLeft and hasRight) return borderChars[@intFromEnum(BorderCharIndex.bottomT)];
if ((hasLeft or hasRight) and !hasUp and !hasDown) return borderChars[@intFromEnum(BorderCharIndex.horizontal)];
if ((hasUp or hasDown) and !hasLeft and !hasRight) return borderChars[@intFromEnum(BorderCharIndex.vertical)];
return borderChars[@intFromEnum(BorderCharIndex.cross)];
}
/// Draw a box with borders and optional fill
pub fn drawBox(
self: *OptimizedBuffer,
+29 -1
View File
@@ -235,7 +235,6 @@ export fn setCursorColor(rendererPtr: *renderer.CliRenderer, color: [*]const f32
rendererPtr.terminal.setCursorColor(utils.f32PtrToRGBA(color));
}
pub const CursorStyleOptions = extern struct {
style: u8,
blinking: u8,
@@ -472,6 +471,35 @@ export fn attributesGetLinkId(attributes: u32) u32 {
return ansi.TextAttributes.getLinkId(attributes);
}
pub const ExternalGridDrawOptions = extern struct {
draw_inner: bool,
draw_outer: bool,
};
export fn bufferDrawGrid(
bufferPtr: *buffer.OptimizedBuffer,
borderChars: [*]const u32,
borderFg: [*]const f32,
borderBg: [*]const f32,
columnOffsets: [*]const i32,
columnCount: u32,
rowOffsets: [*]const i32,
rowCount: u32,
options: *const ExternalGridDrawOptions,
) void {
bufferPtr.drawGrid(
borderChars,
utils.f32PtrToRGBA(borderFg),
utils.f32PtrToRGBA(borderBg),
columnOffsets,
columnCount,
rowOffsets,
rowCount,
options.draw_inner,
options.draw_outer,
);
}
export fn bufferDrawBox(
bufferPtr: *buffer.OptimizedBuffer,
x: i32,