From 52e6c11731b690fd3da0e02d0d750ea3fded8357 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 22:28:51 -0700 Subject: [PATCH 1/2] tui: port Ink's counter, borders, use-focus and static examples, add a text field, and run every example headless --- packages/tui/NOTICE | 5 +- packages/tui/examples/ink/borders.rip | 29 ++++ packages/tui/examples/ink/borders.tsx | 42 ++++++ packages/tui/examples/ink/counter.rip | 14 ++ packages/tui/examples/ink/counter.tsx | 20 +++ packages/tui/examples/ink/static.rip | 25 ++++ packages/tui/examples/ink/static.tsx | 54 +++++++ packages/tui/examples/ink/use-focus.rip | 32 ++++ packages/tui/examples/ink/use-focus.tsx | 29 ++++ packages/tui/examples/input.rip | 63 ++++++++ packages/tui/package.json | 2 +- packages/tui/test/examples.rip | 190 ++++++++++++++++++++++++ packages/tui/test/lines.rip | 39 +++++ 13 files changed, 541 insertions(+), 3 deletions(-) create mode 100644 packages/tui/examples/ink/borders.rip create mode 100644 packages/tui/examples/ink/borders.tsx create mode 100644 packages/tui/examples/ink/counter.rip create mode 100644 packages/tui/examples/ink/counter.tsx create mode 100644 packages/tui/examples/ink/static.rip create mode 100644 packages/tui/examples/ink/static.tsx create mode 100644 packages/tui/examples/ink/use-focus.rip create mode 100644 packages/tui/examples/ink/use-focus.tsx create mode 100644 packages/tui/examples/input.rip create mode 100644 packages/tui/test/examples.rip create mode 100644 packages/tui/test/lines.rip diff --git a/packages/tui/NOTICE b/packages/tui/NOTICE index db741f9a..5bc3982a 100644 --- a/packages/tui/NOTICE +++ b/packages/tui/NOTICE @@ -29,8 +29,9 @@ SOFTWARE. The text wrapping and truncation rules, the border and background painting, and the tests under test/ink/ follow Ink -(https://github.com/vadimdemedes/ink). Ink is distributed under this -license: +(https://github.com/vadimdemedes/ink), and the four .tsx files under +examples/ink/ are Ink's own examples, copied unmodified beside their +ports. Ink is distributed under this license: MIT License diff --git a/packages/tui/examples/ink/borders.rip b/packages/tui/examples/ink/borders.rip new file mode 100644 index 00000000..3b779d6c --- /dev/null +++ b/packages/tui/examples/ink/borders.rip @@ -0,0 +1,29 @@ +# Ink's borders example: the seven named border styles, in two rows. +# Ink's borders.tsx is beside this file; its Box is a row where this +# package's is a column, so the two rows say so. +# +# rip examples/ink/borders.rip + +import { run, Box, Text } from 'rip/tui' + +export Borders = component + render + Box flexDirection: 'column', padding: 2 + Box flexDirection: 'row' + Box borderStyle: 'single', marginRight: 2 + Text "single" + Box borderStyle: 'double', marginRight: 2 + Text "double" + Box borderStyle: 'round', marginRight: 2 + Text "round" + Box borderStyle: 'bold' + Text "bold" + Box flexDirection: 'row', marginTop: 1 + Box borderStyle: 'singleDouble', marginRight: 2 + Text "singleDouble" + Box borderStyle: 'doubleSingle', marginRight: 2 + Text "doubleSingle" + Box borderStyle: 'classic' + Text "classic" + +run Borders if import.meta.main diff --git a/packages/tui/examples/ink/borders.tsx b/packages/tui/examples/ink/borders.tsx new file mode 100644 index 00000000..2117dc6c --- /dev/null +++ b/packages/tui/examples/ink/borders.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import {render, Box, Text} from '../../src/index.js'; + +function Borders() { + return ( + + + + single + + + + double + + + + round + + + + bold + + + + + + singleDouble + + + + doubleSingle + + + + classic + + + + ); +} + +render(); diff --git a/packages/tui/examples/ink/counter.rip b/packages/tui/examples/ink/counter.rip new file mode 100644 index 00000000..ae5d0637 --- /dev/null +++ b/packages/tui/examples/ink/counter.rip @@ -0,0 +1,14 @@ +# Ink's counter example: a number that climbs every 100 ms. Ctrl-C +# quits. Ink's counter.tsx is beside this file. +# +# rip examples/ink/counter.rip + +import { run, clock, Text } from 'rip/tui' + +export Counter = component + tick = clock 100 + + render + Text color: 'green', "#{tick.frame} tests passed" + +run Counter if import.meta.main diff --git a/packages/tui/examples/ink/counter.tsx b/packages/tui/examples/ink/counter.tsx new file mode 100644 index 00000000..6cd5e16b --- /dev/null +++ b/packages/tui/examples/ink/counter.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import {render, Text} from '../../src/index.js'; + +function Counter() { + const [counter, setCounter] = React.useState(0); + + React.useEffect(() => { + const timer = setInterval(() => { + setCounter(prevCounter => prevCounter + 1); // eslint-disable-line unicorn/prevent-abbreviations + }, 100); + + return () => { + clearInterval(timer); + }; + }, []); + + return {counter} tests passed; +} + +render(); diff --git a/packages/tui/examples/ink/static.rip b/packages/tui/examples/ink/static.rip new file mode 100644 index 00000000..1e963b69 --- /dev/null +++ b/packages/tui/examples/ink/static.rip @@ -0,0 +1,25 @@ +# Ink's static example: a test finishes every 100 ms, ten in all, and +# each scrolls into the scrollback above a live count. Ink's static.tsx +# is beside this file; its process ends when its last timer has run, +# and this one quits then, since `run` holds the terminal until it does. +# +# rip examples/ink/static.rip + +import { run, quit, clock, Box, Text, Static } from 'rip/tui' + +export Example = component + tick = clock 100 + tests ~= ({ id: n, title: "Test \##{n + 1}" } for n in [0...Math.min(tick.frame, 10)]) + + ~> quit() if tests.length is 10 + + render + Box flexDirection: 'column' + Static + for test in tests + Box key: test.id + Text color: 'green', "✔ #{test.title}" + Box marginTop: 1 + Text dimColor: true, "Completed tests: #{tests.length}" + +run Example if import.meta.main diff --git a/packages/tui/examples/ink/static.tsx b/packages/tui/examples/ink/static.tsx new file mode 100644 index 00000000..2ea60551 --- /dev/null +++ b/packages/tui/examples/ink/static.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import {Box, Text, render, Static} from '../../src/index.js'; + +function Example() { + const [tests, setTests] = React.useState< + Array<{ + id: number; + title: string; + }> + >([]); + + React.useEffect(() => { + let completedTests = 0; + let timer: NodeJS.Timeout | undefined; + + const run = () => { + if (completedTests++ < 10) { + setTests(previousTests => [ + ...previousTests, + { + id: previousTests.length, + title: `Test #${previousTests.length + 1}`, + }, + ]); + + timer = setTimeout(run, 100); + } + }; + + run(); + + return () => { + clearTimeout(timer); + }; + }, []); + + return ( + <> + + {test => ( + + ✔ {test.title} + + )} + + + + Completed tests: {tests.length} + + + ); +} + +render(); diff --git a/packages/tui/examples/ink/use-focus.rip b/packages/tui/examples/ink/use-focus.rip new file mode 100644 index 00000000..efdcd5be --- /dev/null +++ b/packages/tui/examples/ink/use-focus.rip @@ -0,0 +1,32 @@ +# Ink's use-focus example: three items, Tab and Shift-Tab move focus +# between them, Escape lets it go. Ink's use-focus.tsx is beside this +# file; its `useFocus` hook is a focusable node here, styled by its +# own `focused`, and Ink's Escape is a listener on the root box, which +# hears every key a focused item is sent. +# +# rip examples/ink/use-focus.rip + +import { run, focus, Box, Text } from 'rip/tui' + +Item = component + @label := '' + el := null + + render + span ref: el, focusable: true + "#{@label} " + if el?.focused + Text color: 'green', "(focused)" + +export Focus = component + reset: (event) -> focus.to null if event.key is 'Escape' + + render + Box flexDirection: 'column', padding: 1, @keydown: @reset + Box marginBottom: 1 + Text "Press Tab to focus next element, Shift+Tab to focus previous element, Esc to reset focus." + Item label: 'First' + Item label: 'Second' + Item label: 'Third' + +run Focus if import.meta.main diff --git a/packages/tui/examples/ink/use-focus.tsx b/packages/tui/examples/ink/use-focus.tsx new file mode 100644 index 00000000..26a0c463 --- /dev/null +++ b/packages/tui/examples/ink/use-focus.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import {Box, Text, render, useFocus} from '../../src/index.js'; + +function Focus() { + return ( + + + + Press Tab to focus next element, Shift+Tab to focus previous element, + Esc to reset focus. + + + + + + + ); +} + +function Item({label}: {readonly label: string}) { + const {isFocused} = useFocus(); + return ( + + {label} {isFocused ? (focused) : null} + + ); +} + +render(); diff --git a/packages/tui/examples/input.rip b/packages/tui/examples/input.rip new file mode 100644 index 00000000..f015352a --- /dev/null +++ b/packages/tui/examples/input.rip @@ -0,0 +1,63 @@ +# A single-line text field. Typing inserts at the cursor, the arrows +# move it, Home and End jump, Backspace and Delete take out the cluster +# before it and under it, a paste goes in whole, Enter prints the value +# above the field and clears it, Escape clears it, Ctrl-C quits. The +# cursor is the terminal's own, placed by measured cells, so a wide +# glyph moves it two columns and a letter with its marks one; a value +# wider than the field is cut at its right edge. +# +# rip examples/input.rip + +import { run, print, Box, Text } from 'rip/tui' + +clusters =! Intl.Segmenter.new undefined, { granularity: 'grapheme' } + +# The text as the clusters a terminal draws: what the cursor steps over +# and what one Backspace takes out. +split =! (text) -> Array.from clusters.segment(text), (part) -> part.segment + +export Input = component + @value := '' + at := 0 # the cursor, in clusters from the start + parts ~= split @value + column ~= Bun.stringWidth parts.slice(0, at).join '' + + # Put `text` in at the cursor and leave the cursor after it; a mark + # that joins the cluster before it leaves the cursor on that cluster. + insert: (text) -> + before = parts.slice(0, at).join('') + text + @value = before + parts.slice(at).join '' + at = split(before).length + + clear: -> + @value = '' + at = 0 + + pressed: (event) -> + switch event.key + when 'ArrowLeft' then at = Math.max 0, at - 1 + when 'ArrowRight' then at = Math.min parts.length, at + 1 + when 'Home' then at = 0 + when 'End' then at = parts.length + when 'Delete' then @value = parts.toSpliced(at, 1).join '' + when 'Escape' then @clear() + when 'Backspace' + if at > 0 + at -= 1 + @value = parts.toSpliced(at, 1).join '' + when 'Enter' + print @value if @value + @clear() + else + @insert event.key if Array.from(event.key).length is 1 and not event.ctrlKey and not event.altKey and not event.metaKey + + pasted: (event) -> @insert event.text + + render + Box focusable: true, autofocus: true, borderStyle: 'round', paddingX: 1, cursor: { x: 2 + column, y: 1 }, @keydown: @pressed, @paste: @pasted + if @value + Text wrap: 'truncate', "#{@value}" + else + Text dimColor: true, "type, then Enter" + +run Input if import.meta.main diff --git a/packages/tui/package.json b/packages/tui/package.json index 4ddc0dca..0116573e 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -8,7 +8,7 @@ ".": "./tui.rip" }, "scripts": { - "test": "rip test.rip && rip test/text.rip && rip test/layout.rip && rip test/input.rip && rip test/events.rip && rip test/mouse.rip && rip test/ink.rip && rip test/yoga.rip && rip test/yoga-aspect.rip && rip test/yoga-hand.rip && rip test/fuzz.rip && rip test/damage.rip && rip test/terminal.rip", + "test": "rip test.rip && rip test/text.rip && rip test/layout.rip && rip test/input.rip && rip test/events.rip && rip test/mouse.rip && rip test/ink.rip && rip test/yoga.rip && rip test/yoga-aspect.rip && rip test/yoga-hand.rip && rip test/fuzz.rip && rip test/damage.rip && rip test/terminal.rip && rip test/examples.rip", "demo": "rip demo.rip" }, "files": [ diff --git a/packages/tui/test/examples.rip b/packages/tui/test/examples.rip new file mode 100644 index 00000000..34ed8e75 --- /dev/null +++ b/packages/tui/test/examples.rip @@ -0,0 +1,190 @@ +# The examples, run headless through `mount`: the four ported from Ink +# under examples/ink/, each held to the frames Ink 7.1.1 draws for its +# own example (bench/ holds that Ink; the frames here were read from +# it through a stdout of the test's own, at 100 columns, as Ink's test +# helper draws — every frame is Ink's, none differs), and the text +# field of examples/input.rip, its frame and its cursor after every +# key. +# +# rip test/examples.rip + +import { test, eq, ok } from 'rip/testing' +import { mount } from 'rip/tui' +import * as cells from './ink/cells.rip' +import { table } from './lines.rip' +import { Counter } from '../examples/ink/counter.rip' +import { Borders } from '../examples/ink/borders.rip' +import { Focus } from '../examples/ink/use-focus.rip' +import { Example as Tests } from '../examples/ink/static.rip' +import { Input } from '../examples/input.rip' + +mounted =! (App, options, body) -> + view = mount App, { cols: 100, ...options } + try + body view + finally + view.close() + +# Text with its escape sequences taken out. +bare =! (text) -> text.replace /\x1b\[[0-9;]*m/g, '' + +console.log "\nInk's examples" + +test "counter: the count climbs every 100 ms, as Ink's does", -> + mounted Counter, {}, (view) -> + eq view.frame(), '0 tests passed' + eq cells.styled(view.ansi), ['«green»0«» «green»tests«» «green»passed'] + view.tick 100 + eq view.frame(), '1 tests passed' + view.tick 250 + eq view.frame(), '3 tests passed' + eq view.damage, 14, 'a text of unchanged size owes its own words and no more' + +test "borders: the seven named styles in two rows, as Ink draws them", -> + mounted Borders, {}, (view) -> + eq view.frame(), [ + '' + '' + ' ┌──────┐ ╔══════╗ ╭─────╮ ┏━━━━┓' + ' │single│ ║double║ │round│ ┃bold┃' + ' └──────┘ ╚══════╝ ╰─────╯ ┗━━━━┛' + '' + ' ╓────────────╖ ╒════════════╕ +-------+' + ' ║singleDouble║ │doubleSingle│ |classic|' + ' ╙────────────╜ ╘════════════╛ +-------+' + '' + '' + ].join '\n' + +test "use-focus: Tab and Shift-Tab move focus between the items, Escape lets it go, and the focused one says so in green", -> + mounted Focus, {}, (view) -> + lead = "\n Press Tab to focus next element, Shift+Tab to focus previous element, Esc to reset focus.\n\n" + rows = (first, second, third) -> lead + " First#{first}\n Second#{second}\n Third#{third}\n" + eq view.frame(), rows('', '', '') + eq view.focused, null + view.press 'Tab' + eq view.frame(), rows(' (focused)', '', '') + eq cells.styled(view.ansi)[3], ' First «green»(focused)' + ok view.focused, 'an item holds focus' + view.press 'Tab' + eq view.frame(), rows('', ' (focused)', '') + view.press 'Tab', shift: true + eq view.frame(), rows(' (focused)', '', '') + view.press 'Escape' + eq view.frame(), rows('', '', '') + eq view.focused, null + view.press 'Tab' + eq view.frame(), rows(' (focused)', '', ''), 'after Escape, Tab starts from the top' + view.press 'Tab' + view.press 'Tab' + view.press 'Tab' + eq view.frame(), rows(' (focused)', '', ''), 'Tab past the last item wraps to the first' + +test! "static: a test finishes every 100 ms into the scrollback above the live count, and the app ends with the tenth", -> + view = mount Tests, cols: 100 + try + eq view.frame(), "\nCompleted tests: 0" + eq view.scrollback, '' + view.tick 100 + eq view.frame(), "\nCompleted tests: 1" + eq bare(view.scrollback), "✔ Test #1\n" + eq cells.styled(view.scrollback), ['«green»✔«» «green»Test«» «green»#1', ''] + view.tick 800 + eq view.frame(), "\nCompleted tests: 9" + eq cells.styled(view.ansi), ['', '«dim»Completed«» «dim»tests:«» «dim»9'] + eq bare(view.scrollback), ("✔ Test ##{n}\n" for n in [1..9]).join '' + view.tick 100 + eq view.frame(), "\nCompleted tests: 10" + eq bare(view.scrollback), ("✔ Test ##{n}\n" for n in [1..10]).join '' + eq await view.done, undefined + eq typeof document, 'undefined', 'the app quit with the tenth test' + finally + view.close() + +test "the README's line table is what test/lines.rip counts", -> + eq table(), [ + { name: 'counter', ink: 15, rip: 6 } + { name: 'borders', ink: 34, rip: 21 } + { name: 'use-focus', ink: 26, rip: 19 } + { name: 'static', ink: 45, rip: 14 } + ] + +console.log "\nThe text field" + +test "input: typing inserts at the cursor, the arrows, Home and End move it, Backspace and Delete take a cluster, a paste goes in whole, Enter prints and clears, Escape clears", -> + mounted Input, { cols: 24 }, (view) -> + field = (text) -> "╭──────────────────────╮\n│ #{text}#{' '.repeat 20 - Bun.stringWidth text} │\n╰──────────────────────╯" + eq view.frame(), field('type, then Enter') + eq cells.styled(view.ansi)[1], '│ «dim»type,«» «dim»then«» «dim»Enter«» │' + eq view.cursor, { x: 2, y: 1 } + view.type 'ab' + eq view.frame(), field('ab') + eq view.cursor, { x: 4, y: 1 } + view.press 'ArrowLeft' + view.type 'c' + eq view.frame(), field('acb') + eq view.cursor, { x: 4, y: 1 } + view.press 'Home' + view.frame() + eq view.cursor, { x: 2, y: 1 } + view.press 'ArrowLeft' + view.frame() + eq view.cursor, { x: 2, y: 1 }, 'the cursor stops at the start' + view.press 'End' + view.frame() + eq view.cursor, { x: 5, y: 1 } + view.press 'ArrowRight' + view.frame() + eq view.cursor, { x: 5, y: 1 }, 'and at the end' + view.press 'Backspace' + eq view.frame(), field('ac') + eq view.cursor, { x: 4, y: 1 } + view.press 'ArrowLeft' + view.press 'Delete' + eq view.frame(), field('a') + eq view.cursor, { x: 3, y: 1 } + view.press 'Delete' + eq view.frame(), field('a'), 'Delete at the end takes nothing' + view.paste '日本' + eq view.frame(), field('a日本') + eq view.cursor, { x: 7, y: 1 }, 'a wide glyph is two cells' + view.press 'ArrowLeft' + view.frame() + eq view.cursor, { x: 5, y: 1 } + view.press 'Enter' + eq view.frame(), field('type, then Enter') + eq view.scrollback, 'a日本\n' + eq view.cursor, { x: 2, y: 1 } + view.press 'Enter' + eq view.scrollback, 'a日本\n', 'an empty field prints nothing' + view.type 'xyz' + view.press 'Escape' + eq view.frame(), field('type, then Enter') + eq view.cursor, { x: 2, y: 1 } + view.press 'a', ctrl: true + view.press 'b', alt: true + view.press 'F5' + eq view.frame(), field('type, then Enter'), 'a chord and a named key type nothing' + +test "input: a cluster of several code points is one cursor step and one Backspace", -> + mounted Input, { cols: 24 }, (view) -> + view.type 'e\u0301x' + eq view.frame(), "╭──────────────────────╮\n│ e\u0301x │\n╰──────────────────────╯" + eq view.cursor, { x: 4, y: 1 }, 'a letter with its mark is one cell' + view.press 'ArrowLeft' + view.press 'ArrowLeft' + view.frame() + eq view.cursor, { x: 2, y: 1 } + view.press 'ArrowRight' + view.type '\u0308' + view.frame() + eq view.app.value.value, 'e\u0301\u0308x' + eq view.cursor, { x: 3, y: 1 }, 'a mark joins the cluster before the cursor and the cursor stays on it' + view.press 'End' + view.type '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}' + view.frame() + eq view.cursor, { x: 6, y: 1 }, 'a family emoji, five code points, is two cells' + view.press 'Backspace' + view.frame() + eq view.app.value.value, 'e\u0301\u0308x' + eq view.cursor, { x: 4, y: 1 }, 'and one Backspace' diff --git a/packages/tui/test/lines.rip b/packages/tui/test/lines.rip new file mode 100644 index 00000000..0d13fb6c --- /dev/null +++ b/packages/tui/test/lines.rip @@ -0,0 +1,39 @@ +# The line counts of the examples ported from Ink (examples/ink/), by +# the rule PLAN §2 counts by: non-blank, non-comment lines. A line is +# a comment when it is nothing else — `//` or a `/* … */` block in +# TypeScript, `#` in Rip — and a line of code that ends in a comment +# counts. The README carries the table this prints. +# +# rip test/lines.rip + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +export EXAMPLES =! ['counter', 'borders', 'use-focus', 'static'] + +# The code lines of the source at `path`. +export count =! (path) -> + inside = false # of a block comment + lines = 0 + for line in readFileSync(path, 'utf8').split '\n' + text = line.trim() + if inside + inside = not text.includes '*/' + continue + continue if text is '' or text.startsWith('#') or text.startsWith('//') + if text.startsWith '/*' + inside = not text.includes '*/' + continue + lines += 1 + lines + +# Each example's counts, Ink's tsx against the rip. +export table =! -> + dir = join(import.meta.dir, '..', 'examples', 'ink') + for name in EXAMPLES + { name, ink: count(join dir, "#{name}.tsx"), rip: count(join dir, "#{name}.rip") } + +if import.meta.main + console.log "| Example | Ink | Rip TUI |\n|---|---|---|" + for row in table() + console.log "| `#{row.name}` | #{row.ink} | #{row.rip} |" From ba834b1b3fec6ee58d7f5ecaaf31368851818b64 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 22:32:14 -0700 Subject: [PATCH 2/2] tui: rewrite the README's top around the thesis, the feature matrix and the examples --- packages/tui/PLAN.md | 4 +- packages/tui/README.md | 132 ++++++++++++++++++++++++++++++++--------- packages/tui/TODO.md | 4 ++ 3 files changed, 111 insertions(+), 29 deletions(-) diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index c46aa7f4..26a43065 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -168,7 +168,9 @@ 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`), `README.md`. +`yoga-layout`), `examples/` (`counter`, `files`, `log`, `input`, and +under `examples/ink/` the four ports of §12 step 6 with Ink's source +beside each), `README.md`. ## 4. The host contract (`document.rip`) diff --git a/packages/tui/README.md b/packages/tui/README.md index a75f3335..778c46c6 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -4,49 +4,120 @@ > **Terminal user interfaces from Rip components — one reactive tree, a cell-rounded flexbox, and a diffed cell painter, zero dependencies.** -A compiled `render` block already calls `document.createElement` and -`insertBefore` directly and keeps one effect per dynamic binding, so a -terminal needs no reconciler: `run` installs a terminal `document`, -mounts the app onto it, and every later change arrives through a node -setter the package owns. A node is one object for the tree, the layout, -and the paint. Layout is flexbox in float math, rounded to cells once; -paint fills a grid of typed arrays, and only the cells a change may -have recolored; and each frame is sent as the difference from the grid -the terminal already shows, in one write. +Ink is React driving a fake DOM, Yoga laying it out through +WebAssembly, and a string painter writing the result. Rip needs none +of that between a component and the terminal. A compiled `render` +block already calls `document.createElement` and `insertBefore` +itself and keeps one effect per dynamic binding, so `run` installs a +terminal `document`, mounts the app onto it, and every later change +arrives through a setter the package owns, naming the node that +changed: no reconciler, no tree diff. One object is the tree node, +the layout node and the paint node. Layout is flexbox as Yoga lays it +out, in float math rounded to cells once; paint fills a grid of typed +arrays, and only the cells a change may have recolored; and each +frame is sent as the difference from the grid the terminal already +shows, in one write. The claim is three things: fewer lines than Ink +and Yoga together, less time per update on the same Bun, and clearer +programs. The four examples below show the third, and `bench/` is +where the first two are measured, against Ink on the same Bun +([PLAN.md](PLAN.md) §2 says what each means). **Runtime:** not browser-safe — it writes escape sequences to a terminal stream and measures text with `Bun.stringWidth`. Apps run through `rip app.rip`. -## Quick Start +## Quick start -```coffee -import { run, quit, screen, Box, Text, Spacer } from 'rip/tui' +The smallest app that takes a key, `examples/counter.rip`: -App = component - passed := 0 +```coffee +import { run, Box, Text } from 'rip/tui' - ~> - timer = setInterval (-> passed += 1), 100 - -> clearInterval timer +Counter = component + @count := 0 + pressed: (event) -> + @count += 1 if event.key is 'ArrowUp' + @count -= 1 if event.key is 'ArrowDown' + render + Box @keydown: @pressed, focusable: true, autofocus: true, borderStyle: 'round', paddingX: 1 + Text color: 'green', "count #{@count}" - ~> quit() if passed >= 50 +run Counter +``` - render - Box borderStyle: 'round', paddingX: 1, flexDirection: 'row', width: 40 - Text color: 'green', bold: true - "#{passed} passed" - Spacer - Text dimColor: true - "#{screen.cols}×#{screen.rows}" - -run App +```bash +bun install # once, at the repository root +rip examples/counter.rip # from packages/tui: ↑ and ↓ count, Ctrl-C quits ``` `run` returns `{ app, done, quit, flush }`; `done` resolves with the value given to `quit`. Ctrl-C quits. The last frame stays in the scrollback and the cursor lands on the line below it. +## What it does + +Every row of the first two columns is built and under test; +[TODO.md](TODO.md) lists the open work within them, most of it cost. + +| Matches Ink | Beyond Ink | Disclosed gaps | Never | +|---|---|---|---| +| Flexbox layout incl. baseline, static position, and aspect ratio; borders, backgrounds | Mouse, opt-in: click, wheel, hover, and a drag that selects text to the clipboard | Screen-reader output mode (`role` / `aria-*` are accepted and kept on the node) | React devtools | +| `overflow: hidden` clipping | Keys bubble from the focused node, with preventable default actions | Windows — unclaimed and untested, as for Rip itself | Concurrent rendering, Suspense | +| Scrolling by content offset (`contentOffsetX` / `contentOffsetY`) | Tree-order focus | List virtualization | | +| Wrap and truncate modes | Node-relative cursor placement | | | +| `Static` scrollback output | A text change of unchanged size runs no layout | | | +| Inline and alternate-screen rendering | Hyperlinks as a prop: `link` on text (OSC 8), refused unless the URL is printable ASCII, never left open across a cursor move | | | +| Synchronized, diffed, coalesced output | | | | +| Non-TTY / CI output, `NO_COLOR`, color depth | | | | +| Console capture while rendering | | | | +| Error display with terminal restore | | | | +| `renderToString` and a test driver | | | | +| Node metrics (`useBoxMetrics` equivalent) | | | | +| Key input, paste, focus, cursor placement | | | | +| Enhanced keyboard (kitty protocol), opt-in | | | | +| Animation clock (`useAnimation` equivalent) | | | | + +Ink's string `Transform` has no counterpart because it has no job +here: a text transform is an ordinary expression in the binding +(`"#{name.toUpperCase()}"`). + +## Side by side with Ink + +Four of Ink's own examples, ported program for program under +`examples/ink/`, with Ink's `.tsx` beside each `.rip`. Each port draws +the frames Ink 7.1.1 draws for the same example — `test/examples.rip` +holds every one of them, and none differs — and `rip test/lines.rip` +counts the lines: non-blank and non-comment, the rule +[PLAN.md](PLAN.md) §2 states. + +| Example | Ink | Rip TUI | | +|---|---|---|---| +| `counter` | 15 | 6 | A number that climbs every 100 ms: a `clock` read in the text, where Ink has a state, an effect and a timer to clear. | +| `borders` | 34 | 21 | The seven named border styles in two rows: Ink's tree, with the rows said, since `Box` is a column here as in Yoga and a row in Ink. | +| `use-focus` | 26 | 19 | Three items Tab moves between: a `focusable` node styled by its own `focused`, where Ink registers a hook; Ink's Escape is a listener on the root box. | +| `static` | 45 | 14 | Ten tests into the scrollback, one every 100 ms, above a live count: `Static` around a keyed `for`; the app quits with the tenth, where Ink's process ends when its last timer has run. | + +Run one with `rip examples/ink/counter.rip`. + + + +## Examples + +Each runs with `rip examples/.rip` from `packages/tui`. + +| Example | Shows | +|---|---| +| `counter.rip` | The quick start: a key changes state, the frame follows. | +| `files.rip` | A file browser: two panes as tall as the terminal, clipped and scrolled by content offset; Tab and a click move between them; the wheel scrolls the pane under it; the row under the pointer is underlined (`mouse: 'all'`); Enter opens a directory, Backspace goes up, q quits. | +| `log.rip` | A build log: finished steps into the scrollback through `Static`, a spinner and a bar on the `clock`, a warning above the frame through `print`, the terminal's own progress indicator, and a quit when the last step is done. | +| `input.rip` | A single-line text field in 40 lines of code: the cursor placed by measured cells, so a wide glyph is two columns and a letter with its marks one; typing inserts at the cursor, the arrows, Home and End move it, Backspace and Delete take a cluster, a paste goes in whole, Enter prints the value above the field and clears it, Escape clears it. | +| `ink/*.rip` | The four ports above, Ink's source beside each. | + +`log.rip`, `input.rip` and the ports export their component and run it +only as the entry (`run App if import.meta.main`), which is how +`test.rip` and `test/examples.rip` drive them headless through +`mount`. + ## Running `run App, options` takes the terminal for the app's life and gives it @@ -705,7 +776,12 @@ of a `Static` write and of `print`, the mouse after one, the clock's one timer and where it stops, and the progress sequence. `test/damage.rip` changes random trees a step at a time and holds every frame painted from its damage to the same tree painted whole, cell for -cell, and to the bytes sent, replayed. `test/text.rip` holds the +cell, and to the bytes sent, replayed. `test/examples.rip` drives the +four ports under `examples/ink/` through `mount` and holds each frame +to the one Ink draws for its example, holds the line table above to +what `test/lines.rip` counts, and types, moves, deletes and pastes +into `examples/input.rip`, holding the frame and the cursor after +every key. `test/text.rip` holds the text engine — sanitizing, cluster widths, every wrap and truncate mode — and `test/layout.rip` the layout engine's own pins. `test/input.rip` holds the terminal input parser: 244 of Ink's input cases as a table diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index 073cd887..fbea44b3 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -79,6 +79,10 @@ steps are in [PLAN.md](PLAN.md). caller's own: a test whose app is still live when it fails hands its own report to that stream. `test/terminal.rip` quits after every test for that; the other suites do not. +- [ ] A stdout whose `columns` is 0 — a pty whose size was never set, + as `script` makes with no terminal behind it — draws frames of no + cells, where an undefined `columns` is read as 80 (`screen.rip`) + and Ink reads 0 as 80 too. ## 8. Compiler-side, filed separately