mirror of
https://github.com/anomalyco/opentui.git
synced 2026-09-19 01:26:03 +08:00
introduce TextTable renderable (#731)
--------- Co-authored-by: Simon Klee <hello@simonklee.dk>
This commit is contained in:
@@ -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`)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 │
|
||||
└───┴────────────────────────┘
|
||||
|
||||
|
||||
|
||||
|
||||
"
|
||||
`;
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user