From 0f9d9ba52bccdb107bc0b04272245e7ded6c291b Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 22:44:06 -0700 Subject: [PATCH 1/4] tui: bench runs both sides through a terminal reducer and publishes only what drew the same screen --- packages/tui/bench/bench.rip | 226 ++++++++++++++++++ packages/tui/bench/harness.rip | 358 +++++++++++++++++++++++++++-- packages/tui/bench/ink-startup.rip | 27 ++- packages/tui/bench/ink.rip | 164 +++++++------ packages/tui/bench/lines.rip | 120 ++++++++++ packages/tui/bench/package.json | 4 +- packages/tui/bench/tui-startup.rip | 26 +++ packages/tui/bench/tui.rip | 146 ++++++++++-- packages/tui/package.json | 1 + packages/tui/test/ink/cells.rip | 6 +- 10 files changed, 962 insertions(+), 116 deletions(-) create mode 100644 packages/tui/bench/bench.rip create mode 100644 packages/tui/bench/lines.rip create mode 100644 packages/tui/bench/tui-startup.rip diff --git a/packages/tui/bench/bench.rip b/packages/tui/bench/bench.rip new file mode 100644 index 00000000..dfb4ff77 --- /dev/null +++ b/packages/tui/bench/bench.rip @@ -0,0 +1,226 @@ +# ============================================================================== +# tui bench — both sides, every scenario, the proof, and RESULTS.md +# +# cd packages/tui/bench +# bun install # Ink and React live HERE, not in rip/tui +# bun run bench # five runs a side; writes RESULTS.md +# rip bench.rip --runs 3 # fewer runs +# rip bench.rip counter # one scenario, printed, RESULTS.md left alone +# +# Each side of each scenario runs in a fresh process — so neither's JIT +# state or heap taints the other — `runs` times, and the table holds +# the median and half the spread. Every run brings back the screen it +# left after every update, as the reducer read it (harness.rip); the +# runs of a side must agree with each other, and the two sides must +# agree at every update, or the scenario is refused from the table and +# the first differing cell is printed instead of its numbers. Then the +# cold start, the lines of code (lines.rip), one frame (frame.rip), one +# key (keys.rip), one mouse report (hit.rip) and where an Ink frame goes +# (profile.rip), all into RESULTS.md: the one source of every number +# README.md and PLAN.md quote. +# ============================================================================== + +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readFileSync, writeFileSync } from 'node:fs' +import { cpus, totalmem, release, loadavg } from 'node:os' +import { SCENARIOS, differ } from './harness.rip' + +HERE =! dirname(fileURLToPath(import.meta.url)) +ROOT =! join(HERE, '../../..') +TIMEOUT =! 180_000 +STARTS =! 7 + +ENV =! { ...process.env, NODE_ENV: 'production', FORCE_COLOR: '3' } +delete ENV.NO_COLOR +delete ENV.CI + +# ── Children ────────────────────────────────────────────────────────────────── + +children =! Set.new() +for signal in ['SIGINT', 'SIGTERM'] + process.on signal, -> + proc.kill 9 for proc in children + process.exit 1 +process.on 'exit', -> proc.kill 9 for proc in children + +# Run a bench file through this checkout's loader and hand back what it +# printed. A child past the timeout is killed outright, by the spawn's +# own timeout and by a timer of ours behind it. +def spawn(file, args = []) + proc = Bun.spawn { + cmd: ['bun', "--preload=#{join ROOT, 'src/loader.js'}", join(ROOT, 'src/cli/run.js'), join(HERE, file), ...args] + cwd: HERE + env: ENV + stdout: 'pipe' + stderr: 'pipe' + timeout: TIMEOUT + killSignal: 'SIGKILL' + } + children.add proc + timer = setTimeout (-> proc.kill 9), TIMEOUT + 1000 + try + [stdout, stderr] = Promise.all! [Response.new(proc.stdout).text(), Response.new(proc.stderr).text()] + code = await proc.exited + throw Error.new "#{file} #{args.join ' '} was killed after #{TIMEOUT / 1000} s" if proc.signalCode + throw Error.new "#{file} #{args.join ' '} exited #{code}:\n#{stderr}" unless code is 0 + { stdout, stderr } + finally + clearTimeout timer + children.delete proc + +# The JSON a child printed as its last line. +def json(file, args = []) + { stdout } = spawn! file, args + line = stdout.trim().split('\n').findLast (line) -> line.startsWith '{' + throw Error.new "#{file} #{args.join ' '} printed no JSON line:\n#{stdout}" unless line + JSON.parse line + +note =! (text) -> process.stderr.write "#{text}\n" + +# ── Arithmetic ──────────────────────────────────────────────────────────────── + +median =! (xs) -> + sorted = xs.slice().sort (a, b) -> a - b + sorted[sorted.length >> 1] + +# Half the spread, so a number reads as `median ±spread`. +spread =! (xs) -> (Math.max(...xs) - Math.min(...xs)) / 2 + +# `median ±spread` of one field over the runs, to `digits`. +stat =! (runs, field, digits, plusMinus = true) -> + xs = (run[field] for run in runs) + text = median(xs).toFixed digits + text += " ±#{spread(xs).toFixed digits}" if plusMinus + text + +# The first update at which two digest lists differ, or null. +mismatch =! (a, b) -> + for i in [0...Math.max(a.length, b.length)] + return i unless a[i] is b[i] + null + +# ── The scenarios ───────────────────────────────────────────────────────────── + +# The runs of `key` on `file`, each a fresh process. +def runsOf(file, key, runs) + results = [] + for n in [0...runs] + results.push json!(file, [key, '--json']) + results + +# The proof for one scenario: every run of a side drew what its first +# did, and the two sides drew the same screen after every update. A +# failure says where, as the first differing cell. +def prove(key, inks, tuis) + for [side, runs] in [['Ink', inks], ['Rip TUI', tuis]] + for run, n in runs when n + at = mismatch runs[0].digests, run.digests + return "#{side}'s run #{n + 1} drew a different screen from its run 1 at update #{at}" if at? + at = mismatch inks[0].digests, tuis[0].digests + return null unless at? + ink = json! 'ink.rip', [key, '--at', String at] + tui = json! 'tui.rip', [key, '--at', String at] + cell = differ ink.cells, tui.cells + return "update #{at}: the screens hash differently but read the same cells" unless cell + "update #{at}, row #{cell.row}, column #{cell.col}: Ink #{JSON.stringify cell.a}, Rip TUI #{JSON.stringify cell.b}" + +HEAD =! '| Scenario | Ink cpu µs | p50 ms | p99 ms | bytes | writes | Rip TUI cpu µs | p50 ms | p99 ms | bytes | writes | Same screen |' +RULE =! '|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|:--|' + +side =! (runs) -> + [stat(runs, 'cpuMicros', 0), stat(runs, 'p50', 2), stat(runs, 'p99', 2, false), stat(runs, 'bytes', 0, false), stat(runs, 'writes', 1, false)] + +def scenarios(only, runs) + lines = [HEAD, RULE] + refused = [] + for { key, label } in SCENARIOS + continue if only and only isnt key + note "#{label}: Ink × #{runs}" + inks = runsOf! 'ink.rip', key, runs + note "#{label}: Rip TUI × #{runs}" + tuis = runsOf! 'tui.rip', key, runs + fault = prove! key, inks, tuis + if fault + refused.push "- **#{label}** — #{fault}" + lines.push "| #{label} | — | — | — | — | — | — | — | — | — | — | ✗ refused |" + else + lines.push "| #{label} | #{side(inks).join ' | '} | #{side(tuis).join ' | '} | ✓ |" + text = lines.join '\n' + text += "\n\nRefused — the reducer read different screens, so no number is published:\n\n#{refused.join '\n'}" if refused.length + { text, refused: refused.length } + +# ── Cold start ──────────────────────────────────────────────────────────────── + +def startup() + rows = [] + cells = {} + for [side, file] in [['Ink 7.1.1', 'ink-startup.rip'], ['Rip TUI', 'tui-startup.rip']] + note "cold start: #{side} × #{STARTS}" + samples = [] + for n in [0...STARTS] + samples.push json!(file) + cells[side] = samples[0].cells + pick = (fn) -> median(samples.map fn).toFixed 1 + rows.push "| #{side} | #{pick (s) -> s.loaded - s.began} | #{pick (s) -> s.framed - s.loaded} | #{pick (s) -> s.framed} |" + cell = differ cells['Ink 7.1.1'], cells['Rip TUI'] + same = if cell then "✗ refused — row #{cell.row}, column #{cell.col}: Ink #{JSON.stringify cell.a}, Rip TUI #{JSON.stringify cell.b}" else '✓ both first frames read the same' + "| | import ms | first frame ms after import | process start → frame ms |\n|---|--:|--:|--:|\n#{rows.join '\n'}\n\nMedian of #{STARTS} fresh processes each; #{same}." + +# ── The rest of the bench ───────────────────────────────────────────────────── + +# A bench script's own printout, as a code block. +def printed(file, args = []) + note "#{file} #{args.join ' '}".trim() + { stdout } = spawn! file, args + "```\n#{stdout.trim()}\n```" + +# ── The report ──────────────────────────────────────────────────────────────── + +version =! (name) -> JSON.parse(readFileSync join(HERE, 'node_modules', name, 'package.json'), 'utf8').version + +machine =! -> + cpu = cpus() + "#{cpu[0]?.model ?? process.arch}, #{cpu.length} cores, #{Math.round totalmem() / 2 ** 30} GB, #{process.platform} #{release()}" + +load =! -> (avg.toFixed 2 for avg in loadavg()).join ' / ' + +def main() + args = process.argv.slice 2 + runs = 5 + only = null + for arg, i in args + if arg is '--runs' then runs = Number args[i + 1] + else if not args[i - 1]?.startsWith('--') then only = arg + began = load() + date = Date.new().toISOString().slice 0, 10 + { text, refused } = scenarios! only, runs + if only + console.log "\n#{text}\n" + return + sections = [] + sections.push "# Rip TUI against Ink — `bun run bench`" + sections.push "Generated #{date} on #{machine()}; Bun #{Bun.version}, Ink #{version 'ink'}, React #{version 'react'}. Load average at the start: #{began}." + sections.push "## Updates\n\nA 200×60 terminal (the resize scenario starts at 120 columns). Each side of each scenario in a fresh process, #{runs} runs; the median and ±half the spread. Ink: `interactive: true`, incremental rendering on, React's production build, memoized rows, its frame throttle lifted (maxFps 1000), every update awaited to the write that ends its synchronized update. Rip TUI: every update flushed. Bytes and writes are per update. *Same screen*: the reducer read both sides' screens after every update, scrollback included, and they matched cell for cell — the text, and the style of every cell that is not a blank (harness.rip states the blank rule).\n\n#{text}" + cold = startup! + sections.push "## Cold start\n\n#{cold}" + counted = printed! 'lines.rip' + sections.push "## Lines of code\n\n#{counted}" + framed = printed! 'frame.rip' + sections.push "## One frame, whole and damaged\n\n`bun run frame` (frame.rip): the frame alone — paint, diff, write; the state change left out, no layout owed — owing every cell, then owing its damage; median microseconds, the bytes of one, and the cells the damaged frame painted and compared.\n\n#{framed}" + keyed = printed! 'keys.rip' + sections.push "## One key\n\n`bun run keys` (keys.rip): the README's select list sent arrow keys as a terminal's bytes, from `send` to the end of the frame each causes.\n\n#{keyed}" + hit = printed! 'hit.rip' + sections.push "## One mouse report\n\n`bun run hit` (hit.rip): a motion report, a click, and the parser alone, on a tree of 2,403 nodes.\n\n#{hit}" + profiles = [] + for key in ['counter', 'table100', 'list2k'] + profiles.push printed!('profile.rip', [key]) + sections.push "## Where an Ink frame goes\n\n`bun run profile` (profile.rip): one Ink scenario under Bun's sampling profiler, every sample charged to a stage of Ink's pipeline.\n\n#{profiles.join '\n\n'}" + sections.push "Load average at the end: #{load()}." + report = sections.join('\n\n') + '\n' + writeFileSync join(HERE, 'RESULTS.md'), report + console.log "\n#{report}" + note "written to bench/RESULTS.md#{if refused then " — #{refused} scenario#{if refused is 1 then '' else 's'} refused" else ''}" + +main! +process.exit 0 diff --git a/packages/tui/bench/harness.rip b/packages/tui/bench/harness.rip index 1f8e894b..68b568db 100644 --- a/packages/tui/bench/harness.rip +++ b/packages/tui/bench/harness.rip @@ -1,33 +1,85 @@ # ============================================================================== -# tui bench harness — the measuring half, shared by every contender +# tui bench harness — the measuring half, shared by both contenders, and +# the terminal reducer that proves they drew the same screen # -# A contender is `{name, mount, update, unmount}` driven against a fake -# terminal stream, so no real TTY is involved and both sides see the -# same columns, rows, and write sink. One scenario run reports CPU time, -# set-to-write latency, bytes and writes per update, and heap growth. +# A contender is mounted against a fake terminal stream, so no real TTY +# is involved and both sides see the same columns, rows and write sink. +# The stream counts and keeps every write; `measure` drives the awaited +# updates and marks where each one's writes end. Afterwards `reduce` +# replays the kept bytes through `Terminal` — a screen of styled cells +# with a cursor — and reads the screen after every update as a digest, +# which bench.rip compares between the two sides. A number is published +# only from a scenario whose digests agree at every update. # ============================================================================== import { EventEmitter } from 'node:events' +import { PLAIN, apply, canon, show } from '../test/ink/cells.rip' -# A write sink shaped like process.stdout. It counts, and keeps the -# bytes only when asked, so a long run holds no output in memory. -export def terminal(columns, rows, keep = false) +# ── The scenarios ───────────────────────────────────────────────────────────── + +export COLUMNS =! 200 +export ROWS =! 60 + +# Every scenario both sides run, in the order the table prints them: +# the updates measured after the warmup, the columns the terminal starts +# with, and what an update is — a tick set on the app, or the terminal +# taking another width. Both sides and the runner read this table, so +# neither can be measured over another's count. +export SCENARIOS =! [ + { key: 'counter', label: 'counter in a 1,000-element tree', updates: 300, warmup: 30 } + { key: 'table10', label: '40×8 table, 10% churn', updates: 300, warmup: 30 } + { key: 'table100', label: '40×8 table, 100% churn', updates: 300, warmup: 30 } + { key: 'list2k', label: '2,000-row list, scroll by one', updates: 60, warmup: 6 } + { key: 'list10k', label: '10,000-row list, scroll by one', updates: 30, warmup: 3 } + { key: 'insert', label: 'insert at the top of a 50-row list', updates: 300, warmup: 30 } + { key: 'static', label: '1,000 scrollback appends', updates: 1000, warmup: 10 } + { key: 'resize', label: 'resize 120 → 80 → 120, 12×4 wrapped', updates: 60, warmup: 6, cols: 120, drive: 'resize' } + { key: 'relayout', label: 'full relayout of 10,000 nodes', updates: 30, warmup: 3 } + { key: 'wide', label: '20×8 table of CJK and emoji, churning', updates: 300, warmup: 30 } +] + +export scenario =! (key) -> + found = SCENARIOS.find (s) -> s.key is key + throw Error.new "no scenario is called #{JSON.stringify key}; the keys are #{(s.key for s in SCENARIOS).join ', '}" unless found + found + +# The width an update of the resize scenario takes the terminal to: it +# starts at 120, so the first update narrows it. +export widthAt =! (i) -> if i % 2 then 120 else 80 + +# ── The stream ──────────────────────────────────────────────────────────────── + +# A write sink shaped like process.stdout. It counts, keeps every write +# and every resize in order, and marks where each update's writes end, +# so the screens can be read back afterwards without a reducer's cost +# inside the timed loop. +export def terminal(columns, rows) out = EventEmitter.new() out.columns = columns out.rows = rows out.isTTY = true out.bytes = 0 out.writes = 0 - out.kept = [] + out.first = { columns, rows } + out.kept = [] # every write, and every resize, in order + out.marks = [] # `kept.length` after each update + out.hook = null # told every write, for a step that waits on one out.write = (data, encoding, done) -> done = encoding if typeof encoding is 'function' - text = String(data) - out.bytes += Buffer.byteLength(text) + text = String data + out.bytes += Buffer.byteLength text out.writes += 1 - out.kept.push text if keep + out.kept.push text queueMicrotask done if typeof done is 'function' + out.hook?(text) true - out.getWindowSize = -> [columns, rows] + out.getWindowSize = -> [out.columns, out.rows] + out.mark = -> out.marks.push out.kept.length + out.resize = (columns, rows = out.rows) -> + out.columns = columns + out.rows = rows + out.kept.push { columns, rows } + out.emit 'resize' out def percentile(sorted, fraction) @@ -35,10 +87,14 @@ def percentile(sorted, fraction) sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))] # Drive `updates` awaited updates through one contender. `step(i)` -# applies update i and resolves when its frame has been written. +# applies update i and resolves when its frame has been written; the +# stream is marked after each, so the reducer can read the screen it +# left. The mount's own frame is marked before the first. export def measure(label, out, updates, warmup, step) + out.mark() for i in [0...warmup] step! i + out.mark() Bun.gc true heap0 = process.memoryUsage().heapUsed bytes0 = out.bytes @@ -49,6 +105,7 @@ export def measure(label, out, updates, warmup, step) t0 = performance.now() step! warmup + i waits.push performance.now() - t0 + out.mark() cpu = process.cpuUsage cpu0 heap = process.memoryUsage().heapUsed - heap0 waits.sort (a, b) -> a - b @@ -65,6 +122,275 @@ export def measure(label, out, updates, warmup, step) export def report(title, rows) p "\n#{title}" - p " #{'scenario'.padEnd(34)} #{'cpu µs/upd'.padStart(11)} #{'p50 ms'.padStart(8)} #{'p99 ms'.padStart(8)} #{'bytes/upd'.padStart(10)} #{'writes/upd'.padStart(11)} #{'heap KB'.padStart(9)}" + p " #{'scenario'.padEnd(40)} #{'cpu µs/upd'.padStart(11)} #{'p50 ms'.padStart(8)} #{'p99 ms'.padStart(8)} #{'bytes/upd'.padStart(10)} #{'writes/upd'.padStart(11)} #{'heap KB'.padStart(9)}" for r in rows - p " #{r.label.padEnd(34)} #{r.cpuMicros.toFixed(0).padStart(11)} #{r.p50.toFixed(2).padStart(8)} #{r.p99.toFixed(2).padStart(8)} #{r.bytes.toFixed(0).padStart(10)} #{r.writes.toFixed(2).padStart(11)} #{r.heapKB.toFixed(0).padStart(9)}" + p " #{r.label.padEnd(40)} #{r.cpuMicros.toFixed(0).padStart(11)} #{r.p50.toFixed(2).padStart(8)} #{r.p99.toFixed(2).padStart(8)} #{r.bytes.toFixed(0).padStart(10)} #{r.writes.toFixed(2).padStart(11)} #{r.heapKB.toFixed(0).padStart(9)}" + +# ── The reducer ─────────────────────────────────────────────────────────────── + +CSI =! /\x1b\[([?<>=]?)([0-9;]*)([A-Za-z@`])/y +OSC =! /\x1b\]([^\x07\x1b]*)(?:\x07|\x1b\\)/y +graphemes =! Intl.Segmenter.new undefined, granularity: 'grapheme' + +# As much of a terminal as both contenders' frames need, seeded by the +# cursor model of test/events/harness.rip and the styled cells of +# test/ink/cells.rip: a screen of `rows` rows of cells that scrolls into +# a scrollback it keeps, a cursor, the style in force, and the +# sequences both sides send. Relative and absolute cursor moves (CUU, +# CUD, CUF, CUB, CNL, CPL, CHA, CUP), erase in display and in line (ED, +# EL — a blank left by an erase carries the background in force, as a +# terminal with background color erase leaves it), SGR through +# cells.rip's reader, a line feed that returns the carriage as a raw +# terminal with ONLCR does and scrolls at the last row, DEC private +# modes set and reset (the cursor, synchronized output, the alternate +# screen, paste, focus and mouse reports change no cell), the cursor +# saved and restored, the queries both sides make, the kitty stack, and +# OSC strings. A sequence outside that list is refused, never skipped. +# Glyphs are as wide as `Bun.stringWidth` draws each grapheme; a mark of +# no width joins the cell before it; a half of a wide glyph overwritten +# takes the other half out; a glyph past the right edge wraps. +# +# The screen is read in cells.rip's notation by its blank rule — a +# space shows nothing of its foreground, bold, dim or italic, and a +# row's trailing default spaces are trimmed — with trailing blank rows +# dropped. That is the normalization, and all of it: the comparison +# holds every row written since the start, the scrollback included. +export class Terminal + constructor: (@columns, @rows) -> + @lines = [] # every row since the start, the scrolled-off ones first + @notes = [] # each line in the notation, cached until it is written + @base = 0 # the index in `lines` of the terminal's top row + @x = @y = 0 + @style = { ...PLAIN } + @saved = null + @alt = null # the primary screen, kept while the alternate is shown + + # Feed one kept write, or one kept resize. + feed!: (text) -> + unless typeof text is 'string' + @resize text.columns, text.rows + return + at = 0 + while at < text.length + code = text.charCodeAt at + if code is 0x1b + at = @escape text, at + else if code is 0x0a + @x = 0 + @down() + at += 1 + else if code is 0x0d + @x = 0 + at += 1 + else if code < 0x20 or code is 0x7f + at += 1 + else + stop = at + 1 + stop += 1 while stop < text.length and text.charCodeAt(stop) >= 0x20 and text.charCodeAt(stop) isnt 0x7f + @print text.slice(at, stop) + at = stop + return + + escape: (text, at) -> + next = text[at + 1] + if next is '[' + CSI.lastIndex = at + found = CSI.exec text + throw Error.new "the reducer was sent a control sequence it does not know: #{JSON.stringify text.slice at, at + 16}" unless found + @control found[1], found[2], found[3] + return at + found[0].length + if next is ']' + OSC.lastIndex = at + found = OSC.exec text + throw Error.new "the reducer was sent an unterminated OSC string: #{JSON.stringify text.slice at, at + 16}" unless found + @style = { ...@style, link: found[1].slice(found[1].indexOf(';', 2) + 1) or null } if found[1].startsWith '8;' + return at + found[0].length + switch next + when '7' then @saved = { x: @x, y: @y } + when '8' then { x: @x, y: @y } = @saved if @saved + when 'c' + @erase 2 + @x = @y = 0 + @style = { ...PLAIN } + else throw Error.new "the reducer was sent an escape it does not know: #{JSON.stringify text.slice at, at + 8}" + at + 2 + + control: (prefix, params, verb) -> + if prefix is '?' + throw Error.new "the reducer was sent CSI ?#{params}#{verb}, which it does not know" unless verb in ['h', 'l', 'n', 'u'] + if verb is 'h' or verb is 'l' + @mode +mode, verb is 'h' for mode in params.split ';' + return + if prefix + throw Error.new "the reducer was sent CSI #{prefix}#{params}#{verb}, which it does not know" unless verb is 'u' + return + n = parseInt(params) or 1 + switch verb + when 'A' then @y = Math.max 0, @y - n + when 'B' then @y = Math.min @rows - 1, @y + n + when 'C' then @x = Math.min @columns - 1, @x + n + when 'D' then @x = Math.max 0, @x - n + when 'E' + @y = Math.min @rows - 1, @y + n + @x = 0 + when 'F' + @y = Math.max 0, @y - n + @x = 0 + when 'G' then @x = Math.min @columns - 1, n - 1 + when 'H', 'f' + [row, col] = params.split ';' + @y = Math.min @rows - 1, (parseInt(row) or 1) - 1 + @x = Math.min @columns - 1, (parseInt(col) or 1) - 1 + when 'J' then @erase parseInt(params) or 0 + when 'K' then @clear @y, (if (parseInt(params) or 0) is 0 then @x else 0), (if (parseInt(params) or 0) is 1 then @x + 1 else @columns) + when 'm' then @style = apply @style, params + when 's' then @saved = { x: @x, y: @y } + when 'u' then { x: @x, y: @y } = @saved if @saved + when 'n', 'c' then null + else throw Error.new "the reducer was sent CSI #{params}#{verb}, which it does not know" + return + + mode: (mode, set) -> + return unless mode is 1049 + if set and not @alt + @alt = { lines: @lines, notes: @notes, base: @base, x: @x, y: @y } + @lines = [] + @notes = [] + @base = @x = @y = 0 + else if not set and @alt + { lines: @lines, notes: @notes, base: @base, x: @x, y: @y } = @alt + @alt = null + return + + # Erase in display: 0 from the cursor down, 1 up to it, 2 the screen, + # 3 the scrollback. + erase: (kind) -> + if kind is 3 + @lines.splice 0, @base + @notes.splice 0, @base + @base = 0 + return + from = if kind is 0 then @y else 0 + to = if kind is 1 then @y else @rows - 1 + for row in [from..to] + @clear row, (if row is @y and kind is 0 then @x else 0), (if row is @y and kind is 1 then @x + 1 else @columns) + return + + # Blank the cells of `row` from `from` up to `to`, with the background + # in force. + clear: (row, from, to) -> + line = @lines[@base + row] + return unless line + blank = if @style.bg then { ...PLAIN, ch: ' ', bg: @style.bg } else undefined + if from is 0 and to >= line.length + line.length = 0 + line.push blank for i in [0...@columns] if blank + else + to = Math.min to, line.length + @unpair line, from + @unpair line, to + line[i] = blank for i in [from...to] + @notes[@base + row] = undefined + return + + # A wide glyph one half of which is `at` goes out whole. + unpair: (line, at) -> + if line[at]?.ch is '' and at > 0 + line[at - 1] = undefined + else if line[at]?.wide is 2 + line[at + 1] = undefined + return + + down: -> + if @y is @rows - 1 then @base += 1 else @y += 1 + + print: (run) -> + for piece in graphemes.segment run + glyph = piece.segment + wide = Bun.stringWidth glyph + if wide is 0 + @join glyph + continue + if @x + wide > @columns + @x = 0 + @down() + line = @lines[@base + @y] ?= [] + @unpair line, @x + @unpair line, @x + wide - 1 + line[@x] = { ...@style, ch: glyph, wide } + line[@x + 1] = { ...@style, ch: '' } if wide is 2 + @notes[@base + @y] = undefined + @x += wide + return + + # A mark of no width joins the glyph before the cursor. + join: (glyph) -> + line = @lines[@base + @y] + at = @x - 1 + at -= 1 if line?[at]?.ch is '' + return unless at >= 0 and line?[at] + line[at] = { ...line[at], ch: line[at].ch + glyph } + @notes[@base + @y] = undefined + return + + # The terminal takes another size: what is past the new right edge is + # lost, as a terminal that does not reflow loses it. + resize: (columns, rows) -> + @columns = columns + @rows = rows + for line, i in @lines when line and line.length > columns + @unpair line, columns + line.length = columns + @notes[i] = undefined + @x = Math.min @x, columns - 1 + @y = Math.min @y, rows - 1 + return + + # One row in the notation, from the cells. + note: (i) -> + @notes[i] ?= show canon([Array.from(@lines[i] ?? [], (cell) -> cell ?? BLANK)])[0] + + # Every row since the start, in the notation, trailing blank rows + # dropped: what the comparison is made of. + screen: -> + rows = (@note i for i in [0...@lines.length]) + rows.pop() while rows.length and rows[rows.length - 1] is '' + rows + + # The same, each cell its own string, to find the first that differs. + cells: -> + rows = for i in [0...@lines.length] + (show [cell] for cell in canon([Array.from(@lines[i] ?? [], (cell) -> cell ?? BLANK)])[0]) + rows.pop() while rows.length and rows[rows.length - 1].length is 0 + rows + + digest: -> String Bun.hash @screen().join('\n') + +BLANK =! { ...PLAIN, ch: ' ' } + +# Replay what `out` kept through a reducer and read the screen after +# every update: a digest each, or with `at`, the cells after update +# `at`. +export def reduce(out, at = null) + term = Terminal.new out.first.columns, out.first.rows + digests = [] + fed = 0 + for mark, k in out.marks + term.feed out.kept[i] for i in [fed...mark] + fed = mark + return term.cells() if at is k + digests.push term.digest() + digests + +# The first cell at which two screens read as cells differ, or null. +export def differ(a, b) + for row in [0...Math.max(a.length, b.length)] + left = a[row] ?? [] + right = b[row] ?? [] + for col in [0...Math.max(left.length, right.length)] + return { row, col, a: left[col] ?? '', b: right[col] ?? '' } unless left[col] is right[col] + null + +# Print `result` as the last line of stdout, for bench.rip to read. +export def emit(result) + console.log JSON.stringify result diff --git a/packages/tui/bench/ink-startup.rip b/packages/tui/bench/ink-startup.rip index 33fa57e0..268fa2ac 100644 --- a/packages/tui/bench/ink-startup.rip +++ b/packages/tui/bench/ink-startup.rip @@ -1,26 +1,37 @@ # ============================================================================== -# tui bench — Ink cold start, one fresh process per sample (see ink.rip) +# tui bench — Ink cold start, one fresh process per sample (bench.rip) # -# Prints three times in milliseconds since process start: when this +# Prints three times in milliseconds since process start — when this # module began, when Ink and React had loaded, and when the first frame -# had been written. +# had been written (the write that ends Ink's synchronized update) — +# and the screen that frame left, read by the reducer. # ============================================================================== -import { terminal } from './harness.rip' +import { terminal, reduce, emit } from './harness.rip' +process.env.FORCE_COLOR = '3' began = performance.now() React = import!('react').default { render: draw, Text } = import!('ink') loaded = performance.now() out = terminal 200, 60 +framed = null +written = Promise.new (resolve) -> + out.hook = (text) -> + return unless text is '\x1b[?2026l' + framed = performance.now() + out.hook = null + resolve() App = -> React.createElement Text, { color: 'green' }, 'ready' -draw React.createElement(App), { +app = draw React.createElement(App), { stdout: out patchConsole: false exitOnCtrlC: false interactive: true - onRender: -> - p JSON.stringify { began, loaded, framed: performance.now() } - process.exit 0 } +await written +out.mark() +app.unmount() +emit { began, loaded, framed, cells: reduce(out, 0) } +process.exit 0 diff --git a/packages/tui/bench/ink.rip b/packages/tui/bench/ink.rip index 05dec39a..d86b14de 100644 --- a/packages/tui/bench/ink.rip +++ b/packages/tui/bench/ink.rip @@ -1,31 +1,36 @@ # ============================================================================== -# tui bench — Ink baselines +# tui bench — Ink on every scenario # # cd packages/tui/bench # bun install # Ink and React live HERE, not in rip/tui -# bun run ink # every scenario, incremental rendering off and on +# bun run bench # both sides, every scenario, the proof, RESULTS.md +# bun run ink # Ink alone, incremental rendering off and on # rip ink.rip counter # one scenario # rip ink.rip counter 5 # one scenario, five times the updates # bun run profile # where an Ink frame goes (profile.rip) # # Ink runs interactive against a fake terminal, its frame throttle -# lifted (maxFps 1000), and every update awaits the frame it causes, so -# a number is the cost of one update and not of a 30 fps timer. React -# loads its production build: the scripts set NODE_ENV, and a run -# without it is refused, since a development React would flatter us. +# lifted (maxFps 1000), and every update awaits the frame it causes — +# the write that ends Ink's synchronized update — so a number is the +# cost of one update and not of a 30 fps timer. React loads its +# production build: the scripts set NODE_ENV, and a run without it is +# refused, since a development React would flatter us. Colors are +# forced to 24-bit so chalk draws them off a terminal, as rip/tui does. # ============================================================================== -import React from 'react' -import { render as draw, Box, Text, Static } from 'ink' import { EventEmitter } from 'node:events' -import { join, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' -import { terminal, measure, report } from './harness.rip' +import { terminal, measure, report, reduce, emit, scenario, SCENARIOS, COLUMNS, ROWS, widthAt } from './harness.rip' -h =! React.createElement +throw Error.new "Ink must be measured on React's production build — use `bun run ink`, or set NODE_ENV=production" unless process.env.NODE_ENV is 'production' +process.env.FORCE_COLOR = '3' +delete process.env.NO_COLOR +delete process.env.CI + +React = import!('react').default +{ render: draw, Box, Text, Static } = import!('ink') -COLUMNS =! 200 -ROWS =! 60 +h =! React.createElement +ESU =! '\x1b[?2026l' def quietInput() stdin = EventEmitter.new() @@ -40,9 +45,10 @@ def quietInput() stdin # Mount `App` (a component taking `{tick}`) and return a `step` that -# sets the tick and resolves once Ink has written the frame. -def mount(App, out, incremental) - framed = null +# applies update i — the tick set, or the terminal resized — and +# resolves once Ink has written the frame: the write that ends its +# synchronized update. +def mount(App, out, incremental, drive) setTick = null Root = -> [tick, set] = React.useState 0 @@ -56,15 +62,14 @@ def mount(App, out, incremental) interactive: true maxFps: 1000 incrementalRendering: incremental - onRender: -> - done = framed - framed = null - done?() } step = (i) -> Promise.new (resolve) -> - framed = resolve - setTick i + 1 + out.hook = (text) -> + return unless text is ESU + out.hook = null + resolve() + if drive is 'resize' then out.resize widthAt(i) else setTick i + 1 { app, step } # ── Scenarios ───────────────────────────────────────────────────────────────── @@ -74,6 +79,8 @@ def mount(App, out, incremental) Cell =! React.memo ({label, width, color}) -> h Box, { width }, h(Text, { color }, label) +Row =! React.memo ({label}) -> h Text, null, label + # One counter in a tree of about 1,000 elements: 50 rows of 10 boxed # labels, one of which shows the tick. CounterTree = ({tick}) -> @@ -107,6 +114,14 @@ def scrolledList(count) h Box, { height: 40, overflow: 'hidden', flexDirection: 'column' }, h(Box, { flexDirection: 'column', flexShrink: 0, marginTop: -(tick % (count - 40)) }, ...items) +# A 50-row list that takes a new row at its top each update and lets +# the last one go: every row moves down by one. +Inserting = ({tick}) -> + rows = for i in [0...50] + id = tick - i + h Row, { key: id, label: "row #{id} — the quick brown fox jumps over the lazy dog" } + h Box, { flexDirection: 'column' }, ...rows + # One line appended to scrollback per update, above a live status line. StaticLog = ({tick}) -> lines = (i for i in [0...tick]) @@ -114,55 +129,74 @@ StaticLog = ({tick}) -> h(Static, { items: lines }, (i) -> h(Text, { key: i }, "done #{i}")), h(Text, null, "working on #{tick}") -SCENARIOS =! [ - ['counter', 'counter in a 1,000-element tree', CounterTree, 300, 30] - ['table10', '40×8 table, 10% churn', churnTable(0.1), 300, 30] - ['table100', '40×8 table, 100% churn', churnTable(1), 300, 30] - ['list', '2,000-row list, scroll by one', scrolledList(2000), 60, 5] - ['static', '1,000 scrollback appends', StaticLog, 1000, 10] -] - -# Cold start: a fresh process per sample, median of seven. Times are -# milliseconds from process start, so the Rip loader's compile of the -# entry file is inside the number, as it is for any Rip program. -def startup() - here = dirname(fileURLToPath(import.meta.url)) - root = join(here, '../../..') - samples = for i in [0...7] - child = Bun.spawnSync { - cmd: ['bun', "--preload=#{join(root, 'src/loader.js')}", join(root, 'src/cli/run.js'), join(here, 'ink-startup.rip')] - cwd: here - env: { ...process.env, NODE_ENV: 'production' } - stdout: 'pipe' - stderr: 'pipe' - } - throw Error.new "the startup sample exited #{child.exitCode}: #{child.stderr.toString()}" unless child.exitCode is 0 - JSON.parse child.stdout.toString().trim().split('\n').pop() - median = (pick) -> - sorted = samples.map(pick).sort (a, b) -> a - b - sorted[sorted.length >> 1] - p "\nInk 7.1.1 — cold start, median of #{samples.length} fresh processes" - p " import ink + react #{median((s) -> s.loaded - s.began).toFixed(1).padStart(7)} ms" - p " first frame written #{median((s) -> s.framed - s.loaded).toFixed(1).padStart(7)} ms after import" - p " process start → frame #{median((s) -> s.framed).toFixed(1).padStart(6)} ms" +# Twelve rows of four cells a quarter of the width each — 30 cells at +# 120 columns, 20 at 80, whole either way — holding a sentence that +# wraps differently at every width the terminal takes. +Wrapped = -> + rows = for r in [0...12] + cells = for c in [0...4] + h Box, { key: c, width: '25%' }, + h(Text, null, "row #{r} cell #{c}: the quick brown fox jumps over the lazy dog and keeps going") + h Box, { key: r }, ...cells + h Box, { flexDirection: 'column' }, ...rows + +# 10,000 nodes — 50 rows of 100 two-column cells, a box and a text +# each — under a root whose top padding toggles, so every node moves. +Relayout = ({tick}) -> + rows = React.useMemo (-> + for r in [0...50] + cells = for c in [0...100] + h Cell, { key: c, width: 2, label: 'ab' } + h Box, { key: r }, ...cells + ), [] + h Box, { flexDirection: 'column', paddingTop: tick % 2 }, ...rows + +# A 20 × 8 table of CJK and emoji cells, every one changing each update. +GLYPHS =! ['漢字', '🍎🍊', '日本語', '🚀✨', '中文', '🐍🦀', '한국어', '🌍🌙'] +WideTable = ({tick}) -> + rows = for r in [0...20] + cells = for c in [0...8] + h Cell, { key: c, width: 10, color: 'green', label: "#{GLYPHS[(r + c) % 8]}#{(tick + r + c) % 10}" } + h Box, { key: r }, ...cells + h Box, { flexDirection: 'column' }, ...rows + +APPS =! { + counter: CounterTree + table10: churnTable(0.1) + table100: churnTable(1) + list2k: scrolledList(2000) + list10k: scrolledList(10000) + insert: Inserting + static: StaticLog + resize: Wrapped + relayout: Relayout + wide: WideTable +} + +# One scenario, measured: the result, with the screen after every +# update as a digest — or, with `at`, the cells after update `at`. +def bench(key, incremental, scale = 1, at = null) + { label, updates, warmup, cols, drive } = scenario key + out = terminal cols ?? COLUMNS, ROWS + { app, step } = mount APPS[key], out, incremental, drive + result = measure! label, out, updates * scale, warmup, step + app.unmount() + if at? then { key, at, cells: reduce(out, at) } else { key, ...result, digests: reduce(out) } def run(only, scale = 1) for incremental in [false, true] results = [] - for [key, label, App, updates, warmup] in SCENARIOS + for { key } in SCENARIOS continue if only and only isnt key - out = terminal COLUMNS, ROWS - { app, step } = mount App, out, incremental - results.push measure!(label, out, updates * scale, warmup, step) - app.unmount() + results.push bench!(key, incremental, scale) report "Ink 7.1.1 — incremental rendering #{if incremental then 'on' else 'off'}, #{COLUMNS}×#{ROWS}", results if import.meta.main - unless process.env.NODE_ENV is 'production' - throw Error.new "Ink must be measured on React's production build — use `bun run ink`, or set NODE_ENV=production" - if process.argv[2] is 'startup' - startup() + args = process.argv.slice 2 + if args[1] is '--json' + emit bench!(args[0], true) + else if args[1] is '--at' + emit bench!(args[0], true, 1, Number(args[2])) else - run! process.argv[2], Number(process.argv[3] ?? 1) - startup() unless process.argv[2] + run! args[0], Number(args[1] ?? 1) process.exit 0 diff --git a/packages/tui/bench/lines.rip b/packages/tui/bench/lines.rip new file mode 100644 index 00000000..5c334ffb --- /dev/null +++ b/packages/tui/bench/lines.rip @@ -0,0 +1,120 @@ +# ============================================================================== +# tui bench — lines of code, by PLAN §2's rule +# +# cd packages/tui/bench +# bun run lines +# +# Non-blank, non-comment source lines; no tests, fixtures, examples or +# benchmarks. Ink is its `src/` (.ts, .tsx) and Yoga its +# `yoga/algorithm/` (.cpp, .h) — the algorithm alone, without the +# bindings, the C API or the node — read from checkouts named by +# INK_SRC and YOGA_SRC, or from misc/ink and misc/yoga at this +# checkout's root; a checkout that is not Ink 7.1.1 is refused. Rip TUI +# is the .rip files its package.json ships, and its runtime is what a +# compiled component imports: src/runtime/reactive.js and components.js. +# A comment is a line that is only one, or a `/* */` block; a `//` or +# `#` after code on the same line leaves the line counted. +# ============================================================================== + +import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs' +import { join, dirname, extname } from 'node:path' +import { fileURLToPath } from 'node:url' + +HERE =! dirname(fileURLToPath(import.meta.url)) +ROOT =! join(HERE, '../../..') +PACKAGE =! join(HERE, '..') + +# The files under `dir` with one of `exts`, recursively. +def files(dir, exts) + found = [] + for name in readdirSync(dir).sort() + path = join dir, name + if statSync(path).isDirectory() then found.push ...files(path, exts) + else found.push path if exts.includes extname(name) + found + +# Non-blank, non-comment lines of a C-like source: `//` lines and +# `/* */` blocks dropped, the rest counted. +def clike(source) + count = 0 + block = false + for line in source.split '\n' + rest = line + code = '' + loop + if block + end = rest.indexOf '*/' + break if end < 0 + block = false + rest = rest.slice end + 2 + else + open = rest.indexOf '/*' + slash = rest.indexOf '//' + if slash >= 0 and (open < 0 or slash < open) + code += rest.slice 0, slash + break + if open < 0 + code += rest + break + code += rest.slice 0, open + block = true + rest = rest.slice open + 2 + count += 1 if code.trim() + count + +# Non-blank, non-comment lines of Rip: `#` lines dropped. +def rip(source) + count = 0 + for line in source.split '\n' + text = line.trim() + count += 1 if text and not text.startsWith '#' + count + +def total(paths, counter) + sum = 0 + sum += counter readFileSync(path, 'utf8') for path in paths + sum + +n =! (value) -> value.toLocaleString 'en-US' + +# Where a checkout is, or a refusal saying how to get one. +def checkout(env, fallback, need) + dir = process.env[env] ?? join(ROOT, 'misc', fallback) + throw Error.new "#{need} is not at #{dir} — check it out there, or name it in #{env}" unless existsSync join(dir, need.split(' ')[0]) + dir + +ink = checkout 'INK_SRC', 'ink', 'src of Ink 7.1.1' +inkVersion = JSON.parse(readFileSync join(ink, 'package.json'), 'utf8').version +throw Error.new "the Ink checkout at #{ink} is #{inkVersion}, not 7.1.1" unless inkVersion is '7.1.1' +yoga = checkout 'YOGA_SRC', 'yoga', 'yoga/algorithm of Yoga' + +inkLines = total files(join(ink, 'src'), ['.ts', '.tsx']), clike +yogaLines = total files(join(yoga, 'yoga/algorithm'), ['.cpp', '.h']), clike + +shipped = JSON.parse(readFileSync join(PACKAGE, 'package.json'), 'utf8').files.filter (name) -> name.endsWith '.rip' +ours = {} +ours[name] = rip readFileSync(join(PACKAGE, name), 'utf8') for name in shipped +oursLines = Object.values(ours).reduce ((a, b) -> a + b), 0 +runtimeFiles = ['reactive.js', 'components.js'] +runtimeLines = total (join(ROOT, 'src/runtime', name) for name in runtimeFiles), clike + +# Ink's production dependency closure, as installed here. +seen = Set.new() +walk = (name) -> + return if seen.has name + seen.add name + manifest = JSON.parse readFileSync(join(HERE, 'node_modules', name, 'package.json'), 'utf8') + walk dep for dep of manifest.dependencies ?? {} +walk 'ink' +deps = seen.size - 1 + +frameworks = inkLines / oursLines +withLayout = (inkLines + yogaLines) / oursLines + +p "| | Ink + Yoga | Rip TUI |" +p "|---|--:|--:|" +p "| Framework only | Ink `src/` #{n inkLines} | #{n oursLines} |" +p "| Framework + layout algorithm | + Yoga `yoga/algorithm/` #{n yogaLines} = #{n inkLines + yogaLines} | #{n oursLines} (layout.rip is #{n ours['layout.rip']} of it) |" +p "| Full runtime closure | + React, react-reconciler, scheduler and #{n deps - 3} more packages (#{n deps} in all) | + Rip runtime #{n runtimeLines} (#{runtimeFiles.join ', '}) = #{n oursLines + runtimeLines} |" +p "" +p "Framework against framework, Ink is #{frameworks.toFixed 1}× the lines of Rip TUI; with the layout algorithm on both sides, Ink + Yoga is #{withLayout.toFixed 1}×. Rip TUI's files: #{("#{name} #{n count}" for name, count of ours).join ', '}." diff --git a/packages/tui/bench/package.json b/packages/tui/bench/package.json index cde8b9a1..0c5b50f6 100644 --- a/packages/tui/bench/package.json +++ b/packages/tui/bench/package.json @@ -5,12 +5,14 @@ "type": "module", "description": "Head-to-head terminal UI comparison \u2014 Ink and React are quarantined here, never in rip/tui", "scripts": { + "bench": "rip bench.rip", "ink": "NODE_ENV=production rip ink.rip", "tui": "rip tui.rip", "frame": "rip frame.rip", "keys": "rip keys.rip", "hit": "rip hit.rip", - "profile": "rip profile.rip" + "profile": "rip profile.rip", + "lines": "rip lines.rip" }, "dependencies": { "ink": "7.1.1", diff --git a/packages/tui/bench/tui-startup.rip b/packages/tui/bench/tui-startup.rip new file mode 100644 index 00000000..ed26177e --- /dev/null +++ b/packages/tui/bench/tui-startup.rip @@ -0,0 +1,26 @@ +# ============================================================================== +# tui bench — Rip TUI cold start, one fresh process per sample (bench.rip) +# +# Prints three times in milliseconds since process start — when this +# module began, when rip/tui had loaded, and when the first frame had +# been written — and the screen that frame left, read by the reducer. +# ============================================================================== + +import { terminal, reduce, emit } from './harness.rip' + +process.env.FORCE_COLOR = '3' +began = performance.now() +{ run, quit, Text } = import!('rip/tui') +loaded = performance.now() + +out = terminal 200, 60 +App = component + render + Text color: 'green', 'ready' +running = run App, stdout: out +framed = performance.now() +out.mark() +quit() +await running.done +emit { began, loaded, framed, cells: reduce(out, 0) } +process.exit 0 diff --git a/packages/tui/bench/tui.rip b/packages/tui/bench/tui.rip index 449f9ed6..07aff77e 100644 --- a/packages/tui/bench/tui.rip +++ b/packages/tui/bench/tui.rip @@ -2,25 +2,36 @@ # tui bench — Rip TUI on the scenarios ink.rip gives Ink # # cd packages/tui/bench -# bun run tui # every scenario the package can draw so far +# bun run bench # both sides, every scenario, the proof, RESULTS.md +# bun run tui # this side alone # rip tui.rip counter # one scenario +# rip tui.rip counter 5 # one scenario, five times the updates # # The same fake terminal, the same trees, the same awaited loop: each -# update sets one state and flushes the frame it owes. +# update sets one state, or resizes the terminal, and flushes the frame +# it owes. Colors are forced to 24-bit, as they are for Ink. # ============================================================================== -import { run, quit, Box, Text } from 'rip/tui' -import { terminal, measure, report } from './harness.rip' +import { run, quit, Box, Text, Static } from 'rip/tui' +import { terminal, measure, report, reduce, emit, scenario, SCENARIOS, COLUMNS, ROWS, widthAt } from './harness.rip' -COLUMNS =! 200 -ROWS =! 60 +process.env.FORCE_COLOR = '3' +delete process.env.NO_COLOR +delete process.env.CI ROWS50 =! (r for r in [0...50]) COLS10 =! (c for c in [0...10]) ROWS40 =! (r for r in [0...40]) COLS8 =! (c for c in [0...8]) +ROWS20 =! (r for r in [0...20]) +ROWS12 =! (r for r in [0...12]) +COLS4 =! (c for c in [0...4]) +COLS100 =! (c for c in [0...100]) -# One counter in a tree of about 1,000 elements. +# ── Scenarios ───────────────────────────────────────────────────────────────── + +# One counter in a tree of about 1,000 elements: 50 rows of 10 boxed +# labels, one of which shows the tick. CounterTree = component @tick := 0 render @@ -45,26 +56,115 @@ def churnTable(share) Text color: 'green' "v#{if share is 1 then @tick else @tick - ((@tick - (r * 8 + c)) %% 10)}" -SCENARIOS =! [ - ['counter', 'counter in a 1,000-element tree', CounterTree, 300, 30] - ['table10', '40×8 table, 10% churn', churnTable(0.1), 300, 30] - ['table100', '40×8 table, 100% churn', churnTable(1), 300, 30] -] +# A long list scrolled one row per update through a 40-row viewport: +# the content offset of the clipped box, no layout owed. +def scrolledList(count) + items = ("row #{i} — the quick brown fox jumps over the lazy dog" for i in [0...count]) + component + @tick := 0 + render + Box height: 40, overflow: 'hidden', flexDirection: 'column', contentOffsetY: @tick % (count - 40) + Box flexDirection: 'column' + for item in items + Text key: item, item + +# A 50-row list that takes a new row at its top each update and lets +# the last one go: every row moves down by one. +Inserting = component + @tick := 0 + render + Box flexDirection: 'column' + for id in (@tick - i for i in ROWS50) + Text key: id, "row #{id} — the quick brown fox jumps over the lazy dog" + +# One line appended to scrollback per update, above a live status line. +StaticLog = component + @tick := 0 + render + Box flexDirection: 'column' + Static + for i in [0...@tick] + Text key: i, "done #{i}" + Text "working on #{@tick}" + +# Twelve rows of four cells a quarter of the width each — 30 cells at +# 120 columns, 20 at 80, whole either way — holding a sentence that +# wraps differently at every width the terminal takes. +Wrapped = component + @tick := 0 + render + Box flexDirection: 'column' + for r in ROWS12 + Box key: r, flexDirection: 'row' + for c in COLS4 + Box key: c, width: '25%' + Text "row #{r} cell #{c}: the quick brown fox jumps over the lazy dog and keeps going" + +# 10,000 nodes — 50 rows of 100 two-column cells, a box and a text +# each — under a root whose top padding toggles, so every node moves. +Relayout = component + @tick := 0 + render + Box flexDirection: 'column', paddingTop: @tick % 2 + for r in ROWS50 + Box key: r, flexDirection: 'row' + for c in COLS100 + Box key: c, width: 2 + Text 'ab' + +# A 20 × 8 table of CJK and emoji cells, every one changing each update. +GLYPHS =! ['漢字', '🍎🍊', '日本語', '🚀✨', '中文', '🐍🦀', '한국어', '🌍🌙'] +WideTable = component + @tick := 0 + render + Box flexDirection: 'column' + for r in ROWS20 + Box key: r, flexDirection: 'row' + for c in COLS8 + Box key: c, width: 10 + Text color: 'green' + "#{GLYPHS[(r + c) % 8]}#{(@tick + r + c) % 10}" + +APPS =! { + counter: CounterTree + table10: churnTable(0.1) + table100: churnTable(1) + list2k: scrolledList(2000) + list10k: scrolledList(10000) + insert: Inserting + static: StaticLog + resize: Wrapped + relayout: Relayout + wide: WideTable +} + +# One scenario, measured: the result, with the screen after every +# update as a digest — or, with `at`, the cells after update `at`. +def bench(key, scale = 1, at = null) + { label, updates, warmup, cols, drive } = scenario key + out = terminal cols ?? COLUMNS, ROWS + running = run APPS[key], stdout: out + step = (i) -> + if drive is 'resize' then out.resize widthAt(i) else running.app.tick.value = i + 1 + running.flush() + result = measure! label, out, updates * scale, warmup, step + quit() + await running.done + if at? then { key, at, cells: reduce(out, at) } else { key, ...result, digests: reduce(out) } -def bench(only, scale = 1) +def all(only, scale = 1) results = [] - for [key, label, App, updates, warmup] in SCENARIOS + for { key } in SCENARIOS continue if only and only isnt key - out = terminal COLUMNS, ROWS - running = run App, stdout: out - step = (i) -> - running.app.tick.value = i + 1 - running.flush() - results.push measure!(label, out, updates * scale, warmup, step) - quit() - await running.done + results.push bench!(key, scale) report "Rip TUI — #{COLUMNS}×#{ROWS}", results if import.meta.main - bench! process.argv[2], Number(process.argv[3] ?? 1) + args = process.argv.slice 2 + if args[1] is '--json' + emit bench!(args[0]) + else if args[1] is '--at' + emit bench!(args[0], 1, Number(args[2])) + else + all! args[0], Number(args[1] ?? 1) process.exit 0 diff --git a/packages/tui/package.json b/packages/tui/package.json index 4ddc0dca..75130d08 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -21,6 +21,7 @@ "screen.rip", "terminal.rip", "input.rip", + "mouse.rip", "README.md", "NOTICE" ] diff --git a/packages/tui/test/ink/cells.rip b/packages/tui/test/ink/cells.rip index bd655f59..40211bd1 100644 --- a/packages/tui/test/ink/cells.rip +++ b/packages/tui/test/ink/cells.rip @@ -33,7 +33,7 @@ NAMES =! ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'] BRIGHT =! ['gray', 'redBright', 'greenBright', 'yellowBright', 'blueBright', 'magentaBright', 'cyanBright', 'whiteBright'] FLAGS =! ['bold', 'dim', 'italic', 'underline', 'strikethrough', 'inverse'] -PLAIN =! { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false, strikethrough: false, inverse: false, link: null } +export PLAIN =! { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false, strikethrough: false, inverse: false, link: null } SET =! { 1: 'bold', 2: 'dim', 3: 'italic', 4: 'underline', 7: 'inverse', 9: 'strikethrough' } CLEAR =! { 22: ['bold', 'dim'], 23: ['italic'], 24: ['underline'], 27: ['inverse'], 29: ['strikethrough'] } @@ -60,7 +60,7 @@ extended = (codes, at) -> if wanted is 1 then [indexed(parts[0]), 3] else [hex(parts), 5] # The style after one SGR sequence's parameters. -apply = (style, params) -> +export apply =! (style, params) -> codes = (Number(part or 0) for part in params.split ';') next = { ...style } at = 0 @@ -147,7 +147,7 @@ export canon =! (rows) -> kept.push cells kept -mark = (cell) -> +export mark =! (cell) -> words = [] words.push cell.fg if cell.fg words.push "on #{cell.bg}" if cell.bg From 378c1bf6d82971bec9994e2ac77109983f8cc060 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 22:51:33 -0700 Subject: [PATCH 2/4] =?UTF-8?q?tui:=20PLAN=20=C2=A711=20and=20the=20README?= =?UTF-8?q?=20quote=20bench/RESULTS.md;=20=C2=A72's=20line=20counts=20from?= =?UTF-8?q?=20lines.rip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tui/PLAN.md | 235 +++++++++++++++++++++------------- packages/tui/README.md | 67 +++++++++- packages/tui/bench/RESULTS.md | 119 +++++++++++++++++ packages/tui/bench/bench.rip | 11 +- packages/tui/bench/hit.rip | 1 + packages/tui/bench/lines.rip | 72 +++-------- 6 files changed, 351 insertions(+), 154 deletions(-) create mode 100644 packages/tui/bench/RESULTS.md diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index 26a43065..fff440ca 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -51,27 +51,32 @@ bench that reproduces it (`packages/AGENTS.md`, value 4). ### Lines of code -Counted as non-blank, non-comment source lines, excluding tests, +Counted as non-blank, non-comment source lines — `test/lines.rip`'s +rule: a line is a comment when it is nothing else — excluding tests, fixtures, examples, and benchmarks. Three rows, always published -together: +together, counted by `bench/lines.rip` and quoted from +`bench/RESULTS.md` (§11): -| Row | Ink + Yoga | Rip TUI (budget) | +| Row | Ink + Yoga | Rip TUI | |---|---|---| -| Framework only | Ink `src/` ≈ 7,250 | ≈ 1,900 | -| Framework + layout algorithm | + `yoga/algorithm/` 4,679 ≈ 12,000 | ≈ 3,400 | -| Full runtime closure | + React, react-reconciler, 23 npm deps | + Rip runtime 2,262 | +| Framework only | Ink `src/` 6,760 | 4,252 | +| Framework + layout algorithm | + `yoga/algorithm/` 3,492 = 10,252 | 4,252 (`layout.rip` is 1,466 of it) | +| Full runtime closure | + React, react-reconciler, scheduler and 33 more packages | + Rip runtime 1,598 (`reactive.js`, `components.js`) = 5,850 | -The honest headline is **roughly 3× smaller**, not more. Raw totals -(9,872 + 12,471) overstate Ink + Yoga by counting comments, bindings, -and the C API. +The honest headline is **2.4× smaller** with the layout algorithm on +both sides, and 1.6× framework against framework: not the 3× the +budget aimed at, since the package ships the mouse, the enhanced +keyboard, text selection, hyperlinks, and the terminal's progress +indicator, which Ink does not. Raw totals overstate Ink + Yoga by +counting comments, bindings, and the C API, and are not quoted. ### Performance -Measured by `bench.rip` against Ink on the same Bun, through a fake -TTY stream, with final screens proven identical by a terminal reducer. -Metrics: CPU time per update, p50/p99 set-to-write latency, bytes and -writes per update, heap delta and GC count, cold start to first frame, -import time, install size, dependency count. +Measured by `bun run bench` (`bench/bench.rip`) against Ink on the same +Bun, through a fake TTY stream, with the screen after every update +proven identical by a terminal reducer (§11). Metrics: CPU time per +update, p50/p99 set-to-write latency, bytes and writes per update, +cold start to first frame, import time, dependency count. ### Clarity @@ -166,9 +171,9 @@ rule: where a report lands, what it fires, and what a drag selects are one idea, and `tui.rip` hands it every report. The event and its dispatch stay in `document.rip`, beside the nodes whose links they walk. -Also at the package root: `test.rip`, `demo.rip`, `bench.rip`, -`bench/` (its own `package.json` quarantining Ink, React, and -`yoga-layout`), `examples/` (`counter`, `files`, `log`, `input`, and +Also at the package root: `test.rip`, `demo.rip`, `bench/` (its own +`package.json` quarantining Ink, React, and `yoga-layout`; `bench.rip` +runs both sides and writes `RESULTS.md`), `examples/` (`counter`, `files`, `log`, `input`, and under `examples/ink/` the four ports of §12 step 6 with Ink's source beside each), `README.md`. @@ -1242,80 +1247,131 @@ run App hardware cursor on the mount's terminal, `{x, y}`, or null while it is hidden. -## 11. Benchmark (`bench.rip`, `bench/`) +## 11. Benchmark (`bench/`) Ink's own benchmarks record no metric and mostly measure React, -because paint is throttled and piped output writes no frames. Ours: +because paint is throttled and piped output writes no frames. Ours, +`bun run bench` in `bench/`, runs every scenario on both sides and +writes `bench/RESULTS.md`, the one source of every number this file +and the README quote: | Scenario | Shows | |---|---| -| One counter in a 1,000-node tree | Fine-grained update cost | -| 10,000-row list through a 40-row viewport | Scrolling, large moves | -| 80×40 table, 10% and 100% churn | Diff and emit | -| Insert at the top of a list | Region moves | +| One counter in a 1,000-element tree | Fine-grained update cost | +| 40×8 table, 10% and 100% churn | Diff and emit | +| 2,000- and 10,000-row lists through a 40-row viewport | Scrolling, large moves | +| Insert at the top of a 50-row list | Region moves | | 1,000 `Static` appends | Scrollback path | | Resize 120 → 80 → 120 | Relayout and repaint | | Full relayout at 10,000 nodes | Layout engine vs WebAssembly | | Startup to first frame, import time | Cold path | -| Wide-character and emoji text | Text path | - -Ink runs with `interactive: true`, with incremental rendering on and -off, `CI` unset, on the same Bun, **on React's production build** (a -run without `NODE_ENV=production` is refused), written the way a -careful React app is (memoized cells and rows). Its frame throttle is -lifted and every update awaits the frame it causes, so a number is -the cost of one update. - -### The Ink baseline - -`bench/` holds the harness (`harness.rip`), the Ink scenarios -(`ink.rip`, `ink-startup.rip`), and the frame profiler -(`profile.rip`). Reproduce with `cd bench && bun install`, then -`bun run ink` and `bun run profile`. Ink 7.1.1, React 19.3.0, Bun -1.4.2, Apple M5, a 200×60 terminal, incremental rendering on: - -| Scenario | CPU per update | p50 / p99 latency | Bytes per update | -|---|---|---|---| -| One counter in a 1,000-element tree | 3.8 ms | 3.1 / 4.9 ms | 347 (9,013 with incremental off) | -| 40×8 table, 10% churn | 2.8 ms | 2.2 / 3.3 ms | 3,109 | -| 40×8 table, 100% churn | 3.9 ms | 3.3 / 4.5 ms | 3,852 | -| 2,000-row list, scroll by one | 22.0 ms | 20.7 / 22.9 ms | 2,501 | -| 1,000 scrollback appends | 0.14 ms | 0.11 / 0.57 ms | 55 | -| Cold start to first frame | 83 ms from process start; importing Ink and React is 47 ms of it | | | - -Every update costs three writes. Heap deltas swing with collector -timing and are not quoted. - -**Where an Ink frame goes** (share of in-frame CPU time, sampled): - -| Stage | counter | table 100% | list | +| A 20×8 table of CJK and emoji, churning | Text path | + +Both sides draw the same tree, node for node and string for string, +against the same fake terminal (`bench/harness.rip`): 200×60, a write +sink that counts and keeps every write. Ink runs with `interactive: +true`, incremental rendering on, `CI` unset, on the same Bun, **on +React's production build** (a run without `NODE_ENV=production` is +refused), written the way a careful React app is (memoized cells and +rows), colors forced to 24-bit for both. Its frame throttle is lifted +and every update awaits the write that ends Ink's synchronized update, +so a number is the cost of one update. Rip TUI sets one state, or +resizes the terminal, and flushes the frame it owes. Each side of each +scenario runs in a fresh process — neither's JIT state or heap taints +the other — five times; the table holds the median and half the spread. + +**The reducer.** Every run keeps the bytes each update wrote, and +afterwards replays them through `Terminal` (`harness.rip`): a screen of +styled cells with a cursor that scrolls into a scrollback it keeps — +relative and absolute cursor moves, erase in display and in line, SGR +through `test/ink/cells.rip`'s reader, line feeds, DEC private modes, +the queries both sides make — and refuses any sequence outside that +list. The screen after every update, scrollback included, is read in +`cells.rip`'s notation under its blank rule (a space shows nothing of +its foreground, bold, dim or italic; a row's trailing default spaces +are trimmed; trailing blank rows are dropped) and digested. A scenario +is in the table only when every run of a side drew what its first +did and the two sides' screens agree at every update, text and style, +cell for cell; otherwise it is refused, and the first differing cell +is printed in the numbers' place. That rule, not a trailing-space +convention, is the whole normalization. + +### The results + +`bench/RESULTS.md`, as `bun run bench` wrote it — Apple M5, Bun 1.4.2, +Ink 7.1.1, React 19.3.0, a load average of 2.3 at the start — cpu in +microseconds per update, latency from the state change to the write +in milliseconds, bytes and writes per update: + +| Scenario | Ink cpu µs | p50 ms | p99 ms | bytes | writes | Rip TUI cpu µs | p50 ms | p99 ms | bytes | writes | Same screen | +|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|:--| +| counter in a 1,000-element tree | 3638 ±163 | 2.99 ±0.13 | 3.93 | 347 | 3.0 | 18 ±1 | 0.00 ±0.00 | 0.02 | 33 | 1.0 | ✓ | +| 40×8 table, 10% churn | 3413 ±89 | 2.39 ±0.12 | 3.41 | 5669 | 3.0 | 216 ±2 | 0.06 ±0.00 | 0.50 | 262 | 1.0 | ✓ | +| 40×8 table, 100% churn | 4241 ±39 | 3.44 ±0.06 | 4.48 | 7052 | 3.0 | 343 ±7 | 0.12 ±0.01 | 0.82 | 1985 | 1.0 | ✓ | +| 2,000-row list, scroll by one | 22346 ±614 | 19.93 ±0.60 | 21.86 | 2501 | 3.0 | 181 ±7 | 0.08 ±0.00 | 0.20 | 273 | 1.0 | ✓ | +| 10,000-row list, scroll by one | 105608 ±230 | 97.19 ±0.51 | 102.29 | 2500 | 3.0 | 303 ±48 | 0.13 ±0.00 | 0.49 | 276 | 1.0 | ✓ | +| insert at the top of a 50-row list | 2740 ±46 | 2.34 ±0.04 | 3.00 | 3155 | 3.0 | 422 ±16 | 0.09 ±0.00 | 0.22 | 344 | 1.0 | ✓ | +| 1,000 scrollback appends | 334 ±8 | 0.11 ±0.00 | 0.57 | 55 | 5.0 | 147 ±1 | 0.05 ±0.00 | 0.11 | 57 | 1.0 | ✓ | +| resize 120 → 80 → 120, 12×4 wrapped | 2049 ±96 | 0.99 ±0.01 | 1.81 | 4295 | 3.5 | 701 ±10 | 0.16 ±0.00 | 0.32 | 6635 | 1.0 | ✓ | +| full relayout of 10,000 nodes | 7753 ±175 | 6.08 ±0.19 | 7.84 | 388 | 3.0 | 2325 ±41 | 1.37 ±0.06 | 2.49 | 380 | 1.0 | ✓ | +| 20×8 table of CJK and emoji, churning | 2728 ±49 | 1.90 ±0.04 | 2.73 | 3791 | 3.0 | 414 ±5 | 0.19 ±0.00 | 0.56 | 990 | 1.0 | ✓ | + +| Cold start | import ms | first frame ms after import | process start → frame ms | +|---|--:|--:|--:| +| Ink 7.1.1 | 39.5 | 4.5 | 70.6 | +| Rip TUI | 8.6 | 3.4 | 39.0 | + +Median of 7 fresh processes each; both first frames read the same. +The lines of code are §2's table, from the same run. + +What the table says, and where it does not flatter: + +- **A scroll costs Ink a layout of the whole list.** Ink scrolls a + list by a negative margin under `overflow: hidden`, so Yoga lays out + every row each frame: 20 ms at 2,000 rows, 97 ms at 10,000. A content + offset here is a repaint of the viewport and owes no layout, so the + 10,000-row scroll costs what the 2,000-row one does. +- **Where Ink is close, the frame is the whole screen.** 100% churn of + the table, the emoji table, and the 10,000-node relayout are frames + that change every cell, and there the two are 8× to 12× apart, not + 100×: the damage path has nothing to skip. +- **The resize writes more bytes than Ink.** After a resize the frame + is drawn from nothing (6,635 bytes to Ink's 4,295), while Ink's + incremental log keeps the lines that did not change. It costs 3× less + CPU, and more bytes. +- **`Static` appends grow with the list.** The cost of an append is not + flat: about 150 µs averaged over 1,000 appends, 210 over 4,000, 350 + over 8,000 (`rip tui.rip static 8`), where Ink stays between 130 and + 330 whatever the count. Each batch lays its container out as a root, + and the body's next layout visits every item under it: a walk as long + as the list, per batch (TODO.md §5). Past about 4,000 appends Ink is + the faster side. +- **Text wraps at a rounded width here and at Yoga's float width in + Ink.** A cell `33%` of 120 columns is 39.6 to Ink's wrapper and 40 + cells to this package's, so a line that fills the cell exactly wraps + differently; and a line that fills its cell exactly at a space leaves + that space at the head of the next line here. The reducer refuses + such a tree (first differing cell: update 0, row 20, column 35), so + the resize scenario's cells are a quarter of 120 and of 80 columns, + whole either way. The second point is a text-engine defect, open. + +**Where an Ink frame goes** (share of in-frame CPU time, sampled; +`bun run profile`, in RESULTS.md): + +| Stage | counter | table 100% | list 2k | |---|---|---|---| -| Text: measure, wrap, tokenize and re-join ANSI | 74% | 43% | 82% | +| Text: measure, wrap, tokenize and re-join ANSI | 75% | 46% | 82% | | Layout: Yoga | 12% | 36% | 12% | -| Reconcile: React and the host config | 5% | 14% | 3% | -| Paint: the output grid, borders | 9% | 7% | 3% | +| Reconcile: React and the host config | 4% | 12% | 2% | +| Paint: the output grid, borders | 9% | 6% | 3% | | Emit: diff and write | under 1% | under 1% | under 1% | In the counter scenario four functions of the ANSI tokenizer (`diffAnsiCodes`, `tokenize`, `undoAnsiCodes`, `ansiCodesToString`) take over half of the whole run. One changed digit in a 1,000-element -tree costs Ink about 4 ms because every frame re-tokenizes and +tree costs Ink about 3.6 ms because every frame re-tokenizes and re-joins the styled text of the entire screen. -**First contact.** The skeleton (full relayout and full repaint every -frame, no damage tracking, no layout cache) on the same scenarios, -`bun run tui`: - -| Scenario | Ink | Rip TUI skeleton | -|---|---|---| -| One counter in a 1,000-element tree | 3.8 ms, 347 bytes, 3 writes | 0.20 ms, 33 bytes, 1 write | -| 40×8 table, 10% churn | 2.8 ms, 3,109 bytes | 0.54 ms, 262 bytes | -| 40×8 table, 100% churn | 3.9 ms, 3,852 bytes | 0.49 ms, 1,985 bytes | - -These rank the two and are not README numbers: the terminal reducer -that proves both sides drew the same screen lands with the published -bench (PR 6). - **One frame, whole and damaged.** `bun run frame` times the frame alone — paint, diff, write; the state change left out, no layout owed — twice for each scenario: owing every cell (`damage: false`), and owing @@ -1325,25 +1381,23 @@ and the run refuses to report if they are not: | Scenario | Whole | Damaged | Cells | |---|---|---|---| -| One cell of a 1,000-element tree | 65 | 1.1 | 7 | -| One cell of a full 200×60 table | 125 | 0.8 | 4 | -| Every cell of that table | 160 | 165 | 12,000 | -| One of 200 bordered panels of wide text | 90 | 0.9 | 8 | -| A 40-line log whose last row comes and goes, laid out each time | 57 | 24 | 200 | +| One cell of a 1,000-element tree | 74 | 1.2 | 7 | +| One cell of a full 200×60 table | 143 | 1.0 | 4 | +| Every cell of that table | 181 | 185 | 12,000 | +| One of 200 bordered panels of wide text | 91 | 1.1 | 8 | +| A 40-line log whose last row comes and goes, laid out each time | 56 | 23 | 200 | A small update's paint and diff fall with its damage, and a frame that -changes everything costs what a whole one does. Under `bun run tui` the -counter in a 1,000-element tree is about 15 µs of CPU an update. 100% -churn of the 40×8 table is about 170 µs over a long run (`rip tui.rip -table100 40`, 12,000 updates) and about 340 µs over the 300 updates of -a default run, which end while the engine is still compiling the path. +changes everything costs what a whole one does. **One key.** `bun run keys` mounts the README's select list and sends it arrow keys as a terminal's bytes, timing each from `send` to the end -of the frame it causes: about 8 µs with ten items (12 cells, 55 bytes) -and about 55 µs with a hundred, where each item's `inverse` is a binding -that reads the choice; a key no listener acts on owes no frame and is -about 0.3 µs. +of the frame it causes: about 5.5 µs with ten items (12 cells, 55 +bytes) and about 42 µs with a hundred, where each item's `inverse` is a +binding that reads the choice; a key no listener acts on owes no frame +and is about 0.2 µs. **One mouse report** (`bun run hit`): on a tree of +2,403 nodes a motion report is about 0.6 µs and a click about 1 µs, +parser included. What this settles: @@ -1354,9 +1408,10 @@ What this settles: - **Knowing the changed node is the right bet.** The dominant costs are whole-screen work repeated per frame, which damage tracking and the same-size fast path never start. -- **The order of work stands.** The skeleton (PR 1) proves the damage - path early; the text and paint step (PR 3) is where the measured - win lands and carries the bench for it. +- **A number without the proof is not a number.** Two renderers that + agree on the bytes they were not asked for — the screen — can be + compared on the bytes they were; the reducer is what makes the + table a comparison and not a ranking. ## 12. Order of work diff --git a/packages/tui/README.md b/packages/tui/README.md index 778c46c6..c7f409ba 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -99,7 +99,61 @@ counts the lines: non-blank and non-comment, the rule Run one with `rip examples/ink/counter.rip`. - +## The numbers + +`bun run bench` in `bench/` runs both sides on the same scenarios — +the same tree, node for node, against the same fake 200×60 terminal — +each in a fresh process, five times, and writes +[bench/RESULTS.md](bench/RESULTS.md); every number reproduces with +`bun run bench`. Ink is measured as a careful React app is written: +React's production build, memoized rows, `interactive: true`, +incremental rendering on, its frame throttle lifted, every update +awaited to the write that ends its frame. A number is published only +when a terminal reducer (`bench/harness.rip`) has replayed what each +side wrote and read the same screen after every update, scrollback +included, cell for cell, text and style; a scenario whose screens +differ is refused from the table with the first differing cell in its +place. [PLAN.md](PLAN.md) §11 says how, and where the table does not +flatter. + +CPU in microseconds per update, latency from the state change to the +write in milliseconds (the median of five runs and half their spread), +bytes and writes per update; Apple M5, Bun 1.4.2, Ink 7.1.1, React +19.3.0: + +| Scenario | Ink cpu µs | p50 ms | p99 ms | bytes | writes | Rip TUI cpu µs | p50 ms | p99 ms | bytes | writes | Same screen | +|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|:--| +| counter in a 1,000-element tree | 3638 ±163 | 2.99 ±0.13 | 3.93 | 347 | 3.0 | 18 ±1 | 0.00 ±0.00 | 0.02 | 33 | 1.0 | ✓ | +| 40×8 table, 10% churn | 3413 ±89 | 2.39 ±0.12 | 3.41 | 5669 | 3.0 | 216 ±2 | 0.06 ±0.00 | 0.50 | 262 | 1.0 | ✓ | +| 40×8 table, 100% churn | 4241 ±39 | 3.44 ±0.06 | 4.48 | 7052 | 3.0 | 343 ±7 | 0.12 ±0.01 | 0.82 | 1985 | 1.0 | ✓ | +| 2,000-row list, scroll by one | 22346 ±614 | 19.93 ±0.60 | 21.86 | 2501 | 3.0 | 181 ±7 | 0.08 ±0.00 | 0.20 | 273 | 1.0 | ✓ | +| 10,000-row list, scroll by one | 105608 ±230 | 97.19 ±0.51 | 102.29 | 2500 | 3.0 | 303 ±48 | 0.13 ±0.00 | 0.49 | 276 | 1.0 | ✓ | +| insert at the top of a 50-row list | 2740 ±46 | 2.34 ±0.04 | 3.00 | 3155 | 3.0 | 422 ±16 | 0.09 ±0.00 | 0.22 | 344 | 1.0 | ✓ | +| 1,000 scrollback appends | 334 ±8 | 0.11 ±0.00 | 0.57 | 55 | 5.0 | 147 ±1 | 0.05 ±0.00 | 0.11 | 57 | 1.0 | ✓ | +| resize 120 → 80 → 120, 12×4 wrapped | 2049 ±96 | 0.99 ±0.01 | 1.81 | 4295 | 3.5 | 701 ±10 | 0.16 ±0.00 | 0.32 | 6635 | 1.0 | ✓ | +| full relayout of 10,000 nodes | 7753 ±175 | 6.08 ±0.19 | 7.84 | 388 | 3.0 | 2325 ±41 | 1.37 ±0.06 | 2.49 | 380 | 1.0 | ✓ | +| 20×8 table of CJK and emoji, churning | 2728 ±49 | 1.90 ±0.04 | 2.73 | 3791 | 3.0 | 414 ±5 | 0.19 ±0.00 | 0.56 | 990 | 1.0 | ✓ | + +| Cold start | import ms | first frame ms after import | process start → frame ms | +|---|--:|--:|--:| +| Ink 7.1.1 | 39.5 | 4.5 | 70.6 | +| Rip TUI | 8.6 | 3.4 | 39.0 | + +Lines of code, by the rule above (`bun run lines`): + +| | Ink + Yoga | Rip TUI | +|---|--:|--:| +| Framework only | Ink `src/` 6,760 | 4,252 | +| Framework + layout algorithm | + Yoga `yoga/algorithm/` 3,492 = 10,252 | 4,252 (layout.rip is 1,466 of it) | +| Full runtime closure | + React, react-reconciler, scheduler and 33 more packages | + Rip runtime 1,598 = 5,850 | + +Ink + Yoga is 2.4× the lines of this package with the layout algorithm +on both sides, 1.6× framework against framework. Two rows where the +table is not one-sided: after a resize the frame is drawn from +nothing, which is more bytes than Ink's incremental log writes; and a +`Static` append grows with the items already written — about 150 µs +averaged over 1,000 appends, 350 over 8,000 — where Ink's stays flat, +so past a few thousand appends Ink is the faster side. ## Examples @@ -747,10 +801,13 @@ next frame's write, and is cleared on every way out. ## What is here, and what is planned -[PLAN.md](PLAN.md) is the design and the order of work: the app -lifecycle, scrollback output, and the published comparison with Ink. `bench/` holds the harness, both contenders -(`bun run ink`, `bun run tui`), and the cost of one frame, whole and -damaged (`bun run frame`), and of one key (`bun run keys`). +[PLAN.md](PLAN.md) is the design and the order of work. `bench/` holds +the harness with its terminal reducer, both contenders (`bun run ink`, +`bun run tui`), the runner that proves and publishes them (`bun run +bench`, [bench/RESULTS.md](bench/RESULTS.md)), the lines of code (`bun +run lines`), and the cost of one frame, whole and damaged (`bun run +frame`), of one key (`bun run keys`), and of one mouse report (`bun run +hit`). ## Demo diff --git a/packages/tui/bench/RESULTS.md b/packages/tui/bench/RESULTS.md new file mode 100644 index 00000000..06db0dbd --- /dev/null +++ b/packages/tui/bench/RESULTS.md @@ -0,0 +1,119 @@ +# Rip TUI against Ink — `bun run bench` + +Generated 2026-09-22 on Apple M5, 10 cores, 32 GB, darwin 27.0.0; Bun 1.4.2, Ink 7.1.1, React 19.3.0. Load average at the start: 2.28 / 3.54 / 4.74. + +## Updates + +A 200×60 terminal (the resize scenario starts at 120 columns). Each side of each scenario in a fresh process, 5 runs; the median and ±half the spread. Ink: `interactive: true`, incremental rendering on, React's production build, memoized rows, its frame throttle lifted (maxFps 1000), every update awaited to the write that ends its synchronized update. Rip TUI: every update flushed. Bytes and writes are per update. *Same screen*: the reducer read both sides' screens after every update, scrollback included, and they matched cell for cell — the text, and the style of every cell that is not a blank (harness.rip states the blank rule). + +| Scenario | Ink cpu µs | p50 ms | p99 ms | bytes | writes | Rip TUI cpu µs | p50 ms | p99 ms | bytes | writes | Same screen | +|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|:--| +| counter in a 1,000-element tree | 3638 ±163 | 2.99 ±0.13 | 3.93 | 347 | 3.0 | 18 ±1 | 0.00 ±0.00 | 0.02 | 33 | 1.0 | ✓ | +| 40×8 table, 10% churn | 3413 ±89 | 2.39 ±0.12 | 3.41 | 5669 | 3.0 | 216 ±2 | 0.06 ±0.00 | 0.50 | 262 | 1.0 | ✓ | +| 40×8 table, 100% churn | 4241 ±39 | 3.44 ±0.06 | 4.48 | 7052 | 3.0 | 343 ±7 | 0.12 ±0.01 | 0.82 | 1985 | 1.0 | ✓ | +| 2,000-row list, scroll by one | 22346 ±614 | 19.93 ±0.60 | 21.86 | 2501 | 3.0 | 181 ±7 | 0.08 ±0.00 | 0.20 | 273 | 1.0 | ✓ | +| 10,000-row list, scroll by one | 105608 ±230 | 97.19 ±0.51 | 102.29 | 2500 | 3.0 | 303 ±48 | 0.13 ±0.00 | 0.49 | 276 | 1.0 | ✓ | +| insert at the top of a 50-row list | 2740 ±46 | 2.34 ±0.04 | 3.00 | 3155 | 3.0 | 422 ±16 | 0.09 ±0.00 | 0.22 | 344 | 1.0 | ✓ | +| 1,000 scrollback appends | 334 ±8 | 0.11 ±0.00 | 0.57 | 55 | 5.0 | 147 ±1 | 0.05 ±0.00 | 0.11 | 57 | 1.0 | ✓ | +| resize 120 → 80 → 120, 12×4 wrapped | 2049 ±96 | 0.99 ±0.01 | 1.81 | 4295 | 3.5 | 701 ±10 | 0.16 ±0.00 | 0.32 | 6635 | 1.0 | ✓ | +| full relayout of 10,000 nodes | 7753 ±175 | 6.08 ±0.19 | 7.84 | 388 | 3.0 | 2325 ±41 | 1.37 ±0.06 | 2.49 | 380 | 1.0 | ✓ | +| 20×8 table of CJK and emoji, churning | 2728 ±49 | 1.90 ±0.04 | 2.73 | 3791 | 3.0 | 414 ±5 | 0.19 ±0.00 | 0.56 | 990 | 1.0 | ✓ | + +## Cold start + +| | import ms | first frame ms after import | process start → frame ms | +|---|--:|--:|--:| +| Ink 7.1.1 | 39.5 | 4.5 | 70.6 | +| Rip TUI | 8.6 | 3.4 | 39.0 | + +Median of 7 fresh processes each; ✓ both first frames read the same. + +## Lines of code + +`bun run lines` (lines.rip): non-blank, non-comment lines by test/lines.rip's rule; Ink's `src/` and Yoga's `yoga/algorithm/` against the files this package ships. + +| | Ink + Yoga | Rip TUI | +|---|--:|--:| +| Framework only | Ink `src/` 6,760 | 4,252 | +| Framework + layout algorithm | + Yoga `yoga/algorithm/` 3,492 = 10,252 | 4,252 (layout.rip is 1,466 of it) | +| Full runtime closure | + React, react-reconciler, scheduler and 33 more packages (36 in all) | + Rip runtime 1,598 (reactive.js, components.js) = 5,850 | + +Framework against framework, Ink is 1.6× the lines of Rip TUI; with the layout algorithm on both sides, Ink + Yoga is 2.4×. Rip TUI's files: tui.rip 311, document.rip 388, focus.rip 63, layout.rip 1,466, text.rip 421, paint.rip 701, screen.rip 173, terminal.rip 203, input.rip 315, mouse.rip 211. + +## One frame, whole and damaged + +`bun run frame` (frame.rip): the frame alone — paint, diff, write; the state change left out, no layout owed — owing every cell, then owing its damage; median microseconds, the bytes of one, and the cells the damaged frame painted and compared. + +``` +Rip TUI — one frame, 200×60, µs + scenario whole p50 p99 damaged p50 p99 bytes cells + one cell of a 1,000-element tree 73.5 93.7 1.2 2.7 33 7 + one cell of a 200×60 table 143.3 173.1 1.0 2.4 43 4 + every cell of a 200×60 table 181.0 255.5 185.1 248.7 7962 12000 + one of 200 bordered wide panels 91.4 116.2 1.1 2.7 46 8 + a last row that comes and goes 56.4 88.1 22.8 34.3 72 200 +``` + +## One key + +`bun run keys` (keys.rip): the README's select list sent arrow keys as a terminal's bytes, from `send` to the end of the frame each causes. + +``` +10 items a key that moves the choice: 5.5 µs (12 cells, 55 bytes) a key that changes nothing: 0.2 µs + 100 items a key that moves the choice: 41.9 µs (13 cells, 58 bytes) a key that changes nothing: 0.1 µs +``` + +## One mouse report + +`bun run hit` (hit.rip): a motion report, a click, and the parser alone, on a tree of 2,403 nodes. + +``` +nodes: 2403 +motion report, same target: 0.57 µs +motion report, target changes (leave + enter): 0.59 µs +motion report, same target, with a mousemove listener: 0.71 µs +click (down, up, click, focus): 1.06 µs +parser alone: 0.13 µs +``` + +## Where an Ink frame goes + +`bun run profile` (profile.rip): one Ink scenario under Bun's sampling profiler, every sample charged to a stage of Ink's pipeline. + +``` +Ink 7.1.1 — 'counter' scenario, share of in-frame CPU time + reconcile 3.8% ██ + layout 12.1% ██████ + text 75.1% ██████████████████████████████████████ + paint 8.7% ████ + emit 0.3% + + outside the frame: harness 1817 ms, startup 21 ms, other 35 ms + in-frame total: 13546 ms +``` + +``` +Ink 7.1.1 — 'table100' scenario, share of in-frame CPU time + reconcile 12.3% ██████ + layout 35.8% ██████████████████ + text 45.8% ███████████████████████ + paint 5.7% ███ + emit 0.4% + + outside the frame: harness 2646 ms, startup 23 ms, other 30 ms + in-frame total: 12122 ms +``` + +``` +Ink 7.1.1 — 'list2k' scenario, share of in-frame CPU time + reconcile 2.4% █ + layout 12.4% ██████ + text 82.2% █████████████████████████████████████████ + paint 2.8% █ + emit 0.1% + + outside the frame: harness 438 ms, startup 26 ms, other 39 ms + in-frame total: 14445 ms +``` + +Load average at the end: 3.85 / 3.67 / 4.63. diff --git a/packages/tui/bench/bench.rip b/packages/tui/bench/bench.rip index dfb4ff77..884c2beb 100644 --- a/packages/tui/bench/bench.rip +++ b/packages/tui/bench/bench.rip @@ -169,11 +169,12 @@ def startup() # ── The rest of the bench ───────────────────────────────────────────────────── -# A bench script's own printout, as a code block. -def printed(file, args = []) +# A bench script's own printout, as a code block — or as it is, for +# one that prints markdown. +def printed(file, args = [], markdown = false) note "#{file} #{args.join ' '}".trim() { stdout } = spawn! file, args - "```\n#{stdout.trim()}\n```" + if markdown then stdout.trim() else "```\n#{stdout.trim()}\n```" # ── The report ──────────────────────────────────────────────────────────────── @@ -204,8 +205,8 @@ def main() sections.push "## Updates\n\nA 200×60 terminal (the resize scenario starts at 120 columns). Each side of each scenario in a fresh process, #{runs} runs; the median and ±half the spread. Ink: `interactive: true`, incremental rendering on, React's production build, memoized rows, its frame throttle lifted (maxFps 1000), every update awaited to the write that ends its synchronized update. Rip TUI: every update flushed. Bytes and writes are per update. *Same screen*: the reducer read both sides' screens after every update, scrollback included, and they matched cell for cell — the text, and the style of every cell that is not a blank (harness.rip states the blank rule).\n\n#{text}" cold = startup! sections.push "## Cold start\n\n#{cold}" - counted = printed! 'lines.rip' - sections.push "## Lines of code\n\n#{counted}" + counted = printed! 'lines.rip', [], true + sections.push "## Lines of code\n\n`bun run lines` (lines.rip): non-blank, non-comment lines by test/lines.rip's rule; Ink's `src/` and Yoga's `yoga/algorithm/` against the files this package ships.\n\n#{counted}" framed = printed! 'frame.rip' sections.push "## One frame, whole and damaged\n\n`bun run frame` (frame.rip): the frame alone — paint, diff, write; the state change left out, no layout owed — owing every cell, then owing its damage; median microseconds, the bytes of one, and the cells the damaged frame painted and compared.\n\n#{framed}" keyed = printed! 'keys.rip' diff --git a/packages/tui/bench/hit.rip b/packages/tui/bench/hit.rip index 53c5b70e..86db001a 100644 --- a/packages/tui/bench/hit.rip +++ b/packages/tui/bench/hit.rip @@ -1,5 +1,6 @@ # ============================================================================== # tui bench — what one mouse report costs on a tree of 1,576 elements +# (2,403 nodes with their text and the body's, the count it prints) # # cd packages/tui/bench # bun run hit diff --git a/packages/tui/bench/lines.rip b/packages/tui/bench/lines.rip index 5c334ffb..ff77a2cf 100644 --- a/packages/tui/bench/lines.rip +++ b/packages/tui/bench/lines.rip @@ -4,21 +4,22 @@ # cd packages/tui/bench # bun run lines # -# Non-blank, non-comment source lines; no tests, fixtures, examples or -# benchmarks. Ink is its `src/` (.ts, .tsx) and Yoga its -# `yoga/algorithm/` (.cpp, .h) — the algorithm alone, without the -# bindings, the C API or the node — read from checkouts named by -# INK_SRC and YOGA_SRC, or from misc/ink and misc/yoga at this -# checkout's root; a checkout that is not Ink 7.1.1 is refused. Rip TUI -# is the .rip files its package.json ships, and its runtime is what a -# compiled component imports: src/runtime/reactive.js and components.js. -# A comment is a line that is only one, or a `/* */` block; a `//` or -# `#` after code on the same line leaves the line counted. +# Non-blank, non-comment source lines, counted by test/lines.rip's +# `count` — the rule the README's example table is counted by; no +# tests, fixtures, examples or benchmarks. Ink is its `src/` (.ts, +# .tsx) and Yoga its `yoga/algorithm/` (.cpp, .h) — the algorithm +# alone, without the bindings, the C API or the node — read from +# checkouts named by INK_SRC and YOGA_SRC, or from misc/ink and +# misc/yoga at this checkout's root; a checkout that is not Ink 7.1.1 +# is refused. Rip TUI is the .rip files its package.json ships, and its +# runtime is what a compiled component imports: src/runtime/reactive.js +# and components.js. # ============================================================================== import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs' import { join, dirname, extname } from 'node:path' import { fileURLToPath } from 'node:url' +import { count } from '../test/lines.rip' HERE =! dirname(fileURLToPath(import.meta.url)) ROOT =! join(HERE, '../../..') @@ -33,46 +34,9 @@ def files(dir, exts) else found.push path if exts.includes extname(name) found -# Non-blank, non-comment lines of a C-like source: `//` lines and -# `/* */` blocks dropped, the rest counted. -def clike(source) - count = 0 - block = false - for line in source.split '\n' - rest = line - code = '' - loop - if block - end = rest.indexOf '*/' - break if end < 0 - block = false - rest = rest.slice end + 2 - else - open = rest.indexOf '/*' - slash = rest.indexOf '//' - if slash >= 0 and (open < 0 or slash < open) - code += rest.slice 0, slash - break - if open < 0 - code += rest - break - code += rest.slice 0, open - block = true - rest = rest.slice open + 2 - count += 1 if code.trim() - count - -# Non-blank, non-comment lines of Rip: `#` lines dropped. -def rip(source) - count = 0 - for line in source.split '\n' - text = line.trim() - count += 1 if text and not text.startsWith '#' - count - -def total(paths, counter) +def total(paths) sum = 0 - sum += counter readFileSync(path, 'utf8') for path in paths + sum += count path for path in paths sum n =! (value) -> value.toLocaleString 'en-US' @@ -88,15 +52,15 @@ inkVersion = JSON.parse(readFileSync join(ink, 'package.json'), 'utf8').version throw Error.new "the Ink checkout at #{ink} is #{inkVersion}, not 7.1.1" unless inkVersion is '7.1.1' yoga = checkout 'YOGA_SRC', 'yoga', 'yoga/algorithm of Yoga' -inkLines = total files(join(ink, 'src'), ['.ts', '.tsx']), clike -yogaLines = total files(join(yoga, 'yoga/algorithm'), ['.cpp', '.h']), clike +inkLines = total files(join(ink, 'src'), ['.ts', '.tsx']) +yogaLines = total files(join(yoga, 'yoga/algorithm'), ['.cpp', '.h']) shipped = JSON.parse(readFileSync join(PACKAGE, 'package.json'), 'utf8').files.filter (name) -> name.endsWith '.rip' ours = {} -ours[name] = rip readFileSync(join(PACKAGE, name), 'utf8') for name in shipped +ours[name] = count join(PACKAGE, name) for name in shipped oursLines = Object.values(ours).reduce ((a, b) -> a + b), 0 runtimeFiles = ['reactive.js', 'components.js'] -runtimeLines = total (join(ROOT, 'src/runtime', name) for name in runtimeFiles), clike +runtimeLines = total (join(ROOT, 'src/runtime', name) for name in runtimeFiles) # Ink's production dependency closure, as installed here. seen = Set.new() @@ -117,4 +81,4 @@ p "| Framework only | Ink `src/` #{n inkLines} | #{n oursLines} |" p "| Framework + layout algorithm | + Yoga `yoga/algorithm/` #{n yogaLines} = #{n inkLines + yogaLines} | #{n oursLines} (layout.rip is #{n ours['layout.rip']} of it) |" p "| Full runtime closure | + React, react-reconciler, scheduler and #{n deps - 3} more packages (#{n deps} in all) | + Rip runtime #{n runtimeLines} (#{runtimeFiles.join ', '}) = #{n oursLines + runtimeLines} |" p "" -p "Framework against framework, Ink is #{frameworks.toFixed 1}× the lines of Rip TUI; with the layout algorithm on both sides, Ink + Yoga is #{withLayout.toFixed 1}×. Rip TUI's files: #{("#{name} #{n count}" for name, count of ours).join ', '}." +p "Framework against framework, Ink is #{frameworks.toFixed 1}× the lines of Rip TUI; with the layout algorithm on both sides, Ink + Yoga is #{withLayout.toFixed 1}×. Rip TUI's files: #{("#{name} #{n lines}" for name, lines of ours).join ', '}." From f5fb7fba8fdeaa4a4631967fb710ebba2495b02d Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 22:52:15 -0700 Subject: [PATCH 3/4] tui: README's bench sentence on one line --- packages/tui/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/tui/README.md b/packages/tui/README.md index c7f409ba..b618ca5a 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -104,8 +104,9 @@ Run one with `rip examples/ink/counter.rip`. `bun run bench` in `bench/` runs both sides on the same scenarios — the same tree, node for node, against the same fake 200×60 terminal — each in a fresh process, five times, and writes -[bench/RESULTS.md](bench/RESULTS.md); every number reproduces with -`bun run bench`. Ink is measured as a careful React app is written: +[bench/RESULTS.md](bench/RESULTS.md): +every number reproduces with `bun run bench`. +Ink is measured as a careful React app is written: React's production build, memoized rows, `interactive: true`, incremental rendering on, its frame throttle lifted, every update awaited to the write that ends its frame. A number is published only From ccb0cdfc58d0ca6b211bb8a0dfdec0bbc899b383 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 23:10:34 -0700 Subject: [PATCH 4/4] tui: lines.rip counts Yoga at the version Ink ships; the reducer refuses every private mode and control it does not model, pinned --- packages/tui/PLAN.md | 12 ++++++--- packages/tui/README.md | 7 +++--- packages/tui/bench/RESULTS.md | 6 ++--- packages/tui/bench/bench.rip | 18 ++++++++++++-- packages/tui/bench/harness.rip | 45 +++++++++++++++++++++++++++++----- packages/tui/bench/lines.rip | 14 ++++++++--- 6 files changed, 80 insertions(+), 22 deletions(-) diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index fff440ca..d036ca18 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -60,11 +60,13 @@ together, counted by `bench/lines.rip` and quoted from | Row | Ink + Yoga | Rip TUI | |---|---|---| | Framework only | Ink `src/` 6,760 | 4,252 | -| Framework + layout algorithm | + `yoga/algorithm/` 3,492 = 10,252 | 4,252 (`layout.rip` is 1,466 of it) | +| Framework + layout algorithm | + Yoga 3.2.1 `yoga/algorithm/` 3,042 = 9,802 | 4,252 (`layout.rip` is 1,466 of it) | | Full runtime closure | + React, react-reconciler, scheduler and 33 more packages | + Rip runtime 1,598 (`reactive.js`, `components.js`) = 5,850 | -The honest headline is **2.4× smaller** with the layout algorithm on -both sides, and 1.6× framework against framework: not the 3× the +Yoga is counted at 3.2.1, the version Ink 7.1.1 ships (`lines.rip` +refuses any other). The honest headline is **2.3× smaller** with the +layout algorithm on both sides, and 1.6× framework against framework: +not the 3× the budget aimed at, since the package ships the mouse, the enhanced keyboard, text selection, hyperlinks, and the terminal's progress indicator, which Ink does not. Raw totals overstate Ink + Yoga by @@ -1286,7 +1288,9 @@ styled cells with a cursor that scrolls into a scrollback it keeps — relative and absolute cursor moves, erase in display and in line, SGR through `test/ink/cells.rip`'s reader, line feeds, DEC private modes, the queries both sides make — and refuses any sequence outside that -list. The screen after every update, scrollback included, is read in +list: another private mode, another escape, a control character +(`pinned` in `harness.rip` holds it to that before every run). The +screen after every update, scrollback included, is read in `cells.rip`'s notation under its blank rule (a space shows nothing of its foreground, bold, dim or italic; a row's trailing default spaces are trimmed; trailing blank rows are dropped) and digested. A scenario diff --git a/packages/tui/README.md b/packages/tui/README.md index b618ca5a..1574d47a 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -145,11 +145,12 @@ Lines of code, by the rule above (`bun run lines`): | | Ink + Yoga | Rip TUI | |---|--:|--:| | Framework only | Ink `src/` 6,760 | 4,252 | -| Framework + layout algorithm | + Yoga `yoga/algorithm/` 3,492 = 10,252 | 4,252 (layout.rip is 1,466 of it) | +| Framework + layout algorithm | + Yoga 3.2.1 `yoga/algorithm/` 3,042 = 9,802 | 4,252 (layout.rip is 1,466 of it) | | Full runtime closure | + React, react-reconciler, scheduler and 33 more packages | + Rip runtime 1,598 = 5,850 | -Ink + Yoga is 2.4× the lines of this package with the layout algorithm -on both sides, 1.6× framework against framework. Two rows where the +Ink + Yoga 3.2.1, the version Ink 7.1.1 ships, is 2.3× the lines of +this package with the layout algorithm on both sides, 1.6× framework +against framework. Two rows where the table is not one-sided: after a resize the frame is drawn from nothing, which is more bytes than Ink's incremental log writes; and a `Static` append grows with the items already written — about 150 µs diff --git a/packages/tui/bench/RESULTS.md b/packages/tui/bench/RESULTS.md index 06db0dbd..2324068f 100644 --- a/packages/tui/bench/RESULTS.md +++ b/packages/tui/bench/RESULTS.md @@ -30,15 +30,15 @@ Median of 7 fresh processes each; ✓ both first frames read the same. ## Lines of code -`bun run lines` (lines.rip): non-blank, non-comment lines by test/lines.rip's rule; Ink's `src/` and Yoga's `yoga/algorithm/` against the files this package ships. +`bun run lines` (lines.rip): non-blank, non-comment lines by test/lines.rip's rule; Ink's `src/` and Yoga's `yoga/algorithm/`, at the version Ink installs, against the files this package ships. | | Ink + Yoga | Rip TUI | |---|--:|--:| | Framework only | Ink `src/` 6,760 | 4,252 | -| Framework + layout algorithm | + Yoga `yoga/algorithm/` 3,492 = 10,252 | 4,252 (layout.rip is 1,466 of it) | +| Framework + layout algorithm | + Yoga 3.2.1 `yoga/algorithm/` 3,042 = 9,802 | 4,252 (layout.rip is 1,466 of it) | | Full runtime closure | + React, react-reconciler, scheduler and 33 more packages (36 in all) | + Rip runtime 1,598 (reactive.js, components.js) = 5,850 | -Framework against framework, Ink is 1.6× the lines of Rip TUI; with the layout algorithm on both sides, Ink + Yoga is 2.4×. Rip TUI's files: tui.rip 311, document.rip 388, focus.rip 63, layout.rip 1,466, text.rip 421, paint.rip 701, screen.rip 173, terminal.rip 203, input.rip 315, mouse.rip 211. +Framework against framework, Ink is 1.6× the lines of Rip TUI; with the layout algorithm on both sides, Ink + Yoga 3.2.1 (the version Ink 7.1.1 ships) is 2.3×. Rip TUI's files: tui.rip 311, document.rip 388, focus.rip 63, layout.rip 1,466, text.rip 421, paint.rip 701, screen.rip 173, terminal.rip 203, input.rip 315, mouse.rip 211. ## One frame, whole and damaged diff --git a/packages/tui/bench/bench.rip b/packages/tui/bench/bench.rip index 884c2beb..90667ce1 100644 --- a/packages/tui/bench/bench.rip +++ b/packages/tui/bench/bench.rip @@ -6,6 +6,7 @@ # bun run bench # five runs a side; writes RESULTS.md # rip bench.rip --runs 3 # fewer runs # rip bench.rip counter # one scenario, printed, RESULTS.md left alone +# rip bench.rip --lines # the lines of code alone, into RESULTS.md # # Each side of each scenario runs in a fresh process — so neither's JIT # state or heap taints the other — `runs` times, and the table holds @@ -24,7 +25,7 @@ import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { readFileSync, writeFileSync } from 'node:fs' import { cpus, totalmem, release, loadavg } from 'node:os' -import { SCENARIOS, differ } from './harness.rip' +import { SCENARIOS, differ, pinned } from './harness.rip' HERE =! dirname(fileURLToPath(import.meta.url)) ROOT =! join(HERE, '../../..') @@ -125,6 +126,7 @@ def prove(key, inks, tuis) return "update #{at}: the screens hash differently but read the same cells" unless cell "update #{at}, row #{cell.row}, column #{cell.col}: Ink #{JSON.stringify cell.a}, Rip TUI #{JSON.stringify cell.b}" +LINES =! "`bun run lines` (lines.rip): non-blank, non-comment lines by test/lines.rip's rule; Ink's `src/` and Yoga's `yoga/algorithm/`, at the version Ink installs, against the files this package ships." HEAD =! '| Scenario | Ink cpu µs | p50 ms | p99 ms | bytes | writes | Rip TUI cpu µs | p50 ms | p99 ms | bytes | writes | Same screen |' RULE =! '|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|:--|' @@ -193,6 +195,18 @@ def main() for arg, i in args if arg is '--runs' then runs = Number args[i + 1] else if not args[i - 1]?.startsWith('--') then only = arg + pinned() + if args.includes '--lines' + counted = printed! 'lines.rip', [], true + path = join HERE, 'RESULTS.md' + parts = readFileSync(path, 'utf8').split /^(?=## )/m + at = parts.findIndex (part) -> part.startsWith '## Lines of code' + throw Error.new 'RESULTS.md has no lines section to regenerate' if at < 0 + parts[at] = "## Lines of code\n\n#{LINES}\n\n#{counted}\n\n" + writeFileSync path, parts.join '' + console.log "\n#{counted}\n" + note 'the lines section of bench/RESULTS.md regenerated' + return began = load() date = Date.new().toISOString().slice 0, 10 { text, refused } = scenarios! only, runs @@ -206,7 +220,7 @@ def main() cold = startup! sections.push "## Cold start\n\n#{cold}" counted = printed! 'lines.rip', [], true - sections.push "## Lines of code\n\n`bun run lines` (lines.rip): non-blank, non-comment lines by test/lines.rip's rule; Ink's `src/` and Yoga's `yoga/algorithm/` against the files this package ships.\n\n#{counted}" + sections.push "## Lines of code\n\n#{LINES}\n\n#{counted}" framed = printed! 'frame.rip' sections.push "## One frame, whole and damaged\n\n`bun run frame` (frame.rip): the frame alone — paint, diff, write; the state change left out, no layout owed — owing every cell, then owing its damage; median microseconds, the bytes of one, and the cells the damaged frame painted and compared.\n\n#{framed}" keyed = printed! 'keys.rip' diff --git a/packages/tui/bench/harness.rip b/packages/tui/bench/harness.rip index 68b568db..c95b968f 100644 --- a/packages/tui/bench/harness.rip +++ b/packages/tui/bench/harness.rip @@ -141,11 +141,13 @@ graphemes =! Intl.Segmenter.new undefined, granularity: 'grapheme' # EL — a blank left by an erase carries the background in force, as a # terminal with background color erase leaves it), SGR through # cells.rip's reader, a line feed that returns the carriage as a raw -# terminal with ONLCR does and scrolls at the last row, DEC private -# modes set and reset (the cursor, synchronized output, the alternate -# screen, paste, focus and mouse reports change no cell), the cursor -# saved and restored, the queries both sides make, the kitty stack, and -# OSC strings. A sequence outside that list is refused, never skipped. +# terminal with ONLCR does and scrolls at the last row, the DEC private +# modes both sides set (the cursor, synchronized output, paste, focus +# and mouse reports, which change no cell, and the alternate screen), +# the cursor saved and restored, the queries both sides make, the kitty +# stack, and OSC strings. A sequence outside that list — another +# private mode, another escape, a control character — is refused, +# never skipped; `pinned` holds it to that. # Glyphs are as wide as `Bun.stringWidth` draws each grapheme; a mark of # no width joins the cell before it; a half of a wide glyph overwritten # takes the other half out; a glyph past the right edge wraps. @@ -183,7 +185,7 @@ export class Terminal @x = 0 at += 1 else if code < 0x20 or code is 0x7f - at += 1 + throw Error.new "the reducer was sent a control character it does not model: #{JSON.stringify text[at]}" else stop = at + 1 stop += 1 while stop < text.length and text.charCodeAt(stop) >= 0x20 and text.charCodeAt(stop) isnt 0x7f @@ -250,7 +252,11 @@ export class Terminal else throw Error.new "the reducer was sent CSI #{params}#{verb}, which it does not know" return + # The private modes both sides set: those that change no cell, and the + # alternate screen. Any other — autowrap, origin mode, a scroll region + # — would change what a terminal draws, and is refused. mode: (mode, set) -> + throw Error.new "the reducer was sent DEC private mode #{mode}, which it does not model" unless mode in MODES return unless mode is 1049 if set and not @alt @alt = { lines: @lines, notes: @notes, base: @base, x: @x, y: @y } @@ -367,6 +373,33 @@ export class Terminal digest: -> String Bun.hash @screen().join('\n') BLANK =! { ...PLAIN, ch: ' ' } +MODES =! [25, 1000, 1002, 1003, 1004, 1006, 1049, 2004, 2026] + +# The refusals, pinned: bench.rip runs this before a measurement, so a +# reducer that had started to skip what it does not model could not +# publish a ✓. +export def pinned() + refused = (bytes) -> + try + Terminal.new(10, 4).feed bytes + catch error + return if error.message.includes 'the reducer was sent' + throw error + throw Error.new "the reducer took #{JSON.stringify bytes} without refusing it" + refused '\x1b[?7l' # autowrap off + refused '\x1b[?6h' # origin mode + refused '\x1b[?1;25h' # a list with one it does not model + refused '\t' + refused '\b' + refused '\x07' + refused '\x1b[2;5r' # a scroll region + refused '\x1b[3@' # insert characters + refused '\x1bM' # reverse index + refused '\x1b]8;;x' # an unterminated OSC + term = Terminal.new 10, 4 + term.feed "\x1b[?25l\x1b[?2026h\x1b[?2004h\x1b[?1004h\x1b[?1000;1006h\x1b[?1049h\x1b[32mhi\x1b[0m\x1b[?1049l\x1b[?2026l" + throw Error.new 'the reducer lost the primary screen across the alternate one' unless term.screen().length is 0 + return # Replay what `out` kept through a reducer and read the screen after # every update: a digest each, or with `at`, the cells after update diff --git a/packages/tui/bench/lines.rip b/packages/tui/bench/lines.rip index ff77a2cf..526a617a 100644 --- a/packages/tui/bench/lines.rip +++ b/packages/tui/bench/lines.rip @@ -10,8 +10,9 @@ # .tsx) and Yoga its `yoga/algorithm/` (.cpp, .h) — the algorithm # alone, without the bindings, the C API or the node — read from # checkouts named by INK_SRC and YOGA_SRC, or from misc/ink and -# misc/yoga at this checkout's root; a checkout that is not Ink 7.1.1 -# is refused. Rip TUI is the .rip files its package.json ships, and its +# misc/yoga at this checkout's root; a checkout that is not Ink 7.1.1, +# or not the Yoga version its yoga-layout installs here, is refused. +# Rip TUI is the .rip files its package.json ships, and its # runtime is what a compiled component imports: src/runtime/reactive.js # and components.js. # ============================================================================== @@ -51,6 +52,11 @@ ink = checkout 'INK_SRC', 'ink', 'src of Ink 7.1.1' inkVersion = JSON.parse(readFileSync join(ink, 'package.json'), 'utf8').version throw Error.new "the Ink checkout at #{ink} is #{inkVersion}, not 7.1.1" unless inkVersion is '7.1.1' yoga = checkout 'YOGA_SRC', 'yoga', 'yoga/algorithm of Yoga' +# Yoga's `javascript/` is the yoga-layout package, so its version names +# the checkout; it must be the one Ink 7.1.1 installs here. +yogaVersion = JSON.parse(readFileSync join(yoga, 'javascript/package.json'), 'utf8').version +shipped = JSON.parse(readFileSync join(HERE, 'node_modules/yoga-layout/package.json'), 'utf8').version +throw Error.new "the Yoga checkout at #{yoga} is #{yogaVersion}, and Ink 7.1.1 ships yoga-layout #{shipped} — check out v#{shipped} there, or name it in YOGA_SRC" unless yogaVersion is shipped inkLines = total files(join(ink, 'src'), ['.ts', '.tsx']) yogaLines = total files(join(yoga, 'yoga/algorithm'), ['.cpp', '.h']) @@ -78,7 +84,7 @@ withLayout = (inkLines + yogaLines) / oursLines p "| | Ink + Yoga | Rip TUI |" p "|---|--:|--:|" p "| Framework only | Ink `src/` #{n inkLines} | #{n oursLines} |" -p "| Framework + layout algorithm | + Yoga `yoga/algorithm/` #{n yogaLines} = #{n inkLines + yogaLines} | #{n oursLines} (layout.rip is #{n ours['layout.rip']} of it) |" +p "| Framework + layout algorithm | + Yoga #{yogaVersion} `yoga/algorithm/` #{n yogaLines} = #{n inkLines + yogaLines} | #{n oursLines} (layout.rip is #{n ours['layout.rip']} of it) |" p "| Full runtime closure | + React, react-reconciler, scheduler and #{n deps - 3} more packages (#{n deps} in all) | + Rip runtime #{n runtimeLines} (#{runtimeFiles.join ', '}) = #{n oursLines + runtimeLines} |" p "" -p "Framework against framework, Ink is #{frameworks.toFixed 1}× the lines of Rip TUI; with the layout algorithm on both sides, Ink + Yoga is #{withLayout.toFixed 1}×. Rip TUI's files: #{("#{name} #{n lines}" for name, lines of ours).join ', '}." +p "Framework against framework, Ink is #{frameworks.toFixed 1}× the lines of Rip TUI; with the layout algorithm on both sides, Ink + Yoga #{yogaVersion} (the version Ink 7.1.1 ships) is #{withLayout.toFixed 1}×. Rip TUI's files: #{("#{name} #{n lines}" for name, lines of ours).join ', '}."