From 0f01700fe0ab684f4599912620841666cd9a6bdc Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 20:16:55 -0700 Subject: [PATCH 01/18] =?UTF-8?q?tui:=20the=20terminal=20contract=20?= =?UTF-8?q?=E2=80=94=20every=20way=20out,=20signals,=20suspend,=20the=20al?= =?UTF-8?q?ternate=20screen,=20non-TTY=20output,=20colors,=20the=20console?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tui/package.json | 3 +- packages/tui/terminal.rip | 13 + packages/tui/test/events/harness.rip | 15 +- packages/tui/test/terminal.rip | 1095 ++++++++++++++++++++++++++ packages/tui/test/terminal/SOURCE.md | 160 ++++ packages/tui/test/terminal/child.rip | 41 + packages/tui/tui.rip | 5 + 7 files changed, 1330 insertions(+), 2 deletions(-) create mode 100644 packages/tui/terminal.rip create mode 100644 packages/tui/test/terminal.rip create mode 100644 packages/tui/test/terminal/SOURCE.md create mode 100644 packages/tui/test/terminal/child.rip diff --git a/packages/tui/package.json b/packages/tui/package.json index 41fa56e5..4ddc0dca 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", + "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", "demo": "rip demo.rip" }, "files": [ @@ -19,6 +19,7 @@ "text.rip", "paint.rip", "screen.rip", + "terminal.rip", "input.rip", "README.md", "NOTICE" diff --git a/packages/tui/terminal.rip b/packages/tui/terminal.rip new file mode 100644 index 00000000..88cc784e --- /dev/null +++ b/packages/tui/terminal.rip @@ -0,0 +1,13 @@ +# The terminal, for the app's life: what `run` asks of it and gives +# back — raw mode, the modes, the probes, the cursor, the alternate +# screen, the signals, the console — as one `setup` / `teardown` pair +# that every way out shares. + +export setup! =! (held) -> throw Error.new 'rip/tui: the terminal lifecycle is not built' +export probe! =! (held) -> throw Error.new 'rip/tui: the terminal lifecycle is not built' +export answer =! (held, event) -> throw Error.new 'rip/tui: the terminal lifecycle is not built' +export teardown =! (held, drawn) -> throw Error.new 'rip/tui: the terminal lifecycle is not built' +export handover =! (held, fn) -> throw Error.new 'rip/tui: the terminal lifecycle is not built' +export stop! =! (held) -> throw Error.new 'rip/tui: the terminal lifecycle is not built' +export interactive =! (out) -> true +export colors =! (out) -> 3 diff --git a/packages/tui/test/events/harness.rip b/packages/tui/test/events/harness.rip index 2d56497c..a7a063ca 100644 --- a/packages/tui/test/events/harness.rip +++ b/packages/tui/test/events/harness.rip @@ -22,7 +22,9 @@ ASKS =! { '?u': 'keyboard', 'c': 'attributes', '?6n': 'cursor' } # terminal shows until it is told otherwise); `queries` is what it was # asked, in order; `kitty` is how deep its keyboard stack is pushed; # `clipboard` is what OSC 52 last put there; `scrolled` counts the rows -# pushed into the scrollback; `sent` is every write. +# pushed into the scrollback; `sent` is every write. The alternate screen +# (1049) is a second buffer: entering it saves the lines and the cursor, +# leaving it brings them back. export class Terminal extends EventEmitter constructor: (@columns = 40, @rows = 10) -> super() @@ -35,6 +37,7 @@ export class Terminal extends EventEmitter @kitty = 0 @clipboard = null @scrolled = 0 + @saved = null get shown: -> @modes.has 25 get cursor: -> if @shown then { x: @x, y: @y } else null @@ -90,12 +93,22 @@ export class Terminal extends EventEmitter if params[0] is '?' for mode in params.slice(1).split ';' if verb is 'h' then @modes.add +mode else @modes.delete +mode + if +mode is 1049 and verb is 'h' + @saved = { lines: @lines, x: @x, y: @y } + @lines = [] + else if +mode is 1049 and @saved + { lines: @lines, x: @x, y: @y } = @saved + @saved = null 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 'G' then @x = n - 1 + when 'H' + [row, col] = params.split ';' + @y = (parseInt(row) or 1) - 1 + @x = (parseInt(col) or 1) - 1 when 'J' @lines.length = @y + 1 @lines[@y]?.length = @x diff --git a/packages/tui/test/terminal.rip b/packages/tui/test/terminal.rip new file mode 100644 index 00000000..24d0121d --- /dev/null +++ b/packages/tui/test/terminal.rip @@ -0,0 +1,1095 @@ +# ============================================================================== +# tui terminal tests — every way out, the signals, suspend and resume, the +# alternate screen, output that is no terminal, colors, and the console +# +# rip test/terminal.rip +# +# Ink's suspend, exit, error, console and CI cases come first +# (test/terminal/SOURCE.md counts them). Then what this package pins for +# itself: one setup and one teardown shared by every way out, and what +# each leaves; the signals and the crash, taken for real in a spawned +# process; Ctrl-Z and `suspend`, the bytes and the raw-mode calls in +# order; the alternate screen; a stdout that is no terminal; the color +# depth and what a color becomes at each; the console while an app +# runs; and a fuzz that holds the terminal's modes to what the app +# believes after every step. +# ============================================================================== + +import { test, eq, ok, throws } from 'rip/testing' +import { run, quit, suspend, mount, renderToString, screen, Box, Text } from 'rip/tui' +import { Terminal, Stdin, count, differs, tally } from './events/harness.rip' +import { join } from 'path' + +CSI =! '\x1b[' +HIDE =! "#{CSI}?25l" +SHOW =! "#{CSI}?25h" +ASK =! "#{CSI}?2004h#{CSI}?1004h" # bracketed paste, focus reports (xterm ctlseqs) +ON =! HIDE + ASK # what `run` opens a terminal with +OFF =! "#{CSI}?1004l#{CSI}?2004l" # and withdraws, before the cursor is shown +MOUSE =! "#{CSI}?1002h#{CSI}?1006h" # button-event tracking, SGR reports +QUIET =! "#{CSI}?1006l#{CSI}?1002l" +PROBE =! "#{CSI}?6n" # DECXCPR: where is the cursor +QUERY =! "#{CSI}?u#{CSI}c" # kitty's "is the protocol here", then primary device attributes +PUSH =! "#{CSI}>1u" # kitty: push the disambiguation flag +POP =! "#{CSI} "#{CSI}<#{bits};#{x + 1};#{y + 1}#{final}" +down =! (x, y) -> report 0, x, y +up =! (x, y) -> report 0, x, y, 'm' + +# What is written apart from escape sequences. +plain =! (text) -> text.replace(/\x1b\[[?<>0-9;]*[A-Za-z]/g, '').replace /\x1b\][^\x1b]*\x1b\\/g, '' + +# A stream that is no terminal, and keeps what it is sent. +class Pipe + constructor: (@columns = 40, @rows = 10) -> + @sent = [] + write: (text) -> + @sent.push text + true + get text: -> @sent.join '' + +Echo =! component + @keys := [] + @label := 'x' + render + Box focusable: true, autofocus: true, @keydown: ((event) => @keys.push event.key) + Text "#{@label}" + +# Throws on X, so a listener that throws is one key away. +Ways =! component + @heard := [] + render + Box focusable: true, autofocus: true, @keydown: ((event) => if event.key is 'X' then raise 'a listener failed' else @heard.push event.key), @click: (=> @heard.push 'click') + Text "w" + +# The console, as the test sees it: each method replaced by a recorder +# for the test's turn, so what passes through the package's patch, and +# what reaches the console once the app is closed, are both read. +recorded =! (body) -> + held = {} + printed = [] + for name in ['log', 'info', 'debug', 'warn', 'error'] + held[name] = console[name] + console[name] = ((name) -> (...args) -> printed.push [name, args.join ' '])(name) + try + body! printed + finally + console[name] = fn for name, fn of held + +# ── Every way out ───────────────────────────────────────────────────────────── + +# The process's handlers the run may add, counted before and after. +HANDLED =! ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGCONT', 'uncaughtException', 'unhandledRejection', 'beforeExit', 'exit'] +listeners =! -> (process.listenerCount name for name in HANDLED) + +# What a way out must leave: the terminal's modes off, its keyboard +# stack popped, its cursor shown; stdin cooked, paused and let go; no +# handler of the run's on the process; the `document` slot free. +every =! (name, term, stdin, before) -> + ok not term.modes.has(mode), "#{name}: mode #{mode} was left on" for mode in [2004, 1004, 1002, 1003, 1006, 1049] + ok term.shown, "#{name}: the cursor was left hidden" + eq term.kitty, 0, "#{name}: the keyboard stack" + eq [stdin.raw, stdin.listenerCount('data'), typeof document], [false, 0, 'undefined'], name + eq stdin.calls.slice(-3), ['raw off', 'pause', 'unref'], name + eq listeners(), before, "#{name}: a handler was left on the process" + +# ── A process of its own ────────────────────────────────────────────────────── + +ROOT =! join(import.meta.dir, '../../..') +LIMIT =! 20000 # ms a child may take before it is killed + +# test/terminal/child.rip run through the checkout's own loader, its +# stdout read as it comes. A child that outlives the limit is continued +# (a stopped one takes nothing else) and killed, never left behind. +class Child + constructor: (way, env = {}) -> + @out = '' + @proc = Bun.spawn ['bun', "--preload=#{join ROOT, 'src/loader.js'}", join(ROOT, 'src/cli/run.js'), join(import.meta.dir, 'terminal/child.rip'), way], { cwd: import.meta.dir, env: { ...process.env, CI: 'false', RIP_STDLIB_ANCHOR: join(import.meta.dir, 'terminal'), ...env }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } + @timer = setTimeout (=> @kill()), LIMIT + @pumping = @pump() + + pump!: -> + reader = @proc.stdout.getReader() + decoder = TextDecoder.new() + loop + { done, value } = reader.read! + break if done + @out += decoder.decode value, { stream: true } + + kill!: -> + @proc.kill 'SIGCONT' + @proc.kill 'SIGKILL' + + # Wait for `text` to arrive on stdout. + seen: (text) -> + start = performance.now() + until @out.includes text + raise "the child never wrote #{JSON.stringify text}; it wrote #{JSON.stringify @out}" if performance.now() - start > LIMIT + sleep! 5 + return + + signal: (name) -> @proc.kill name + + # The exit code, the whole of stdout, and stderr. + finish: -> + code = await @proc.exited + clearTimeout @timer + err = Response.new(@proc.stderr).text! + await @pumping + { code, out: @out, err } + +# ==[ Ink's cases ]== + +console.log "\nInk: suspend-terminal" + +test! "suspendTerminal hands the terminal to the callback, then restores Ink", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term + raw = null + suspend! -> + raw = stdin.raw + ok term.since(0).includes(SHOW), 'the cursor is shown for the callback' + count 'suspend-terminal', 'suspendTerminal hands the terminal to the callback, then restores Ink', 'held' + eq [raw, stdin.raw], [false, true] + mark = term.sent.findIndex (write) -> write.includes SHOW + ok term.since(mark + 1).includes(HIDE), 'the cursor is hidden again after' + quit() + await running.done + +test! "suspendTerminal restores the terminal even if the callback throws", -> + stdin = Stdin.new() + running = run Echo, stdin: stdin, stdout: Terminal.new() + threw = false + try + suspend! -> raise 'the callback failed' + catch error + threw = error.message is 'the callback failed' + count 'suspend-terminal', 'suspendTerminal restores the terminal even if the callback throws', 'held' + eq [threw, stdin.raw], [true, true] + quit() + await running.done + +test! "suspendTerminal keeps Ink off the terminal while suspended", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term + writes = null + suspend! -> + before = term.sent.length + running.app.label.value = 'changed' + sleep! 20 + writes = term.sent.length - before + count 'suspend-terminal', 'suspendTerminal keeps Ink off the terminal while suspended', 'held' + eq writes, 0 + sleep! 20 + ok term.text.endsWith('changed'), "the change is drawn on resume: #{JSON.stringify term.text}" + quit() + await running.done + +test! "suspendTerminal runs the callback but skips the handoff when not interactive", -> + pipe = Pipe.new() + running = run Echo, stdin: Stdin.new(), stdout: pipe + ran = false + suspend! -> ran = true + count 'suspend-terminal', 'suspendTerminal runs the callback but skips the handoff when not interactive', 'held' + eq [ran, pipe.text.includes(SHOW)], [true, false] + quit() + await running.done + +test! "suspendTerminal rejects a nested suspend while already suspended", -> + running = run Echo, stdin: Stdin.new(), stdout: Terminal.new() + refused = null + suspend! -> + try + suspend! -> null + catch error + refused = error.message + count 'suspend-terminal', 'suspendTerminal rejects a nested suspend while already suspended', 'held' + ok /already suspended/.test(refused), refused + quit() + await running.done + +test! "suspendTerminal hands the terminal to a child process, then redraws (PTY)", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term + suspend! -> + term.write 'CHILD_OUTPUT\n' + sleep! 20 + count 'suspend-terminal', 'suspendTerminal hands the terminal to a child process, then redraws (PTY)', 'held' + output = term.since 0 + ok output.includes('CHILD_OUTPUT') and output.includes(SHOW) + after = output.slice output.lastIndexOf('CHILD_OUTPUT') + 'CHILD_OUTPUT'.length + ok plain(after).includes('x') and after.includes(HIDE), JSON.stringify after + eq term.text, "x\nCHILD_OUTPUT\nx", 'the frame stands again below what the child wrote' + quit() + await running.done + +test! "suspendTerminal exits and re-enters the alternate screen", -> + term = Terminal.new() + running = run Echo, stdin: Stdin.new(), stdout: term, altScreen: true + left = null + at = 0 + suspend! -> + left = term.since(0).includes LEAVE + at = term.sent.length + count 'suspend-terminal', 'suspendTerminal exits and re-enters the alternate screen', 'held' + eq [left, term.since(at).includes(ENTER)], [true, true] + quit() + await running.done + +test! "suspendTerminal rolls back so a later suspend works if handover throws", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term + once = true + cooked = stdin.setRawMode + stdin.setRawMode = (raw) -> + if once and not raw + once = false + raise 'EIO: setRawMode' + cooked.call stdin, raw + first = null + try + suspend! -> null + catch error + first = error.message + second = false + suspend! -> second = true + count 'suspend-terminal', 'suspendTerminal rolls back so a later suspend works if handover throws', 'held' + eq [first, second, stdin.raw], ['EIO: setRawMode', true, true] + quit() + await running.done + eq stdin.raw, false + +console.log "\nInk: suspension-exit" + +test! "resuming after unmount does not re-enable terminal input (callback: true)", -> + stdin = Stdin.new() + running = run Echo, stdin: stdin, stdout: Terminal.new() + suspend! -> + quit() + await running.done + count 'suspension-exit', 'resuming after unmount does not re-enable terminal input (callback: true)', 'held' + eq [stdin.raw, stdin.listenerCount('data'), stdin.calls.slice(-3)], [false, 0, ['raw off', 'pause', 'unref']] + +console.log "\nInk: suspension-output" + +test! "non-interactive suspension keeps the latest rendered frame", -> + pipe = Pipe.new() + running = run Echo, stdin: Stdin.new(), stdout: pipe + running.app.label.value = 'before' + suspend! -> + running.app.label.value = 'after' + sleep! 20 + quit() + await running.done + count 'suspension-output', 'non-interactive suspension keeps the latest rendered frame', 'held' + eq pipe.text, 'after\n' + +console.log "\nInk: suspension-resize" + +for columns in [80, 120] + test! "resize to #{columns} columns does not write while suspended", -> + term = Terminal.new 100, 10 + running = run Echo, stdin: Stdin.new(), stdout: term + running.app.label.value = 'hello' + sleep! 20 + before = 0 + suspend! -> + before = term.sent.length + term.columns = columns + term.emit 'resize' + eq term.sent.slice(before), [] + sleep! 20 + count 'suspension-resize', "resize to #{columns} columns does not write while suspended", 'held' + ok term.since(before).includes('hello'), JSON.stringify term.since before + eq screen.cols, columns + quit() + await running.done + +console.log "\nInk: kitty-negotiation" + +test! "suspension cancels pending keyboard negotiation", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term, keyboard: 'enhanced', altScreen: true + at = 0 + suspend! -> + before = term.sent.length + stdin.key KITTY + sleep! 30 + eq term.sent.slice(before), [], "an answer while suspended is nobody's" + at = term.sent.length + differs 'kitty-negotiation', 'suspension cancels pending keyboard negotiation', [term.since(at).includes(PUSH), term.since(at).includes(QUERY)], [false, true], [false, false], 'the question is asked again on resume, so a terminal that answers it then is pushed to; Ink cancels the negotiation for the rest of the run' + quit() + await running.done + +test! "unmount while suspended does not pop the primary keyboard stack", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term, keyboard: 'enhanced' + stdin.key KITTY + suspend! -> + quit() + await running.done + count 'kitty-negotiation', 'unmount while suspended does not pop the primary keyboard stack', 'held' + eq [term.since(0).split(PUSH).length - 1, term.since(0).split(POP).length - 1, term.kitty], [1, 1, 0] + +console.log "\nInk: exit" + +test! "exit normally without unmount() or exit(): the loop drains, and the app is closed", -> + child = Child.new 'drain' + { code, out } = child.finish! + count 'exit', 'exit normally without unmount() or exit()', 'held' + count 'exit', 'exit when app finishes execution', 'held' + eq code, 0 + ok out.endsWith("#{QUIET}#{OFF}\n#{SHOW}exited\n"), JSON.stringify out.slice -60 + +test! "exit on unmount(), on exit(), with raw mode: `quit` closes the app and resolves `done`", -> + stdin = Stdin.new() + running = run Echo, stdin: stdin, stdout: Terminal.new() + quit() + eq await running.done, undefined + count 'exit', 'exit on unmount()', 'held' + count 'exit', 'exit on exit()', 'held' + count 'exit', 'exit on exit() with raw mode', 'held' + count 'exit', 'exit on unmount() with raw mode', 'held' + eq [stdin.raw, typeof document], [false, 'undefined'] + +test! "exit on exit() with a result value, and with an object result", -> + running = run Echo, stdin: Stdin.new(), stdout: Terminal.new() + quit 'hello from ink' + eq await running.done, 'hello from ink' + count 'exit', 'exit on exit() with result value', 'held' + running = run Echo, stdin: Stdin.new(), stdout: Terminal.new() + quit { message: 'hello from ink object' } + eq await running.done, { message: 'hello from ink object' } + count 'exit', 'exit on exit() with object result', 'held' + +test "exit with thrown error", -> + Thrower = component + ~> raise 'errored' + render + Text "never" + stdin = Stdin.new() + throws (-> run Thrower, stdin: stdin, stdout: Terminal.new()), 'errored' + count 'exit', 'exit with thrown error', 'held' + eq [stdin.raw, typeof document], [false, 'undefined'] + +console.log "\nInk: errors" + +test! "clean up raw mode when error is thrown", -> + child = Child.new 'throw' + { code, out, err } = child.finish! + count 'errors', 'clean up raw mode when error is thrown', 'held' + eq code, 1 + ok err.includes('a timer failed'), err + ok out.endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -60 + +console.log "\nInk: render" + +for option in ['omitted', 'undefined'] + test! "intercept console methods with #{option} patchConsole option", -> + Hello = component + render + Text "Hello World" + recorded! (printed) -> + term = Terminal.new() + options = { stdin: Stdin.new(), stdout: term } + options.console = undefined if option is 'undefined' + running = run Hello, options + console.log 'First log' + eq term.text, "First log\nHello World" + quit() + await running.done + console.log 'Second log' + count 'render', "intercept console methods with #{option} patchConsole option", 'held' + eq [term.text, printed], ["First log\nHello World", [['log', 'Second log']]] + +console.log "\nInk: components" + +test! "render only last frame when run in CI", -> + child = Child.new 'ci', { CI: 'true' } + { code, out } = child.finish! + count 'components', 'render only last frame when run in CI', 'held' + eq code, 0 + ok not out.includes("count #{n}"), "count #{n} was written" for n in [0, 1, 2, 3, 4] + eq out, "count 5\nexited\n" + +test! "render all frames if CI environment variable equals false", -> + child = Child.new 'ci', { CI: 'false' } + { code, out } = child.finish! + count 'components', 'render all frames if CI environment variable equals false', 'held' + eq code, 0 + ok out.includes("count #{n}"), "count #{n} was not written" for n in [0, 1, 2, 3, 4, 5] + +test "the ported titles are the ones SOURCE.md counts", -> + eq tally, { + 'suspend-terminal': { held: 8, differs: 0 } + 'suspension-exit': { held: 1, differs: 0 } + 'suspension-output': { held: 1, differs: 0 } + 'suspension-resize': { held: 2, differs: 0 } + 'kitty-negotiation': { held: 1, differs: 1 } + 'exit': { held: 9, differs: 0 } + 'errors': { held: 1, differs: 0 } + 'render': { held: 2, differs: 0 } + 'components': { held: 2, differs: 0 } + } + +# ==[ Setup and teardown ]== + +console.log "\nSetup and teardown" + +test! "every way out — quit, Ctrl-C, a listener that throws, a suspend then quit — inline and on the alternate screen, leaves the terminal, stdin and the process as they were", -> + ways = [ + ['quit', (stdin) -> quit()] + ['Ctrl-C', (stdin) -> stdin.key '\x03'] + ['a listener that throws', (stdin) -> stdin.key 'X'] + ['a suspend, then quit', (stdin) -> suspend -> quit()] + ] + for alt in [false, true] + for [way, leave] in ways + name = "#{way}#{if alt then ' on the alternate screen' else ''}" + before = listeners() + stdin = Stdin.new() + term = Terminal.new() + running = run Ways, stdin: stdin, stdout: term, mouse: true, keyboard: 'enhanced', altScreen: alt + stdin.key KITTY + eq [term.kitty, stdin.raw, term.modes.has(1049)], [1, true, alt], name + leave stdin + try + await running.done + catch error + eq error.message, 'a listener failed', name + every name, term, stdin, before + eq term.text, (if alt then '' else 'w'), "#{name}: what the screen shows" + eq term.cursor, { x: 0, y: (if alt or way is 'a listener that throws' then 0 else 1) }, "#{name}: where the cursor lands" + +test! "the run's handlers stand on the process only while it runs, and a second run sees no stale one", -> + before = listeners() + running = run Echo, stdin: Stdin.new(), stdout: Terminal.new() + eq listeners(), (n + 1 for n in before.slice(0, 3)).concat(before.slice(3, 4), (n + 1 for n in before.slice(4))) + quit() + await running.done + eq listeners(), before + running = run Echo, stdin: Stdin.new(), stdout: Terminal.new() + eq process.listenerCount('SIGTERM'), before[1] + 1 + quit() + await running.done + eq listeners(), before + +test! "a teardown is taken once: a quit while suspended withdraws nothing twice, and a close after a close writes nothing", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term, mouse: true + suspend! -> + quit() + await running.done + eq [term.since(0).split(OFF).length - 1, term.since(0).split(QUIET).length - 1, stdin.calls.filter((call) -> call is 'raw off').length], [1, 1, 1] + mark = term.sent.length + running.quit() + sleep! 0 + eq term.sent.length, mark + +test "a teardown after a setup that failed halfway takes the steps that were taken and no other", -> + before = listeners() + stdin = Stdin.new() + stdin.setRawMode = -> raise 'EBADF: setRawMode' + term = Terminal.new() + throws (-> run Echo, stdin: stdin, stdout: term, mouse: true, altScreen: true), 'EBADF' + eq [stdin.calls, stdin.listenerCount('data'), typeof document], [[], 0, 'undefined'] + ok not term.since(0).includes('1002') and not term.since(0).includes('1049'), JSON.stringify term.since 0 + ok term.shown, 'the cursor is shown again' + eq listeners(), before + +# ==[ Signals ]== + +console.log "\nSignals" + +for [name, code] in [['SIGTERM', 143], ['SIGHUP', 129], ['SIGINT', 130]] + test! "#{name} gives the terminal back — the mouse, the modes, the cursor below the frame — and exits #{code}", -> + child = Child.new 'signal' + child.seen! END + child.signal name + result = child.finish! + eq result.code, code + ok result.out.endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify result.out.slice -60 + eq result.err, '' + +test! "an uncaught error gives the terminal back, prints the error, and exits 1", -> + child = Child.new 'throw' + { code, out, err } = child.finish! + eq code, 1 + ok err.includes('a timer failed') and err.includes('child.rip'), err + ok out.endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -60 + +test! "an unhandled rejection is the same way out", -> + child = Child.new 'reject' + { code, out, err } = child.finish! + eq code, 1 + ok err.includes('a promise failed'), err + ok out.endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -60 + +test! "a `process.exit` with the app live tears the terminal down on the way, and keeps its code", -> + child = Child.new 'exit' + { code, out } = child.finish! + eq code, 7 + ok out.endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -60 + +# ==[ Suspend and resume ]== + +console.log "\nSuspend and resume" + +Keys =! component + @keys := [] + render + Box focusable: true, autofocus: true, @keydown: ((event) => @keys.push event.key), @click: (=> @keys.push 'click') + Text "k" + +test! "suspend gives the terminal back — the flag popped, the mouse, paste and focus modes withdrawn, the cursor shown below the frame, stdin cooked — and takes it back: raw mode, the modes, the flag, the origin asked again, then a whole frame", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Keys, stdin: stdin, stdout: term, mouse: true, keyboard: 'enhanced' + stdin.key KITTY + stdin.key "#{CSI}?1;1R" + down(0, 0) + mark = term.sent.length + at = 0 + suspend! -> + eq term.since(mark), "#{POP}#{QUIET}#{OFF}\n#{SHOW}" + eq [stdin.calls.slice(-3), stdin.raw, stdin.listenerCount('data'), term.cursor, term.kitty], [['raw off', 'pause', 'unref'], false, 0, { x: 0, y: 1 }, 0] + at = term.sent.length + ok term.since(at).startsWith("#{HIDE}#{ASK}#{MOUSE}#{PUSH}#{PROBE}"), JSON.stringify term.since at + eq [stdin.calls.slice(-3), stdin.raw, stdin.listenerCount('data'), term.kitty], [['raw on', 'ref', 'resume'], true, 1, 1] + eq term.queries, ['cursor', 'keyboard', 'attributes', 'cursor'], 'the row is asked again; the keyboard, decided, is not' + sleep! 20 + ok term.since(at).includes(BEGIN), 'a frame is drawn again' + eq term.text, "k\nk", 'the frame stands again below the one left in the scrollback' + stdin.key "#{CSI}?2;1R" + up(0, 1) + eq running.app.keys.value, [], 'the press before the suspend is forgotten: its release is no click' + quit() + await running.done + +test! "Ctrl-Z is a default action of keydown: the terminal is given back and the process stopped; SIGCONT takes it back", -> + stopped = 0 + handler = -> + stopped += 1 + process.kill process.pid, 'SIGCONT' + process.on 'SIGTSTP', handler + try + stdin = Stdin.new() + term = Terminal.new() + running = run Keys, stdin: stdin, stdout: term + mark = term.sent.length + stdin.key '\x1a' + eq term.since(mark), "#{OFF}\n#{SHOW}" + eq stdin.raw, false + at = term.sent.length + sleep! 50 + eq stopped, 1 + ok term.since(at).startsWith(ON), JSON.stringify term.since at + eq [stdin.raw, term.text], [true, "k\nk"] + stdin.key 'a' + eq running.app.keys.value, ['a'] + quit() + await running.done + finally + process.off 'SIGTSTP', handler + +test! "preventDefault on Ctrl-Z keeps the app on the terminal, and Ctrl-Z under mount is nothing", -> + stopped = 0 + handler = -> stopped += 1 + process.on 'SIGTSTP', handler + try + Holds = component + render + Box focusable: true, autofocus: true, @keydown: ((event) -> event.preventDefault() if event.key is 'z' and event.ctrlKey) + Text "h" + stdin = Stdin.new() + term = Terminal.new() + running = run Holds, stdin: stdin, stdout: term + mark = term.sent.length + stdin.key '\x1a' + sleep! 20 + eq [term.sent.length, stdin.raw, stopped], [mark, true, 0] + quit() + await running.done + view = mount Keys + view.press 'z', ctrl: true + sleep! 20 + eq [stopped, view.app.keys.value], [0, ['z']] + view.close() + finally + process.off 'SIGTSTP', handler + +test! "a key that arrives while suspended is nobody's, a resize writes nothing, and a log passes straight through", -> + recorded! (printed) -> + stdin = Stdin.new() + term = Terminal.new() + running = run Keys, stdin: stdin, stdout: term + at = 0 + suspend! -> + at = term.sent.length + stdin.key 'a' + term.columns = 60 + term.emit 'resize' + console.log 'while suspended' + eq term.sent.slice(at), ['while suspended\n'] + eq [running.app.keys.value, screen.cols], [[], 60] + sleep! 20 + eq term.text, "k\nwhile suspended\nk" + stdin.key 'b' + eq running.app.keys.value, ['b'] + quit() + await running.done + eq printed, [] + +test! "a suspend with no app running is refused by name", -> + refused = null + try + suspend! -> null + catch error + refused = error.message + ok /no app/.test(refused), refused + +test! "a stop and a continue, for real: the child gives the terminal back and stops itself; SIGCONT takes it back, and it quits clean", -> + child = Child.new 'stop' + child.seen! "#{QUIET}#{OFF}\n#{SHOW}" + sleep! 30 + child.signal 'SIGCONT' + { code, out, err } = child.finish! + eq [code, err], [0, ''] + first = out.indexOf "#{QUIET}#{OFF}\n#{SHOW}" + rest = out.slice first + "#{QUIET}#{OFF}\n#{SHOW}".length + ok rest.startsWith("#{HIDE}#{ASK}#{MOUSE}#{PROBE}"), JSON.stringify rest.slice 0, 60 + ok rest.includes('count 0'), 'the frame is drawn again' + ok rest.endsWith("#{QUIET}#{OFF}\n#{SHOW}exited\n"), JSON.stringify rest.slice -60 + +# ==[ The alternate screen ]== + +console.log "\nThe alternate screen" + +test! "altScreen enters the alternate screen after the modes, at its top-left, with no cursor probe: the frame is absolute", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term, mouse: true, keyboard: 'enhanced', altScreen: true + ok term.since(0).startsWith(ON + MOUSE + ENTER + QUERY), JSON.stringify term.since 0 + eq [term.queries, term.modes.has(1049), term.lines[0]?.join(''), term.y], [['keyboard', 'attributes'], true, 'x', 0] + eq screen.interactive, true + quit() + await running.done + +test! "quit leaves the alternate screen after the last frame is drawn, and the frame vanishes with it: the screen shows what it did before, the cursor where it was", -> + stdin = Stdin.new() + term = Terminal.new() + term.write 'prompt$ ' + running = run Echo, stdin: stdin, stdout: term, mouse: true, altScreen: true + running.app.label.value = 'last' + mark = term.sent.length + quit() + await running.done + out = term.since mark + ok out.startsWith("#{QUIET}#{OFF}#{BEGIN}"), JSON.stringify out + ok out.endsWith("#{END}#{SHOW}#{LEAVE}"), JSON.stringify out + ok out.includes('last'), 'the last frame is drawn, on the alternate screen' + eq [term.text, term.cursor, term.modes.has(1049)], ['prompt$', { x: 8, y: 0 }, false] + +test! "a resize under the alternate screen repaints whole, with no probe", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term, mouse: true, altScreen: true + mark = term.sent.length + term.columns = 60 + term.emit 'resize' + sleep! 20 + ok term.since(mark).includes("#{BEGIN}#{CSI}J"), JSON.stringify term.since mark + eq [term.queries, screen.cols, term.lines[0]?.join('')], [[], 60, 'x'] + quit() + await running.done + +test! "a mouse report under the alternate screen lands on the row it names: the origin is the top", -> + stdin = Stdin.new() + running = run Ways, stdin: stdin, stdout: Terminal.new(), mouse: true, altScreen: true + stdin.key down(0, 0) + up(0, 0) + eq running.app.heard.value, ['click'] + quit() + await running.done + +test! "a log under the alternate screen is kept, and replayed once the screen is left", -> + recorded! (printed) -> + stdin = Stdin.new() + term = Terminal.new() + err = Pipe.new() + running = run Echo, stdin: stdin, stdout: term, stderr: err, altScreen: true + mark = term.sent.length + console.log 'one' + console.error 'two' + console.warn 'three' + eq [term.sent.length, err.sent, term.lines[0]?.join('')], [mark, [], 'x'] + quit() + await running.done + ok term.since(mark).endsWith("#{SHOW}#{LEAVE}one\n"), JSON.stringify term.since mark + eq [err.sent, term.text, printed], [['two\n', 'three\n'], 'one', []] + +test! "altScreen on a stdout that is no terminal is nothing", -> + pipe = Pipe.new() + running = run Echo, stdin: Stdin.new(), stdout: pipe, altScreen: true + quit() + await running.done + eq pipe.text, 'x\n' + +# ==[ Output that is no terminal ]== + +console.log "\nOutput that is no terminal" + +test! "on a stdout that is no terminal nothing is asked — no modes, no cursor, no probe, no raw mode even on a stdin that is one — the frames are kept, and the last is written once at exit", -> + pipe = Pipe.new() + stdin = Stdin.new() + running = run Echo, stdin: stdin, stdout: pipe, mouse: true, keyboard: 'enhanced' + eq [stdin.calls, stdin.listenerCount('data'), pipe.sent, screen.interactive, screen.cols], [[], 0, [], false, 40] + for label in ['one', 'two', 'three'] + running.app.label.value = label + sleep! 20 + eq pipe.sent, [] + quit 'done' + eq await running.done, 'done' + eq pipe.sent, ['three\n'] + stdin.key 'a' + eq running.app.keys.value, [] + +test! "CI set makes a terminal stdout the same, and CI=false does not", -> + process.env.CI = '1' + try + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term + eq [screen.interactive, stdin.calls, term.sent], [false, [], []] + quit() + await running.done + eq term.sent, ['x\n'] + process.env.CI = 'false' + term = Terminal.new() + running = run Echo, stdin: Stdin.new(), stdout: term + eq screen.interactive, true + ok term.since(0).startsWith(ON) + quit() + await running.done + finally + delete process.env.CI + +test! "`screen.interactive` reads true on a terminal, false on a stdout that is no terminal, and true under mount", -> + running = run Echo, stdin: Stdin.new(), stdout: Terminal.new() + eq screen.interactive, true + quit() + await running.done + running = run Echo, stdin: Stdin.new(), stdout: Pipe.new() + eq screen.interactive, false + quit() + await running.done + view = mount Echo + eq screen.interactive, true + view.close() + +test! "the last frame is written once through a real pipe", -> + child = Child.new 'pipe' + { code, out, err } = child.finish! + eq [code, err, out], [0, '', "count 5\nexited\n"] + +# ==[ Colors ]== + +console.log "\nColors" + +# `screen.colors` after a run under `env`, on `out`. +depthUnder =! (env, out = Terminal.new()) -> + held = {} + for name in ['NO_COLOR', 'FORCE_COLOR', 'TERM', 'COLORTERM'] + held[name] = process.env[name] + delete process.env[name] + process.env[name] = value for name, value of env + try + running = run Echo, stdin: Stdin.new(), stdout: out + depth = screen.colors + quit() + await running.done + depth + finally + for name, value of held + if value? then process.env[name] = value else delete process.env[name] + +test! "the depth is read once at run: NO_COLOR is none, FORCE_COLOR is its number, else TERM and COLORTERM decide; a dumb terminal, or no terminal, has none unless forced", -> + rows = [ + [{ NO_COLOR: '1' }, 0] + [{ NO_COLOR: '', COLORTERM: 'truecolor' }, 0] + [{ NO_COLOR: '1', FORCE_COLOR: '3' }, 0] + [{ FORCE_COLOR: '0' }, 0] + [{ FORCE_COLOR: '1' }, 16] + [{ FORCE_COLOR: '2' }, 256] + [{ FORCE_COLOR: '3' }, 16777216] + [{ FORCE_COLOR: 'true', TERM: 'dumb' }, 16] + [{ TERM: 'xterm-256color' }, 256] + [{ TERM: 'xterm-256color', COLORTERM: 'truecolor' }, 16777216] + [{ TERM: 'xterm', COLORTERM: '24bit' }, 16777216] + [{ TERM: 'xterm' }, 16] + [{}, 16] + [{ TERM: 'dumb' }, 0] + ] + for [env, want] in rows + depth = depthUnder! env + eq depth, want, JSON.stringify env + depth = depthUnder! { TERM: 'xterm-256color' }, Pipe.new() + eq depth, 0, 'a pipe' + depth = depthUnder! { FORCE_COLOR: '2' }, Pipe.new() + eq depth, 256, 'a pipe, forced' + +Tinted =! component + render + Box flexDirection: 'row' + Text color: '#ff0000', backgroundColor: '#0000ff', bold: true + "r" + Text color: 'green' + "g" + Text color: '#808080' + "h" + +# The frame `run` draws under `env`, on a terminal, with its escapes. +paintedUnder =! (env) -> + held = {} + for name in ['NO_COLOR', 'FORCE_COLOR', 'TERM', 'COLORTERM'] + held[name] = process.env[name] + delete process.env[name] + process.env[name] = value for name, value of env + try + term = Terminal.new() + running = run Tinted, stdin: Stdin.new(), stdout: term + quit() + await running.done + term.since 0 + finally + for name, value of held + if value? then process.env[name] = value else delete process.env[name] + +test! "a 24-bit color is sent as it is at full depth, as the nearest of the 256 at that depth, as the nearest of the 16 below it, and nothing is sent at none", -> + full = paintedUnder! { FORCE_COLOR: '3' } + ok full.includes("#{CSI}1;38;2;255;0;0;48;2;0;0;255mr") and full.includes("#{CSI}0;32mg") and full.includes("#{CSI}0;38;2;128;128;128mh"), JSON.stringify full + eq screen.colors, 16777216 + cube = paintedUnder! { FORCE_COLOR: '2' } + ok cube.includes("#{CSI}1;38;5;196;48;5;21mr") and cube.includes("#{CSI}0;32mg") and cube.includes("#{CSI}0;38;5;244mh"), JSON.stringify cube + named = paintedUnder! { FORCE_COLOR: '1' } + ok named.includes("#{CSI}1;31;44mr") and named.includes("#{CSI}0;32mg") and named.includes("#{CSI}0;37mh"), JSON.stringify named + none = paintedUnder! { NO_COLOR: '1' } + ok not /\x1b\[[0-9;]*m/.test(none), JSON.stringify none + ok plain(none).includes('rgh'), JSON.stringify none + +test "mount and renderToString stay at full depth whatever the environment says", -> + held = process.env.NO_COLOR + process.env.NO_COLOR = '1' + try + ok renderToString(Tinted, cols: 10, ansi: true).includes('38;2;255;0;0') + eq screen.colors, 16777216 + finally + if held? then process.env.NO_COLOR = held else delete process.env.NO_COLOR + +# ==[ The console ]== + +console.log "\nThe console" + +Field =! component + render + Box focusable: true, autofocus: true, cursor: { x: 1, y: 0 } + Text "field" + +test! "a log during a run clears the frame, writes the line as it was, and draws the frame again below it, the cursor parked where it was", -> + recorded! (printed) -> + stdin = Stdin.new() + term = Terminal.new() + running = run Field, stdin: stdin, stdout: term + eq term.cursor, { x: 1, y: 0 } + mark = term.sent.length + console.log 'hello', 42 + out = term.since mark + ok out.startsWith("#{HIDE}\r#{CSI}Jhello 42\n#{BEGIN}#{CSI}J"), JSON.stringify out + ok out.endsWith("#{CSI}2G#{SHOW}#{END}"), JSON.stringify out + eq [term.text, term.cursor, printed], ["hello 42\nfield", { x: 1, y: 1 }, []] + console.info 'two' + console.debug 'three' + eq term.text, "hello 42\ntwo\nthree\nfield" + quit() + await running.done + +test! "warn and error go to stderr, the frame cleared for them and drawn again", -> + recorded! (printed) -> + term = Terminal.new() + err = Pipe.new() + running = run Echo, stdin: Stdin.new(), stdout: term, stderr: err + mark = term.sent.length + console.error 'bad', { n: 1 } + console.warn 'worse' + eq err.sent, ['bad { n: 1 }\n', 'worse\n'] + ok term.since(mark).startsWith("#{HIDE}\r#{CSI}J#{BEGIN}"), JSON.stringify term.since mark + eq [term.text, printed], ['x', []] + quit() + await running.done + +test! "the console is the process's own again on every way out, and a log with the app closed passes straight through", -> + recorded! (printed) -> + stdin = Stdin.new() + term = Terminal.new() + running = run Ways, stdin: stdin, stdout: term + patched = console.log + stdin.key 'X' + try + await running.done + catch error + null + ok console.log isnt patched, 'the patch is gone' + mark = term.sent.length + console.log 'after' + eq [printed, term.sent.length], [[['log', 'after']], mark] + +test! "console: false leaves the console alone", -> + recorded! (printed) -> + term = Terminal.new() + running = run Echo, stdin: Stdin.new(), stdout: term, console: false + mark = term.sent.length + console.log 'through' + eq [printed, term.sent.length, term.text], [[['log', 'through']], mark, 'x'] + quit() + await running.done + +test! "with the mouse on, a log asks where the frame went", -> + recorded! (printed) -> + term = Terminal.new() + running = run Echo, stdin: Stdin.new(), stdout: term, mouse: true + eq term.queries, ['cursor'] + console.log 'scrolls' + ok term.since(0).endsWith(PROBE), JSON.stringify term.since(0).slice -30 + eq term.queries, ['cursor', 'cursor'] + quit() + await running.done + +test! "a log on a stdout that is no terminal passes straight through", -> + recorded! (printed) -> + pipe = Pipe.new() + running = run Echo, stdin: Stdin.new(), stdout: pipe + console.log 'plain' + eq [printed, pipe.sent], [[['log', 'plain']], []] + quit() + await running.done + +# ==[ Fuzz ]== + +console.log "\nFuzz" + +ROUNDS =! 40 +STEPS =! 20 + +# A seeded generator, so a failure names its seed. +rnd = null +seeded =! (seed) -> + state = seed >>> 0 + -> + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + state / 4294967296 + +pick =! (list) -> list[Math.floor rnd() * list.length] + +Fuzzed =! component + @keys := [] + @label := 'f' + render + Box focusable: true, autofocus: true, @keydown: ((event) => if event.key is 'X' then raise 'a listener failed' else @keys.push event.key) + Text "#{@label}" + +# The modes a run believes it holds, against the terminal's. +same =! (name, term, stdin, believe) -> + got = { raw: stdin.raw, paste: term.modes.has(2004), focus: term.modes.has(1004), mouse: term.modes.has(1006), kitty: term.kitty is 1, alt: term.modes.has(1049), cursor: term.shown } + eq got, believe, name + +test! "random keys, resizes, logs and suspends, then a random way out: the terminal's modes are what the app believes after every step, and every exit leaves everything off, the cursor shown, below the frame", -> + seed = Math.floor Math.random() * 1000000 + rnd = seeded seed + steps = 0 + exits = 0 + suspends = 0 + logs = 0 + ignored = 0 + recorded! (printed) -> + for round in [0...ROUNDS] + alt = rnd() < 0.3 + mouse = pick [false, true, 'all'] + enhanced = rnd() < 0.5 + term = Terminal.new() + stdin = Stdin.new() + before = listeners() + running = run Fuzzed, stdin: stdin, stdout: term, mouse: mouse, keyboard: (if enhanced then 'enhanced' else 'basic'), altScreen: alt + logged = [] + believe = { raw: true, paste: true, focus: true, mouse: mouse isnt false, kitty: false, alt, cursor: false } + if enhanced and rnd() < 0.6 + stdin.key KITTY + believe.kitty = true + name = "seed #{seed}, round #{round}" + same name, term, stdin, believe + for n in [0...STEPS] + steps += 1 + switch pick ['key', 'key', 'resize', 'log', 'suspend'] + when 'key' + key = pick ['a', 'b', '\t', "#{CSI}A"] + stdin.key key + when 'resize' + term.columns = pick [30, 40, 60] + term.emit 'resize' + when 'log' + logs += 1 + console.log "log #{n}" + logged.push "log #{n}" + unless alt + ok term.text.includes("log #{n}") and term.text.endsWith('f'), "#{name}: after a log the screen shows #{JSON.stringify term.text}" + when 'suspend' + suspends += 1 + heard = running.app.keys.value.length + suspend! -> + same "#{name}, suspended", term, stdin, { raw: false, paste: false, focus: false, mouse: false, kitty: false, alt: false, cursor: true } + if rnd() < 0.5 + stdin.key 'a' + ignored += 1 + term.emit 'resize' if rnd() < 0.5 + at = term.sent.length + if rnd() < 0.5 + console.log 'aside' + logged.push 'aside' + eq term.sent.slice(at).filter((write) -> write isnt 'aside\n'), [], "#{name}: nothing is written while suspended" + eq running.app.keys.value.length, heard, "#{name}: a key while suspended reached the app" + same "#{name}, step #{n}", term, stdin, believe + way = pick ['quit', 'Ctrl-C', 'throw', 'suspend and quit'] + switch way + when 'quit' then quit() + when 'Ctrl-C' then stdin.key '\x03' + when 'throw' then stdin.key 'X' + when 'suspend and quit' then suspend -> quit() + try + await running.done + catch error + eq error.message, 'a listener failed', name + exits += 1 + every "#{name}, #{way}", term, stdin, before + if alt + if logged.length + ok term.text.endsWith(logged[logged.length - 1]), "#{name}, #{way}: the logs are replayed once the alternate screen is left: #{JSON.stringify term.text}" + else + eq [term.text, term.cursor], ['', { x: 0, y: 0 }], "#{name}, #{way}: the alternate screen is left as it was found" + else + ok term.text.endsWith('f'), "#{name}, #{way}: the last frame stands" + eq term.cursor.y, (if way is 'throw' then term.lines.length - 1 else term.lines.length), "#{name}, #{way}: the cursor is below the frame" + eq printed, [] + ok steps >= ROUNDS * STEPS, "#{steps} steps" + ok exits is ROUNDS, "#{exits} exits" + ok suspends >= 80, "#{suspends} suspends" + ok logs >= 80, "#{logs} logs" + ok ignored >= 20, "#{ignored} keys ignored while suspended" diff --git a/packages/tui/test/terminal/SOURCE.md b/packages/tui/test/terminal/SOURCE.md new file mode 100644 index 00000000..a2fe7fd3 --- /dev/null +++ b/packages/tui/test/terminal/SOURCE.md @@ -0,0 +1,160 @@ +# Ported: Ink's suspend, exit, error, console and CI tests + +- **Upstream:** [vadimdemedes/ink](https://github.com/vadimdemedes/ink) +- **Path:** `test/suspend-terminal.tsx`, `test/suspension-exit.tsx`, + `test/suspension-output.tsx`, `test/suspension-resize.tsx`, + `test/suspension-handle.tsx`, `test/suspension-input-disable.tsx`, + the two suspension rows of `test/kitty-negotiation.tsx`, + `test/exit.tsx`, `test/errors.tsx`, `test/error-overview.tsx`, + `test/alternate-screen-example.tsx`, the console rows of + `test/render.tsx` and the CI rows of `test/components.tsx`, read + from the checkout at `misc/ink` +- **Commit:** `02ae1e59e7c288e971616100c4c11bcca6b5761b` (Ink's main + branch, ahead of the published 7.1.1) — the commit `test/ink/`, + `test/input/`, `test/events/` and `test/mouse/` port from +- **State:** ported, not vendored. None of Ink's files is copied here; + `test/terminal.rip` redraws each case as a Rip component under Ink's + own test title and takes the way out Ink's test takes. +- **License:** MIT, © Vadym Demedes and Sindre Sorhus. The text is at + the foot of `test/ink/SOURCE.md`. + +The lifecycle is written from the xterm control-sequence reference +(DECTCEM `CSI ? 25`, bracketed paste `CSI ? 2004`, focus reports +`CSI ? 1004`, the mouse modes, the alternate screen `CSI ? 1049`, +DECXCPR `CSI ? 6 n`), the kitty keyboard protocol's push and pop, the +POSIX meaning of SIGTSTP and SIGCONT, and Bun's and Node's `process` +documentation for signals, `uncaughtException`, `beforeExit` and +`exit`. Ink's `ink.tsx`, `log-update.ts` and `App.tsx` were read for +what its tests mean; its ref-counted raw mode, its console patch and +its throttled log-update are not carried over. + +## What runs + +Of the 46 titles in these files, 27 are ported and 19 are left out. +`test/terminal.rip` holds `harness.rip`'s tally to these counts. + +| Ink file | titles | held | differs | left out | +|---|---|---|---|---| +| `suspend-terminal` | 14 | 8 | 0 | 6 | +| `suspension-exit` | 2 | 1 | 0 | 1 | +| `suspension-output` | 3 | 1 | 0 | 2 | +| `suspension-resize` | 2 | 2 | 0 | 0 | +| `suspension-handle` | 2 | 0 | 0 | 2 | +| `suspension-input-disable` | 6 | 0 | 0 | 6 | +| `kitty-negotiation` (suspension rows) | 2 | 1 | 1 | 0 | +| `exit` | 15 | 9 | 0 | 6 | +| `errors` | 7 | 1 | 0 | 6 | +| `error-overview` | 6 | 0 | 0 | 6 | +| `alternate-screen-example` | 2 | 0 | 0 | 2 | +| `render` (console rows) | 2 | 2 | 0 | 0 | +| `components` (CI rows) | 2 | 2 | 0 | 0 | + +The suspension rows of `kitty-negotiation` were left out of +`test/mouse/SOURCE.md` for this file; `waitUntilExit preserves the +original component error` is pinned in `test/events.rip` and left out +here. + +## What is compared + +Ink's `useApp().suspendTerminal(fn)` is `suspend fn` here: both give +the terminal back, run `fn`, and take it again with a whole redraw. +Ink's `unmount()` and `exit(value)` are both `quit value`; its +`waitUntilExit()` is `done`; its `patchConsole` option is `console`; +its `interactive: false` is a stdout that is no terminal, or `CI` +set. A case Ink runs on a pty runs here in a spawned `rip` process +whose stdout says it is a terminal and writes through to the pipe +(`test/terminal/child.rip`), so the bytes are read as a pty would show +them and the exit code is the process's own. + +| Ink | here | +|---|---| +| `suspendTerminal(async () => …)` | `suspend! -> …` | +| `stdin.setRawMode` calls | `Stdin.calls` (`harness.rip`) | +| `stdout.getWrites()` | `Terminal.sent`, `Terminal.since` | +| `[?25h` / `[?25l` in a write | the same bytes | +| `[?1049h` / `[?1049l` | the same bytes; `CSI H` follows the entry | +| `alternateScreen: true` | `altScreen: true` | +| `interactive: false` | `stdout` with no `isTTY`, or `CI` set | +| `patchConsole: false` | `console: false` | +| `process.exit`, a fixture's exit code | `Child.finish` in a spawned process | + +## Held + +- `suspend-terminal`: hands the terminal to the callback, then + restores Ink; restores the terminal even if the callback throws; + keeps Ink off the terminal while suspended; runs the callback but + skips the handoff when not interactive; rejects a nested suspend + while already suspended; hands the terminal to a child process, then + redraws (PTY) — the "child" writes to the same fake terminal, and + the frame is drawn again below what it wrote; exits and re-enters + the alternate screen; rolls back so a later suspend works if + handover throws +- `suspension-exit`: resuming after unmount does not re-enable + terminal input (callback: true) +- `suspension-output`: non-interactive suspension keeps the latest + rendered frame +- `suspension-resize`: resize to 80 / 120 columns does not write while + suspended +- `kitty-negotiation`: unmount while suspended does not pop the primary + keyboard stack +- `exit`: exit normally without unmount() or exit(); exit when app + finishes execution — both by the same spawned process, whose loop + drains and whose app is closed on `beforeExit`; exit on unmount(); + exit on exit(); exit on exit() with raw mode; exit on unmount() with + raw mode — raw mode is always on here, so the four are one `quit`; + exit on exit() with result value; exit on exit() with object result; + exit with thrown error — `run` throws it +- `errors`: clean up raw mode when error is thrown — an uncaught error + in a spawned process, which leaves the modes withdrawn and exits 1 +- `render`: intercept console methods with omitted / undefined + patchConsole option +- `components`: render only last frame when run in CI; render all + frames if CI environment variable equals false + +## Stated differences + +- `kitty-negotiation`: suspension cancels pending keyboard negotiation + — an answer that arrives while suspended reaches nobody, as in Ink; + but the question is asked again on resume, so a terminal that + answers it then is pushed to, where Ink cancels the negotiation for + the rest of the run + +## Left out + +- `suspend-terminal`: returns a disposable that resumes on resume(); + disposable resumes via Symbol.asyncDispose — `suspend` takes the + function to run and resumes when it settles; there is no handle +- `suspend-terminal`: shows `` output once after resume + re-enters the alternate screen (four modes) — `Static` is a + documented no-op on the alternate screen (PLAN §6) +- `suspension-exit`: callback: false — a handle +- `suspension-output`: non-interactive suspension preserves stdout / + stderr writes — Ink's `useStdout().write`; a write to a stream is + the stream's own here +- `suspension-handle`: both — handles +- `suspension-input-disable`: all six — Ink's input hooks each own a + share of raw mode and of bracketed paste, and a resume restores the + shares that still have an owner; the host owns the terminal for the + app's life here, with no share to disable (PLAN §8) +- `exit`: exit on exit() with error; with error with value property; + with raw mode with error — `quit` takes a result and never an error; + an app that fails throws, and `done` rejects with what it threw + (`test/events.rip`) +- `exit`: don't exit while raw mode is active — the process is kept + alive by a real stdin's `ref`, which a fake stdin has no way to show +- `exit`: exit when DEV is set — React's development flag +- `exit`: exit on exit() with error and static output — `Static`, and + an error result +- `errors`: catch and display error; ErrorBoundary catches and + displays nested component errors — the error overview is a deferred + reporter (PLAN §13); display thrown strings and reject waitUntilExit; + display thrown undefined — the same; does not emit unhandledRejection + when render exits with an error and waitUntilExit is unused — `done` + is always handed back, and a rejection nobody awaits is the caller's; + waitUntilExit preserves the original component error — + `test/events.rip` pins that `done` rejects with what a listener threw +- `error-overview`: all six — the deferred reporter; stacks are the + runtime's own (`src/cli/run.js`) +- `alternate-screen-example`: both — the snake game's reducer, and a + fixture that prints its state; the alternate screen's bytes are + pinned by this package's own rows diff --git a/packages/tui/test/terminal/child.rip b/packages/tui/test/terminal/child.rip new file mode 100644 index 00000000..a69d2664 --- /dev/null +++ b/packages/tui/test/terminal/child.rip @@ -0,0 +1,41 @@ +# A small app under `run`, spawned by test/terminal.rip to take one way +# out for real — a signal, an uncaught error, a `process.exit`, a loop +# that drains, a stop and a continue — on a stdout that says it is a +# terminal and writes through to the process's own, so whoever spawned +# this reads every byte, and on the harness's stdin. The argument is +# the way out; `pipe` and `ci` run on the process's stdout as it is. + +import { run, quit, Box, Text } from 'rip/tui' +import { Stdin } from '../events/harness.rip' + +way = process.argv[2] +tty = { isTTY: true, columns: 40, rows: 10, write: (text) -> process.stdout.write text } +stdin = Stdin.new() + +App = component + @count := 0 + render + Box focusable: true, autofocus: true + Text "count #{@count}" + +options = { stdin, stdout: (if way is 'pipe' then process.stdout else tty), mouse: true } +running = run App, options +running.done.then (-> console.log 'exited'), (error) -> console.log "errored: #{error.message}" + +switch way + when 'throw' then setTimeout (-> raise 'a timer failed'), 20 + when 'reject' then setTimeout (-> Promise.reject Error.new 'a promise failed'), 20 + when 'exit' then setTimeout (-> process.exit 7), 20 + when 'stop' + timer = setInterval (->), 1000 + process.on 'SIGCONT', -> setTimeout (-> clearInterval timer; quit()), 40 + setTimeout (-> stdin.key '\x1a'), 20 + when 'pipe', 'ci' + timer = setInterval -> + running.app.count.value += 1 + return unless running.app.count.value is 5 + clearInterval timer + quit() + , 30 + when 'drain' then null + else setInterval (->), 1000 diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index 362b760f..7dd6a8a0 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -365,6 +365,11 @@ close =! (drawn, error = null, result = undefined) -> step restore if error then settled.reject error else settled.resolve result +# Hand the terminal to `fn` — an editor, a shell — and take it back +# once `fn` settles: the road Ctrl-Z takes, without the signal. +export suspend =! (fn) -> + throw Error.new 'rip/tui: no app is running — `suspend` hands over the terminal `run` holds' + # Unmount the mounted app, leave its last frame on screen, and give the # process its `document` slot back. The work waits for the turn to end, # so a `quit` called from inside an effect still draws what that same From bf8803f34950f92068e22a45c50f4adc80793d97 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 20:22:54 -0700 Subject: [PATCH 02/18] tui: the contract for Static, print, the clock and progress Ink's static-*.tsx, issue-973-static-commit.tsx and the Static cases of components, render-to-string and background ported into test/ink/static.rip, with cursor.tsx's useStdout and useStderr cases through print; byte pins for the write above the frame, the mouse origin after it, the clock's timer and the progress sequence in test.rip; stubs so every file loads. --- packages/tui/document.rip | 2 +- packages/tui/paint.rip | 9 + packages/tui/screen.rip | 11 + packages/tui/test.rip | 373 ++++++++++++++++- packages/tui/test/events/SOURCE.md | 6 +- packages/tui/test/ink.rip | 1 + packages/tui/test/ink/SOURCE.md | 95 +++-- packages/tui/test/ink/static.rip | 620 +++++++++++++++++++++++++++++ packages/tui/tui.rip | 30 +- 9 files changed, 1098 insertions(+), 49 deletions(-) create mode 100644 packages/tui/test/ink/static.rip diff --git a/packages/tui/document.rip b/packages/tui/document.rip index 601b0ef5..1e7c44e5 100644 --- a/packages/tui/document.rip +++ b/packages/tui/document.rip @@ -82,7 +82,7 @@ unknown =! (key, tag) -> # A reactive cell, minted on the first read of what it holds — a node's # box, whether it has focus — so a node nobody asks carries none. -watch =! (first) -> +export watch =! (first) -> held := first { read: (-> held), write: ((next) -> held = next) } diff --git a/packages/tui/paint.rip b/packages/tui/paint.rip index 21847a97..2d8151df 100644 --- a/packages/tui/paint.rip +++ b/packages/tui/paint.rip @@ -431,6 +431,15 @@ export class Grid lines.push (if ansi then line else line.trimEnd()) lines.join '\n' +# The one serializer of a painted grid: `renderToString`, a `Static` +# item and the frame written off a terminal (terminal.rip) all read +# their rows through it. +export rowsToString =! (grid, ansi = false) -> grid.toString ansi + +# Paint `node` alone, as a root laid out at `cols`, onto a grid of its +# own: the rows a `Static` batch writes to the scrollback (screen.rip). +export still =! (node, cols) -> Grid.new cols, 0 + # The selection (mouse.rip): the cells `from` to `to` in reading order, # each counted as row × columns + column of tree cells, shown inverse. # Only the cells the paint owed are flipped: the rest show as they diff --git a/packages/tui/screen.rip b/packages/tui/screen.rip index 00fc94d8..19cea6db 100644 --- a/packages/tui/screen.rip +++ b/packages/tui/screen.rip @@ -44,6 +44,17 @@ export class Screen @origin = 0 # the terminal row the frame's top-left is on: what the cursor probe answers, and lower once a frame scrolls the terminal @selection = { from: -1, to: -1 } # the selected cells (mouse.rip), none while `from` is under zero @after = null # run after every frame: the pointer looks again at what is under it + @statics = Set.new() # the `Static` containers mounted (tui.rip), in the order they arrived + @scrollback = '' # everything written above the frame so far, as it was written + + # Write `text` above the live frame. + above!: (text, err = null) -> + + # Report progress on the terminal's own indicator. + progress!: (value) -> + + # The bytes that clear a reported progress, once. + clearProgress: -> '' get cols: -> @out.columns ?? 80 get rows: -> @out.rows ?? 24 diff --git a/packages/tui/test.rip b/packages/tui/test.rip index c94b822e..338e1e69 100644 --- a/packages/tui/test.rip +++ b/packages/tui/test.rip @@ -3,11 +3,11 @@ # ============================================================================== import { test, eq, ok, throws } from 'rip/testing' -import { run, quit, mount, renderToString, screen, Box, Text, Spacer } from 'rip/tui' +import { run, quit, mount, renderToString, screen, print, clock, Box, Text, Spacer, Static, Newline } from 'rip/tui' import * as mod from 'rip/tui' import { install } from './document.rip' import { layout } from './layout.rip' -import { Grid, paint, diff, tally as styleCount } from './paint.rip' +import { Grid, paint, diff, rowsToString, tally as styleCount } from './paint.rip' import { tally as clusterCount, clusterWidth } from './text.rip' import { __setChildFailureReporter } from '../../src/runtime/components.js' import { EventEmitter } from 'node:events' @@ -138,7 +138,7 @@ def mounted(App, body) console.log "\nPackage" test "module exports the entry surface and nothing else", -> - eq Object.keys(mod).sort(), ['Box', 'Spacer', 'Text', 'focus', 'mount', 'quit', 'renderToString', 'run', 'screen'] + eq Object.keys(mod).sort(), ['Box', 'Newline', 'Spacer', 'Static', 'Text', 'clock', 'focus', 'mount', 'print', 'quit', 'renderToString', 'run', 'screen'] test "declares no dependencies", -> pkg = JSON.parse readFileSync(join(import.meta.dir, 'package.json'), 'utf8') @@ -1936,3 +1936,370 @@ test "a sweep as a paint starts owes every cell", -> wholly view finally view.close() + +# ==[ Static, print ]== + +console.log "\nScrollback" + +# A log of finished steps above a live line. +Journal =! component + @done := [] + @live := 'live' + render + Box flexDirection: 'column' + Static + for step in @done + Text key: step, step + Text "#{@live}" + +test "a static item is written in the frame's write: the frame's rows cleared, the item's rows, the frame drawn again below", -> + view = mount Journal, cols: 20, rows: 10 + try + eq view.frame(), 'live' + eq view.bytes, "\x1b[?2026h\x1b[J\x1b[1Glive\r\x1b[?2026l" + view.app.done.value = ['step one'] + eq view.frame(), 'live', 'the live frame does not grow' + eq view.bytes, "\x1b[?2026h\x1b[Jstep one\n\x1b[J\x1b[1Glive\r\x1b[?2026l" + eq view.scrollback, "step one\n" + view.app.done.value = ['step one', 'two', 'three'] + view.frame() + eq view.bytes, "\x1b[?2026h\x1b[Jtwo\nthree\n\x1b[J\x1b[1Glive\r\x1b[?2026l", 'two new items are written in order, in one write' + eq view.scrollback, "step one\ntwo\nthree\n" + eq replay([view.bytes], 20, 10), "two\nthree\nlive" + finally + view.close() + +test "an item is painted once, at the terminal's width: a change to it or its removal writes nothing, and the frame stays", -> + Steps = component + @done := ['a'] + @tint := undefined + render + Box flexDirection: 'column' + Static + for step in @done + Text key: step, color: @tint, wrap: 'truncate' + "#{step} #{'x'.repeat 30}" + Text "live" + view = mount Steps, cols: 20 + try + eq view.frame(), 'live' + eq view.scrollback, "a xxxxxxxxxxxxxxxxx…\n" + view.app.tint.value = 'red' + view.frame() + eq view.bytes, '', 'a written item that changes writes nothing' + eq view.scrollback, "a xxxxxxxxxxxxxxxxx…\n" + view.app.done.value = ['a', 'b'] + view.frame() + eq view.scrollback, "a xxxxxxxxxxxxxxxxx…\n\x1b[31mb xxxxxxxxxxxxxxxxx…\x1b[0m\n", 'the new item is written in its color, the old one not again' + view.app.done.value = ['b'] + view.frame() + eq view.bytes, '', 'a removal writes nothing' + eq view.damage, 0 + finally + view.close() + +test "the mouse lands right after a static write: the frame's origin moves down by the rows written", -> + hits = [] + Rows = component + @done := [] + render + Box flexDirection: 'column' + Static + for step in @done + Text key: step, step + for n in [0, 1] + Text key: n, @click: (-> hits.push n) + "row #{n}" + view = mount Rows, cols: 20, rows: 10, mouse: true + try + view.frame() + view.send "\x1b[<0;1;2M\x1b[<0;1;2m" + eq hits, [1] + view.app.done.value = ['one', 'two'] + view.frame() + view.send "\x1b[<0;1;4M\x1b[<0;1;4m" + eq hits, [1, 1], 'the frame sits two rows lower' + view.send "\x1b[<0;1;2M\x1b[<0;1;2m" + eq hits, [1, 1], 'a click on a written row hits nothing' + finally + view.close() + +test "under the alternate screen a static item writes nothing and paints nothing", -> + view = mount Journal, cols: 20 + try + view.held.view.alt = true + view.app.done.value = ['x'] + eq view.frame(), 'live' + ok not view.bytes.includes('x'), JSON.stringify view.bytes + eq view.scrollback, '' + finally + view.close() + +test "off a terminal, items are written as plain text as they arrive", -> + Tinted = component + @done := [] + render + Box flexDirection: 'column' + Static + for step in @done + Text key: step, color: 'red', step + Text "live" + view = mount Tinted, cols: 20 + try + view.held.view.interactive = false + view.app.done.value = ['x'] + view.frame() + ok view.bytes.startsWith("x\n"), JSON.stringify view.bytes + eq view.scrollback, "x\n" + finally + view.close() + +test "an item under a hidden ancestor waits, and is written once shown", -> + Folded = component + @open := false + render + Box flexDirection: 'column' + Box display: (if @open then undefined else 'none') + Static + for step in ['x'] + Text key: step, step + Text "live" + view = mount Folded, cols: 20 + try + view.frame() + eq view.scrollback, '' + view.app.open.value = true + view.frame() + eq view.scrollback, "x\n" + view.app.open.value = false + view.app.open.value = true + view.frame() + eq view.scrollback, "x\n" + finally + view.close() + +test "renderToString is the rows written above and then the frame, plain or with escape sequences", -> + eq renderToString(Journal, cols: 20, props: { done: ['a', 'b'] }), "a\nb\nlive" + eq renderToString(Journal, cols: 20, props: { done: ['a', 'b'], live: '' }), "a\nb\n" + Only = component + render + Static + for step in ['a', 'b'] + Text key: step, color: 'red', step + eq renderToString(Only, cols: 20), "a\nb" + eq renderToString(Only, cols: 20, ansi: true), "\x1b[31ma\x1b[0m\n\x1b[31mb\x1b[0m" + +test "print writes above the frame, ending its line; print.err the same on stderr, the frame cleared on stdout first", -> + view = mount Journal, cols: 20 + try + view.frame() + print 'warning' + view.frame() + eq view.bytes, "\x1b[?2026h\x1b[Jwarning\n\x1b[J\x1b[1Glive\r\x1b[?2026l" + print 'two\nlines\n' + view.frame() + eq view.bytes, "\x1b[?2026h\x1b[Jtwo\nlines\n\x1b[J\x1b[1Glive\r\x1b[?2026l" + print.err 'oops' + ok view.bytes.endsWith("\x1b[J"), 'the frame is cleared at once' + eq view.stderr, "oops\n" + view.frame() + eq view.bytes, "\x1b[?2026h\x1b[J\x1b[1Glive\r\x1b[?2026l", 'the frame is drawn again below' + eq view.scrollback, "warning\ntwo\nlines\noops\n" + finally + view.close() + +test "rowsToString is the grid's serializer, plain and with escape sequences", -> + view = mount Journal, cols: 20, props: { live: 'here' } + try + view.frame() + front = view.held.view.front + eq rowsToString(front), 'here' + eq rowsToString(front, true), front.toString true + finally + view.close() + +test "Newline breaks a line inside text, `count` times", -> + One = component + render + Text + "Hello" + Newline + "World" + eq renderToString(One, cols: 20), "Hello\nWorld" + Two = component + render + Text + "Hello" + Newline count: 2 + "World" + eq renderToString(Two, cols: 20), "Hello\n\nWorld" + +# ==[ The clock ]== + +console.log "\nClock" + +# A timer source a test drives by hand and counts: `tick` jumps its +# time and runs what fell due, in order. +def timers() + fake = { time: 0, due: [], afters: 0, cancels: 0 } + fake.now = -> fake.time + fake.after = (ms, fn) -> + fake.afters += 1 + entry = { at: fake.time + ms, fn } + fake.due.push entry + -> + fake.cancels += 1 + fake.due = fake.due.filter (other) -> other isnt entry + fake.tick = (ms) -> + fake.time += ms + ready = fake.due.filter((entry) -> entry.at <= fake.time).sort (a, b) -> a.at - b.at + fake.due = fake.due.filter (entry) -> entry.at > fake.time + entry.fn() for entry in ready + fake + +test "a clock a component holds but never reads costs no timer", -> + fake = timers() + Holder = component + tick = clock 30, fake + render + Text "still" + view = mount Holder, cols: 20 + try + eq view.frame(), 'still' + eq fake.afters, 0 + finally + view.close() + eq fake.cancels, 0 + +test "two components reading one interval share one timer, which runs while a reader remains and stops at close", -> + fake = timers() + Spin = component + tick = clock 40, fake + render + Text "#{tick.frame}" + Pair = component + @second := true + render + Box flexDirection: 'row' + Spin + if @second + Spin + view = mount Pair, cols: 20 + try + eq view.frame(), '00' + eq fake.afters, 1, 'one timer for two readers' + fake.tick 80 + eq view.frame(), '22', 'the frame is the time elapsed over the interval, however late the tick' + eq fake.afters, 2, 'the one timer is set again after the tick' + view.app.second.value = false + eq view.frame(), '2' + eq fake.cancels, 0, 'the timer stays while a reader remains' + fake.tick 40 + eq view.frame(), '3' + finally + view.close() + eq fake.cancels, 1, 'the timer stops at close' + eq fake.due, [], 'nothing is left to run' + +test "`tick` moves the mount's clock: frame counts intervals, time the milliseconds, delta the time since the last tick", -> + Watch = component + tick = clock 50 + render + Text "#{tick.frame},#{tick.time},#{tick.delta}" + view = mount Watch, cols: 20 + try + eq view.frame(), '0,0,0' + view.tick 50 + eq view.frame(), '1,50,50' + view.tick 120 + eq view.frame(), '3,150,50' + view.tick 30 + eq view.frame(), '4,200,50', 'the parser and the clock share one time' + finally + view.close() + +test "the frame catches up when the timer is late, and the interval defaults to 100 where none is given or the one given is no number", -> + fake = timers() + Late = component + tick = clock 60, fake + render + Text "#{tick.frame}" + view = mount Late, cols: 20 + try + view.frame() + fake.tick 250 + eq view.frame(), '4' + finally + view.close() + ok clock(NaN) is clock(100) and clock() is clock(100) and clock(Infinity) is clock(100) + +test "a clock never runs where the output is not interactive", -> + fake = timers() + Quiet = component + tick = clock 70, fake + render + Text "#{tick.frame}" + view = mount Quiet, cols: 20 + try + view.held.view.interactive = false + eq view.frame(), '0' + eq fake.afters, 0 + finally + view.close() + +# ==[ Progress ]== + +console.log "\nProgress" + +test "progress goes out with the next frame's write: a percent, error, indeterminate, and the clear", -> + view = mount Journal, cols: 20 + try + view.frame() + screen.progress 0.5 + view.frame() + eq view.bytes, "\x1b[?2026h\x1b]9;4;1;50\x1b\\\x1b[?2026l" + screen.progress 'error' + view.frame() + eq view.bytes, "\x1b[?2026h\x1b]9;4;2\x1b\\\x1b[?2026l" + screen.progress 'indeterminate' + view.app.live.value = 'busy' + view.frame() + eq view.bytes, "\x1b[?2026h\x1b[1Gbusy\r\x1b]9;4;3\x1b\\\x1b[?2026l", 'one write carries the cells and the progress' + screen.progress null + view.frame() + eq view.bytes, "\x1b[?2026h\x1b]9;4;0\x1b\\\x1b[?2026l" + screen.progress 1 + screen.progress 0.333 + view.frame() + eq view.bytes, "\x1b[?2026h\x1b]9;4;1;33\x1b\\\x1b[?2026l", 'the last value set before the write is the one sent' + throws (-> screen.progress 2), "progress: 2 is not a number from 0 to 1, 'error', 'indeterminate' or null" + finally + view.close() + +test "the clear on the way out is answered once a progress was reported, and not otherwise", -> + view = mount Journal, cols: 20 + try + view.frame() + eq view.held.view.clearProgress(), '' + screen.progress null + view.frame() + eq view.bytes, '', 'a clear with nothing reported is nothing' + screen.progress 0.25 + view.frame() + eq view.held.view.clearProgress(), "\x1b]9;4;0\x1b\\" + eq view.held.view.clearProgress(), '' + finally + view.close() + +test "progress is nothing off a terminal, and with no app mounted", -> + screen.progress 0.5 + view = mount Journal, cols: 20 + try + view.held.view.interactive = false + view.frame() + screen.progress 0.5 + view.frame() + ok not view.bytes.includes("\x1b]9;4"), JSON.stringify view.bytes + eq view.held.view.clearProgress(), '' + finally + view.close() diff --git a/packages/tui/test/events/SOURCE.md b/packages/tui/test/events/SOURCE.md index f47ff33f..44e96f1c 100644 --- a/packages/tui/test/events/SOURCE.md +++ b/packages/tui/test/events/SOURCE.md @@ -38,7 +38,9 @@ pins. Of Ink's 194 titles in the fourteen files, 164 are ported: `harness.rip`: the answer must equal this package's stated one and must not equal Ink's, with the decision in a sentence, so a pin the package outgrows fails. They are listed below; -- 30 are left out, each with its reason. +- 23 are left out, each with its reason, and 7 more — `cursor.tsx`'s + `useStdout().write` and `useStderr().write` cases — are ported in + `test/ink/static.rip`, where `print` is pinned. `test/events.rip` holds `harness.rip`'s tally of titles to these counts, file by file, so the table cannot drift from what runs. A case @@ -182,7 +184,7 @@ outside a paste, the Linux console's `CSI [`, `SS3 Z`), one of - `hooks-use-input`: useInput - discrete priority keeps states in sync with useTransition during rapid input - `cursor`: cursor position does not leak from suspended concurrent render to fallback -**Writing beside the frame (`useStdout().write`, `useStderr().write`, Ink's debug writer): `print` is step 5 of PLAN §12** (7) +**Writing beside the frame (`useStdout().write`, `useStderr().write`, Ink's debug writer): ported in `test/ink/static.rip`, through `print` and `print.err`** (7) - `cursor`: cursor remains visible after useStdout().write() - `cursor`: cursor remains visible after useStderr().write() diff --git a/packages/tui/test/ink.rip b/packages/tui/test/ink.rip index baec5ba7..f3360634 100644 --- a/packages/tui/test/ink.rip +++ b/packages/tui/test/ink.rip @@ -39,3 +39,4 @@ import './ink/render-to-string.rip' import './ink/update.rip' import './ink/style-update-consistency.rip' import './ink/reconciler.rip' +import './ink/static.rip' diff --git a/packages/tui/test/ink/SOURCE.md b/packages/tui/test/ink/SOURCE.md index f8779ab7..76797efd 100644 --- a/packages/tui/test/ink/SOURCE.md +++ b/packages/tui/test/ink/SOURCE.md @@ -12,12 +12,15 @@ ## What runs -`rip test/ink.rip` runs 490 tests: 478 ported cases and 12 self-tests of -`cells.rip` (`cells-check.rip`). Of the ported cases: - -- 459 hold the frame to Ink's, row for row — 53 of them Ink's - `rerender` cases, which hold every frame of a mounted tree; -- 18 are **stated differences**, pinned through `differs` in +`rip test/ink.rip` runs 540 tests: 521 ported paint-side cases, the 7 +`useStdout` / `useStderr` cases of `cursor.tsx` (`static.rip`, counted +in `test/events/SOURCE.md`'s table), and 12 self-tests of `cells.rip` +(`cells-check.rip`). Of the paint-side cases: + +- 501 hold the frame to Ink's, row for row — 53 of them Ink's + `rerender` cases, which hold every frame of a mounted tree, and 16 + the `Static` cases that draw a mounted tree frame by frame; +- 19 are **stated differences**, pinned through `differs` in `harness.rip`: the frame must equal this package's stated frame and must not equal Ink's, with the decision in a sentence, so a pin the package outgrows fails. They are listed below; @@ -115,6 +118,21 @@ comment at the literal says which when it is not the first: the `link` prop where Ink's writes it into the text, and `cells.rip` reads OSC 8 and refuses a hyperlink left open at the end of a row. +Ink's `{fn}` is `Static` around a keyed +`for` here (`static.rip`), and Ink's `style` prop on it is the same +props on `Static`. Ink's `renderToString` answers the static output and +then the live frame, and so does this package's, so the +`renderToString` cases run through `plain` and `styled` unchanged. Ink's +`render` cases read its debug output, which is every static item so far +and then the frame; here that is what the terminal shows, the scrollback +and then the frame (`shown` in `static.rip`), and a case that asks +whether a stale item was written again holds the scrollback still. A +case that reconstructs a terminal of some height replays the bytes +through `test/events/harness.rip`'s terminal. The published build +draws several of these frames a row short, keeps a `Static` that a +layout effect replaced, or writes a removed `Static`'s items again with +every frame, and the comments at those literals say what it draws. + `wrap-text.tsx` calls Ink's `wrapText` function and cannot load against the published build, so `oracle/extra/wrap-text.tsx` asks the same questions through components and `wrap-text.rip` ports that; the one @@ -143,6 +161,7 @@ and `link`, and the hyperlink half is a stated difference. - `width-height`: set max width in percent - `flex-justify-content`: row - align two text nodes with equal space around them - `flex-justify-content`: column - align two text nodes with equal space around them +- `background`: Static background color is inherited by its text and the refusal test, `content-offset`: contentOffsetX/Y - non-finite offsets fall back to zero. @@ -151,7 +170,7 @@ offsets fall back to zero. | Ink test file | cases | ported | left out | |---|---:|---:|---:| -| `components` | 93 | 31 | 62 | +| `components` | 93 | 42 | 51 | | `text` | 57 | 49 | 8 | | `wrap-text` | 17 | 13 | 4 | | `text-width` | 18 | 18 | 0 | @@ -160,7 +179,7 @@ offsets fall back to zero. | `styled-combining-marks` | 9 | 9 | 0 | | `borders` | 52 | 48 | 4 | | `border-backgrounds` | 5 | 5 | 0 | -| `background` | 32 | 25 | 7 | +| `background` | 32 | 26 | 6 | | `overflow` | 44 | 39 | 5 | | `content-offset` | 23 | 23 | 0 | | `clip-wide-background` | 10 | 10 | 0 | @@ -179,14 +198,20 @@ offsets fall back to zero. | `flex-align-items` | 9 | 9 | 0 | | `flex-align-self` | 9 | 9 | 0 | | `flex-justify-content` | 12 | 12 | 0 | -| `render-to-string` | 37 | 23 | 14 | +| `render-to-string` | 37 | 28 | 9 | | `style-update-consistency` | 15 | 15 | 0 | | `reconciler` | 12 | 8 | 4 | -| **total** | **602** | **478** | **124** | +| `static-blank-lines` | 9 | 9 | 0 | +| `static-string-replacement` | 4 | 4 | 0 | +| `static-trailing-layout` | 7 | 7 | 0 | +| `static-runtime-blank-lines` | 8 | 5 | 3 | +| `static-abandoned-render` | 1 | 0 | 1 | +| `issue-973-static-commit` | 1 | 1 | 0 | +| **total** | **632** | **521** | **111** | A case counts once per title Ink registers, loops included. Files -finished by hand after the draft: `absolute-truncation` (written by -hand), `content-offset`, `overflow`, `render-to-string`, +finished by hand after the draft: `absolute-truncation` and `static` +(written by hand), `content-offset`, `overflow`, `render-to-string`, `styled-combining-marks`, `text-width`, `wrap-text`, the hyperlink case of `components`, the comments in `clip-wide-background` and `rendering-regressions`, and every `differs` @@ -197,42 +222,28 @@ and a rerender is one component drawn again. ## Cases left out -**A rerender of a mounted tree that is out of scope for another reason** (15) +**A rerender of a mounted tree that is out of scope for another reason** (6) -- `components`: static output stops accumulating after Static unmounts (#904) — `` -- `components`: separate Ink instances do not clobber each other’s staticNode — `` -- `components`: unmounting a ancestor in concurrent mode does not crash — `` +- `components`: separate Ink instances do not clobber each other’s staticNode — one app is mounted at a time +- `components`: updating in one instance after another instance mounted sets the dirty flag on the correct root — one app is mounted at a time +- `components`: unmounting a ancestor in concurrent mode does not crash — concurrent rendering +- `components`: unmounting a ancestor in screen-reader mode does not replay stale output — screen-reader output - `background`: Box preserves child state when adding a background color — a component with hooks - `background`: Box preserves child state when removing a background color — a component with hooks -- `components`: static padding is not emitted again when there are no new items — `` -- `components`: skip previous output when rendering new static output — `` -- `components`: fullStaticOutput is reset when unmounts so stale items are not replayed — `` -- `components`: unmounting an ancestor of clears staticNode and does not crash the renderer — `` -- `components`: removing a ancestor that is a direct child of the root does not crash — `` -- `components`: updating in one instance after another instance mounted sets the dirty flag on the correct root — `` -- `components`: unmounting a ancestor in screen-reader mode does not replay stale output — screen-reader output -- `components`: remounting via key change emits the new items (nested under ) — `` -- `components`: remounting via key change emits the new items (root-level — removeChildFromContainer) — `` -- `components`: render only new items in static output on final render — `` -**Concurrent rendering: the `- concurrent` twin of a case that is ported** (43) +**Concurrent rendering: the `- concurrent` twin of a case that is ported** (44) -`borders` 4, `background` 4, `overflow` 4, `text` 8, `width-height` 2, `position` 1, `display` 2, `margin` 2, `padding` 2, `gap` 3, `flex-direction` 2, `flex-align-content` 1, `components` 8 +`borders` 4, `background` 4, `overflow` 4, `text` 8, `width-height` 2, `position` 1, `display` 2, `margin` 2, `padding` 2, `gap` 3, `flex-direction` 2, `flex-align-content` 1, `components` 9 -**Concurrent rendering, of a case that is itself left out** (2) +**Concurrent rendering, of a case that is itself left out** (1) - `components`: transform children - concurrent — `` -- `components`: static output - concurrent — `` -**``** (7) +**The final frame written off a terminal at exit: terminal.rip** (3) -- `background`: Static background color is inherited by its text -- `render-to-string`: skip Static content inside a hidden ancestor -- `render-to-string`: render Static component with items -- `render-to-string`: Static preserves its margins and all items -- `render-to-string`: render static-only output has no trailing newline -- `render-to-string`: render static + dynamic output has exactly one newline between parts -- `components`: static output +- `static-runtime-blank-lines`: runtime Static preserves 0 blank rows in non-interactive output +- `static-runtime-blank-lines`: runtime Static preserves 1 blank rows in non-interactive output +- `static-runtime-blank-lines`: runtime Static preserves 2 blank rows in non-interactive output **``** (10) @@ -247,8 +258,9 @@ and a rerender is one component drawn again. - `components`: with null children - `render-to-string`: runs effect cleanup when a transform throws -**Suspense** (4) +**Suspense** (5) +- `static-abandoned-render`: abandoned transition render does not replace committed Static - `reconciler`: Suspense hides nested text while showing its fallback - `reconciler`: resuming Suspense preserves display none - `reconciler`: support suspense @@ -274,7 +286,7 @@ and a rerender is one component drawn again. - `render-to-string`: text outside Text component throws -**Input, the app lifecycle, the alternate screen, or CI output: no frame to compare** (27) +**Input, the app lifecycle, the alternate screen, or CI output: no frame to compare** (26) - `components`: disable raw mode when all input components are unmounted - `components`: do not disable raw mode when swapping components that use useInput @@ -302,7 +314,6 @@ and a rerender is one component drawn again. - `components`: alternate screen - content is rendered between enter and exit - `components`: alternate screen - ignored when isTTY is false - `components`: alternate screen - ignored when isTTY is false even if interactive is true -- `components`: static output is written immediately in non-interactive mode **An empty `render` block has no spelling in Rip** (1) @@ -323,7 +334,7 @@ and a rerender is one component drawn again. - **Input, focus, hooks, the cursor, and the kitty keyboard protocol:** `cursor`, `cursor-exit-position`, `cursor-helpers`, `focus`, `focus-empty-id-regression`, `focus-order`, `focus-strict-mode`, `hooks`, `hooks-use-input`, `hooks-use-input-kitty`, `hooks-use-input-navigation`, `hooks-use-paste`, `input-buffered-ctrl-c`, `input-keypad-enter`, `input-parser`, `kitty-keyboard`, `kitty-negotiation`, `parse-keypress`, `rerender-input`, `use-animation`. - **The app lifecycle, the terminal, and log-update internals:** `alternate-screen-example`, `clear-rerender`, `exit`, `exit-keyboard`, `log-update`, `log-update-blank-growth`, `render`, `render-callback`, `suspend-terminal`, `suspension-exit`, `suspension-handle`, `suspension-input-disable`, `suspension-output`, `suspension-resize`, `terminal-resize`, `write-synchronized`. - **Escape and control sequences embedded in text:** `ansi-newlines`, `ansi-tokenizer`, `c1-rendering`, `colon-colors`, `sanitize-ansi`, `text-controls`. -- **`` and ``:** `component-regressions`, `issue-973-static-commit`, `squash-text-nodes`, `static-abandoned-render`, `static-blank-lines`, `static-runtime-blank-lines`, `static-string-replacement`, `static-trailing-layout`. +- **``:** `component-regressions`, `squash-text-nodes`. - **Error boundaries and Ink's style table:** `error-overview`, `errors`, `styles`. - **measureElement, useBoxMetrics, and Ink's `measureText` function:** `measure-element`, `measure-text`, `use-box-metrics`. - **Screen-reader output:** `screen-reader`. diff --git a/packages/tui/test/ink/static.rip b/packages/tui/test/ink/static.rip new file mode 100644 index 00000000..04b5d0a0 --- /dev/null +++ b/packages/tui/test/ink/static.rip @@ -0,0 +1,620 @@ +# Ink's Static and stdout-hook tests, ported: the trees of +# test/static-blank-lines.tsx, static-string-replacement.tsx, +# static-trailing-layout.tsx, static-runtime-blank-lines.tsx and +# issue-973-static-commit.tsx, the Static cases of components.tsx, +# render-to-string.tsx and background.tsx, and the useStdout / +# useStderr cases of cursor.tsx (test/events/SOURCE.md names them). +# +# Each test keeps Ink's title and draws Ink's tree, prop for prop. A +# `` with a render function is `Static` around a keyed +# `for`. SOURCE.md says where each expected frame comes from and which +# of Ink's cases are left out. +# +# rip test/ink/static.rip + +import { test, eq, ok } from 'rip/testing' +import { mount, print, Static } from 'rip/tui' +import { Box, Text, plain, styled, differs } from './harness.rip' +import { Terminal } from '../events/harness.rip' +import * as cells from './cells.rip' + +console.log "\nStatic" + +# Ink's debug output is every static item written so far and then the +# live frame, which is what a terminal shows: the scrollback, then the +# frame. +shown =! (view) -> cells.plain view.scrollback + view.frame() + +mounted =! (App, options, body) -> + view = mount App, { cols: 100, ...options } + try + body view + finally + view.close() + +# ── static-blank-lines.tsx ──────────────────────────────────────────────────── + +# Published Ink draws "after" where one blank row is expected. +for height in [0, 1, 2, 3] + test "Static preserves #{height} blank rows before dynamic output", -> + App = component + render + Box flexDirection: 'column' + Static + for item in ['blank'] + Box key: item, height: height + Text "after" + Expected = component + render + Box flexDirection: 'column' + Box height: height + Text "after" + expected = plain Expected, 80 + eq expected, cells.plain('\n'.repeat(height) + 'after') + eq plain(App, 80), expected + +# What published Ink draws, by content: for " " it draws "after". +WHITESPACE =! { undefined: 'after', '""': 'after', '" "': '\nafter', '"\\n"': '\n\nafter' } + +for content in [undefined, '', ' ', '\n'] + test "Static preserves whitespace content #{JSON.stringify content}", -> + App = component + render + Box flexDirection: 'column' + Static + for key in ['item'] + Box key: key + Text "#{content ?? ''}" + Text "after" + Expected = component + render + Box flexDirection: 'column' + Text "#{content ?? ''}" + Text "after" + eq plain(App, 80), plain(Expected, 80) + eq plain(App, 80), cells.plain(WHITESPACE[JSON.stringify content]) + +test "empty Static does not insert a blank row", -> + App = component + render + Box flexDirection: 'column' + Static + for item in [] + Text key: item, item + Text "after" + eq plain(App, 80), ["after"] + +# ── static-string-replacement.tsx ───────────────────────────────────────────── + +# Ink's layout effect writes its state before the first frame is read; +# an effect here runs as the component is made, before any frame. + +# Published Ink draws "Old\nLive" and "Old\nNew\nLive": its +# renderToString keeps a Static its layout effect took away. +for replace in [false, true] + test "renderToString reflects a layout-effect Static #{if replace then 'replacement' else 'removal'}", -> + Example = component + updated := false + ~> updated = true + render + Box flexDirection: 'column' + if not updated or replace + Static + for item in [(if updated then 'New' else 'Old')] + Text key: item, item + Text "Live" + eq plain(Example, 80), cells.plain(if replace then 'New\nLive' else 'Live') + +test "layout-effect appends to the same Static retain earlier items", -> + Example = component + items := ['First'] + ~> items = ['First', 'Second'] + render + Static + for item in items + Text key: item, item + eq plain(Example, 80), ["First", "Second"] + +test "replacement Static survives cleanup in static-only output", -> + cleanups = 0 + Example = component + updated := false + ~> + updated = true + -> cleanups += 1 + render + Static + for item in [(if updated then 'New' else 'Old')] + Text key: item, item + # Published Ink draws "Old\nNew". + eq plain(Example, 80), ["New"] + eq cleanups, 1 + +# ── static-trailing-layout.tsx ──────────────────────────────────────────────── + +# Published Ink draws "A" for one trailing row. +for height in [0, 1, 2] + test "renderToString preserves #{height} trailing dynamic rows after Static", -> + App = component + render + Box flexDirection: 'column' + Static + for item in ['A'] + Text key: item, item + Box height: height + eq plain(App, 80), cells.plain "A#{'\n'.repeat height}" + +# Published Ink draws "A" and "A\nB", a row short. +TRAILING =! { '""': '', '"A"': 'A\n', '"A\\nB"': 'A\nB\n' } + +for staticText in ['', 'A', 'A\nB'] + test "blank dynamic rows agree with ordinary layout after Static #{JSON.stringify staticText}", -> + App = component + render + Box flexDirection: 'column' + Static + for item in ['item'] + Text key: item + staticText + Box height: 1 + Expected = component + render + Box flexDirection: 'column' + Text staticText + Box height: 1 + eq plain(App, 80), plain(Expected, 80) + eq plain(App, 80), cells.plain(TRAILING[JSON.stringify staticText]) + +test "hidden dynamic content does not add a separator to Static output", -> + App = component + render + Box flexDirection: 'column' + Static + for item in ['A'] + Text key: item, item + Box display: 'none', height: 1 + Text "Hidden" + eq plain(App, 80), ["A"] + +# ── static-runtime-blank-lines.tsx ──────────────────────────────────────────── + +# Published Ink shows "after" for one blank row. +for height in [0, 1, 2] + test "runtime Static preserves #{height} blank rows in debug output", -> + App = component + render + Box flexDirection: 'column' + Static + for item in ['blank'] + Box key: item, height: height + Text "after" + mounted App, {}, (view) -> + eq shown(view), cells.plain('\n'.repeat(height) + 'after') + +# Every frame here is drawn incrementally; Ink's flag has no counterpart. +# Published Ink shows ["first", "last", "updated", ""]: the blank row +# is lost. +for incremental in [false, true] + test "a blank Static append survives a later live update (incremental: #{incremental})", -> + View = component + @items := ['first'] + @label := 'live' + render + Box flexDirection: 'column' + Static + for item in @items + Box key: item, height: (if item is 'blank' then 1 else undefined) + if item isnt 'blank' + Text item + Text "#{@label}" + mounted View, { rows: 8 }, (view) -> + term = Terminal.new 100, 8 + draw = -> + view.frame() + term.write view.bytes + draw() + view.app.items.value = ['first', 'blank'] + draw() + view.app.items.value = ['first', 'blank', 'last'] + draw() + view.app.label.value = 'updated' + draw() + eq term.text.split('\n').slice(0, 4), ['first', '', 'last', 'updated'] + +# ── issue-973-static-commit.tsx ─────────────────────────────────────────────── + +# Ink's fixture: a live region stands, a static item taller than the +# terminal arrives beside it, then a live-only update follows; the +# erase of that update must not take the static item's last row. +test "#973: static item taller than viewport keeps its last line", -> + rows = 6 + Commit = component + @phase := 'mount' + render + Box flexDirection: 'column' + Static + for item in (if @phase is 'mount' then [] else [("line #{n}" for n in [1..rows]).join '\n']) + Text key: item, item + Box flexDirection: 'column' + Text (if @phase is 'nudge' then 'INPUT BOX' else 'input box') + mounted Commit, { rows }, (view) -> + term = Terminal.new 100, rows + for phase in ['mount', 'live', 'nudge'] + view.app.phase.value = phase + view.frame() + term.write view.bytes + seen = term.text.split('\n').filter (line) -> line.length + ok seen.includes('line 6'), "the last static line must stay visible, got #{JSON.stringify seen}" + ok seen.includes('INPUT BOX'), "the live region must render, got #{JSON.stringify seen}" + ok not term.sent.join('').includes("\x1b[H"), 'the terminal was never homed and erased' + +# ── components.tsx ──────────────────────────────────────────────────────────── + +test "static output", -> + App = component + render + Box + Static paddingBottom: 1 + for letter in ['A', 'B', 'C'] + Text key: letter, letter + Box marginTop: 1 + Text "X" + eq plain(App), ["A", "B", "C", "", "", "X"] + +test "static padding is not emitted again when there are no new items", -> + Test = component + @status := 'Waiting' + @items := ['A'] + render + Box flexDirection: 'column' + Static padding: 1 + for item in @items + Text key: item, item + Text "#{@status}" + # Published Ink draws the padding again with every frame: + # "\n A\n\n\n\nWaiting", "\n A\n\n\n\n\n\nReady" and + # "\n A\n\n\n\n\n\n\n B\n\n\n\nDone". + mounted Test, {}, (view) -> + eq shown(view), cells.plain "\n A\n\nWaiting" + view.app.status.value = 'Ready' + eq shown(view), cells.plain "\n A\n\nReady" + view.app.status.value = 'Done' + view.app.items.value = ['A', 'B'] + eq shown(view), cells.plain "\n A\n\n\n B\n\nDone" + +# Ink's debug write is every item so far; what reaches the terminal for +# a frame is its new items, and the earlier ones are not written again. +test "skip previous output when rendering new static output", -> + Dynamic = component + @items := ['A'] + render + Static + for item in @items + Text key: item, item + mounted Dynamic, {}, (view) -> + view.frame() + eq view.scrollback, "A\n" + view.app.items.value = ['A', 'B'] + view.frame() + eq view.scrollback, "A\nB\n" + ok not view.bytes.includes('A'), "the frame wrote A again: #{JSON.stringify view.bytes}" + +test "static output stops accumulating after Static unmounts (#904)", -> + App = component + @show := true + render + Box + if @show + Static + for item in ['A', 'B'] + Text key: item, item + Text "Dynamic" + mounted App, {}, (view) -> + eq shown(view), ['A', 'B', 'Dynamic'] + view.app.show.value = false + eq view.frame(), 'Dynamic' + written = view.scrollback + for n in [0...10] + view.frame() + eq view.scrollback, written + eq view.frame(), 'Dynamic' + +test "fullStaticOutput is reset when unmounts so stale items are not replayed", -> + App = component + @show := true + @dynamicLabel := 'd1' + render + Box + if @show + Static + for item in ['HISTORY-A', 'HISTORY-B'] + Text key: item, item + Text "#{@dynamicLabel}" + mounted App, {}, (view) -> + eq shown(view), ['HISTORY-A', 'HISTORY-B', 'd1'] + written = view.scrollback + view.app.show.value = false + view.app.dynamicLabel.value = 'd2' + eq view.frame(), 'd2' + eq view.scrollback, written + ok not view.bytes.includes('HISTORY'), "a stale item was written again: #{JSON.stringify view.bytes}" + +# Published Ink writes HISTORY-X again with every frame after the unmount. +test "unmounting an ancestor of clears staticNode and does not crash the renderer", -> + Wrapper = component + render + Box + slot + App = component + @showWrapper := true + @label := 'live-1' + render + Box + if @showWrapper + Wrapper + Static + for item in ['HISTORY-X'] + Text key: item, item + Text "#{@label}" + mounted App, {}, (view) -> + eq shown(view), ['HISTORY-X', 'live-1'] + written = view.scrollback + view.app.showWrapper.value = false + view.app.label.value = 'live-2' + eq view.frame(), 'live-2' + eq view.scrollback, written + view.app.label.value = 'live-3' + eq view.frame(), 'live-3' + eq view.scrollback, written + +# Published Ink writes ROOT-HISTORY again after the unmount. +test "removing a ancestor that is a direct child of the root does not crash", -> + App = component + @showWrapper := true + @label := 'root-1' + render + Box flexDirection: 'column' + if @showWrapper + Box + Static + for item in ['ROOT-HISTORY'] + Text key: item, item + Text "#{@label}" + mounted App, {}, (view) -> + eq shown(view), ['ROOT-HISTORY', 'root-1'] + written = view.scrollback + view.app.showWrapper.value = false + view.app.label.value = 'root-2' + eq view.frame(), 'root-2' + eq view.scrollback, written + +# The items of the first Static stay in the terminal's scrollback, where +# Ink's debug replay forgets them; the rows from the remount on are held. +test "remounting via key change emits the new items (nested under )", -> + App = component + @session := 1 + render + Box + if @session is 1 + Static + for item in ['old-A', 'old-B'] + Text key: item, item + else + Static + for item in ['new-C', 'new-D'] + Text key: item, item + Text "dynamic" + mounted App, {}, (view) -> + eq shown(view), ['old-A', 'old-B', 'dynamic'] + view.app.session.value = 2 + eq shown(view).slice(2), ['new-C', 'new-D', 'dynamic'] + +test "remounting via key change emits the new items (root-level — removeChildFromContainer)", -> + App = component + @session := 1 + render + Box flexDirection: 'column' + if @session is 1 + Static + for item in ['old-A', 'old-B'] + Text key: item, item + else + Static + for item in ['new-C', 'new-D'] + Text key: item, item + mounted App, {}, (view) -> + eq shown(view), ['old-A', 'old-B', ''] + view.app.session.value = 2 + eq shown(view).slice(2), ['new-C', 'new-D', ''] + +test "render only new items in static output on final render", -> + Dynamic = component + @items := [] + render + Static + for item in @items + Text key: item, item + mounted Dynamic, {}, (view) -> + view.frame() + eq view.scrollback, '' + view.app.items.value = ['A'] + view.frame() + eq view.scrollback, "A\n" + view.app.items.value = ['A', 'B'] + view.frame() + eq view.scrollback, "A\nB\n" + ok view.bytes.includes('B') and not view.bytes.includes('A'), "the last frame wrote #{JSON.stringify view.bytes}" + +# Off a terminal, an item is written as plain text with the frame that +# finds it, and the live frame is not drawn there (terminal.rip). +test "static output is written immediately in non-interactive mode", -> + App = component + @items := ['A'] + render + Box + Static + for item in @items + Text key: item, item + Text "Dynamic" + mounted App, {}, (view) -> + view.held.view.interactive = false + view.frame() + ok view.bytes.startsWith("A\n"), JSON.stringify view.bytes + view.app.items.value = ['A', 'B'] + view.frame() + ok view.bytes.startsWith("B\n"), JSON.stringify view.bytes + eq view.scrollback, "A\nB\n" + +# ── render-to-string.tsx ────────────────────────────────────────────────────── + +# Published Ink throws on this tree: a negative count in its output. +test "skip Static content inside a hidden ancestor", -> + App = component + render + Box flexDirection: 'column' + Box display: 'none' + Static + for item in ['Hidden'] + Box key: item, borderStyle: 'single' + Text item + Text "Visible" + eq plain(App, 80), ["Visible"] + +test "render Static component with items", -> + App = component + render + Box flexDirection: 'column' + Static + for item in ['A', 'B', 'C'] + Text key: item, item + Text "Dynamic" + eq plain(App, 80), ["A", "B", "C", "Dynamic"] + +# Published Ink draws "\n A": one item, one column of margin. +test "Static preserves its margins and all items", -> + App = component + render + Static marginTop: 1, marginBottom: 1, marginLeft: 2 + for item in ['A', 'B'] + Text key: item, item + eq plain(App, 80), ["", " A", " B", ""] + +test "render static-only output has no trailing newline", -> + App = component + render + Static + for item in ['A', 'B'] + Text key: item, item + eq plain(App, 80), ["A", "B"] + +test "render static + dynamic output has exactly one newline between parts", -> + App = component + render + Box flexDirection: 'column' + Static + for item in ['A', 'B'] + Text key: item, item + Text "Dynamic" + eq plain(App, 80), ["A", "B", "Dynamic"] + +# ── background.tsx ──────────────────────────────────────────────────────────── + +# Published Ink colors the two padding cells and not the A. +test "Static background color is inherited by its text", -> + App = component + render + Static width: 3, backgroundColor: 'blue' + for item in ['A'] + Text key: item, item + differs styled(App), ['«on blue»A '], ['«on blue»A ', ''], + "the live frame is empty, so nothing follows the item; Ink's root keeps one row for its absolute Static" + +# ── cursor.tsx: useStdout().write and useStderr().write ─────────────────────── + +# `print` writes above the live frame and `print.err` the same on +# stderr; Ink's `useCursor` places the cursor, which a node's `cursor` +# does here. Ink's effect runs after the first frame; an effect here +# runs as the component is made, and `ready` stands for that turn where +# a case reads the frames after it. + +Hook =! (write) -> + component + ~> write() + render + Box focusable: true, autofocus: true, cursor: { x: 2, y: 0 } + Text "Hello" + +test "cursor remains visible after useStdout().write()", -> + mounted Hook(-> print 'from stdout hook'), {}, (view) -> + view.frame() + ok view.bytes.includes('from stdout hook'), JSON.stringify view.bytes + ok view.bytes.lastIndexOf("\x1b[?25h") > view.bytes.lastIndexOf("\x1b[?25l"), "the cursor is left hidden: #{JSON.stringify view.bytes}" + +test "cursor remains visible after useStderr().write()", -> + mounted Hook(-> print.err 'from stderr hook'), {}, (view) -> + view.frame() + eq view.stderr, "from stderr hook\n" + ok view.bytes.lastIndexOf("\x1b[?25h") > view.bytes.lastIndexOf("\x1b[?25l"), "the cursor is left hidden: #{JSON.stringify view.bytes}" + +Debug =! (write) -> + component + @ready := false + ~> write() if @ready + render + Text "Hello" + +test "debug mode: useStdout().write() replays latest frame", -> + mounted Debug(-> print 'from stdout hook'), {}, (view) -> + term = Terminal.new 100, 24 + view.frame() + term.write view.bytes + view.app.ready.value = true + view.frame() + term.write view.bytes + eq term.text, "from stdout hook\nHello" + ok view.bytes.includes('Hello'), 'the frame was drawn again below the line' + +test "debug mode: useStdout().write() does not leak into stderr", -> + mounted Debug(-> print 'from stdout hook'), {}, (view) -> + view.frame() + view.app.ready.value = true + view.frame() + eq view.stderr, '' + +test "debug mode: useStderr().write() replays latest frame without empty writes", -> + mounted Debug(-> print.err 'from stderr hook'), {}, (view) -> + term = Terminal.new 100, 24 + view.frame() + term.write view.bytes + view.app.ready.value = true + view.frame() + term.write view.bytes + eq view.stderr, "from stderr hook\n" + eq term.text, 'Hello' + ok view.bytes.includes('Hello'), 'the frame was drawn again after the clear' + ok not view.bytes.includes('from stderr hook'), 'stderr text reached stdout' + +Rerendered =! (write) -> + component + text := 'Initial' + ~> text = 'Updated' + ~> write() if text is 'Updated' + render + Text text + +test "debug mode: useStdout().write() replays rerendered frame", -> + mounted Rerendered(-> print 'from stdout hook'), {}, (view) -> + term = Terminal.new 100, 24 + view.frame() + term.write view.bytes + eq term.text, "from stdout hook\nUpdated" + ok not view.bytes.includes('Initial'), 'the frame before the write was drawn' + +test "debug mode: useStderr().write() replays rerendered frame", -> + mounted Rerendered(-> print.err 'from stderr hook'), {}, (view) -> + term = Terminal.new 100, 24 + view.frame() + term.write view.bytes + eq view.stderr, "from stderr hook\n" + eq term.text, 'Updated' + ok not view.bytes.includes('Initial') and not view.bytes.includes('from stderr hook'), JSON.stringify view.bytes diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index 362b760f..d74f81bf 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -27,6 +27,26 @@ export Spacer = component render div flexGrow: 1 +# `count` line breaks inside text. +export Newline = component + @count := 1 + render + span "\n".repeat(@count) + +# Items written once above the live frame, into the scrollback. +export Static = component extends div + render + div display: 'none' + slot + +# Text written above the live frame; `print.err` the same on stderr. +export print =! (text) -> +print.err = (text) -> + +# ── Animation ───────────────────────────────────────────────────────────────── + +export clock =! (interval = 100, timers = null) -> { frame: 0, time: 0, delta: 0 } + # ── The mounted app ─────────────────────────────────────────────────────────── cols := 80 @@ -42,6 +62,7 @@ class Viewport get focused: -> shown get keyboard: -> keyboard get selection: -> selected + progress: (value) -> # The terminal's size, whether it is the window the user is in (its # focus reports), and whether its keyboard is enhanced, as reactive @@ -283,6 +304,11 @@ class Mount get ansi: -> @held.view.front?.toString(true) ?? '' get bytes: -> @held.out.sent + # What was written above the frame so far, as it was written, and + # what stderr was sent. + get scrollback: -> @held.view.scrollback + get stderr: -> @held.err.sent + # The cells the last frame owed: those it painted and compared. get damage: -> @held.view.owed @@ -329,7 +355,9 @@ class Mount # what it is sent. With no `rows` it is as tall as the frame. export mount =! (App, options = {}) -> out = { columns: options.cols ?? 80, rows: options.rows ?? Infinity, sent: '', write: (text) -> @sent += text } - Mount.new open(App, options, out, false, null, Clock.new()) + held = open App, options, out, false, null, Clock.new() + held.err = { sent: '', write: (text) -> @sent += text } + Mount.new held # Give the terminal and the process back: the cursor, the listeners, the # `document` slot. `drawn` leaves the last frame in the scrollback; a From 07311633481b9eed182504cc07a6eebd7a8587fe Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 20:34:43 -0700 Subject: [PATCH 03/18] tui: Static, print, the animation clock and progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Static item is laid out once as a root at the terminal's width, painted to rows of its own and written above the live frame through Screen.above — the frame's rows cleared, the rows written, the frame drawn again below, in one write — then hidden, so the live frame never holds it and the mouse origin moves by the rows written. print takes the same road, print.err on stderr. clock(interval) is one timer per interval that runs while a mounted component holds and reads it, on the mount's own clock under mount. screen.progress reports OSC 9;4 with the next frame's write, and Screen.clearProgress answers the clear for the way out. Grid.toString(true) leaves a row's trailing default-style blanks off, as the plain form does, so a static row carries no padding into the scrollback; the six pins that spelled those blanks out are updated. Static's registration keeps its node as a plain value: a cleanup that read the ref cell subscribed the disposing branch effect to it, and the detach's clear built the replacement branch twice. --- packages/tui/paint.rip | 26 ++++-- packages/tui/screen.rip | 97 ++++++++++++++++--- packages/tui/test.rip | 17 ++-- packages/tui/test/ink/static.rip | 4 +- packages/tui/test/text.rip | 2 +- packages/tui/tui.rip | 154 ++++++++++++++++++++++++++----- 6 files changed, 251 insertions(+), 49 deletions(-) diff --git a/packages/tui/paint.rip b/packages/tui/paint.rip index 2d8151df..3a3763f4 100644 --- a/packages/tui/paint.rip +++ b/packages/tui/paint.rip @@ -10,7 +10,7 @@ # A frame paints and compares only its damage: the cells some change may # have recolored, a span of columns on each row. -import { kids } from './layout.rip' +import { kids, layout } from './layout.rip' import { CLUSTER, glyph, crowded, evict, sanitize, Flow, refresh, MODES, A0, A1, B0, B1, DOTS, ROW } from './text.rip' # ── Styles ──────────────────────────────────────────────────────────────────── @@ -408,16 +408,20 @@ export class Grid @wide.fill 1, at + left, at + right # The grid as text: one string per row, trailing blanks trimmed. - # With `ansi`, each run of a style carries its escape sequence, and a - # link is closed on the row it opens on. + # With `ansi`, each run of a style carries its escape sequence, a + # link is closed on the row it opens on, and the blanks left off a + # row's end are those of the default style. toString: (ansi = false) -> lines = [] for row in [0...@rows] line = '' held = 0 url = null - for col in [0...@cols] - at = row * @cols + col + from = row * @cols + stop = @cols + stop -= 1 while ansi and stop > 0 and @ch[from + stop - 1] is SPACE and @style[from + stop - 1] is 0 and @wide[from + stop - 1] is 1 + for col in [0...stop] + at = from + col continue if @wide[at] is 0 if ansi and @style[at] isnt held line += shift held, @style[at] @@ -437,8 +441,16 @@ export class Grid export rowsToString =! (grid, ansi = false) -> grid.toString ansi # Paint `node` alone, as a root laid out at `cols`, onto a grid of its -# own: the rows a `Static` batch writes to the scrollback (screen.rip). -export still =! (node, cols) -> Grid.new cols, 0 +# own, as tall as the node and the margins above and below it: the rows +# a `Static` batch writes to the scrollback (screen.rip). +export still =! (node, cols) -> + layout node, cols + rect = node.rect + s = node.styles + below = s.marginBottom ?? s.marginY ?? s.margin + grid = Grid.new cols, rect.y + rect.h + (if typeof below is 'number' and below > 0 then Math.round below else 0) + paint grid, node + grid # The selection (mouse.rip): the cells `from` to `to` in reading order, # each counted as row × columns + column of tree cells, shown inverse. diff --git a/packages/tui/screen.rip b/packages/tui/screen.rip index 19cea6db..555d4a55 100644 --- a/packages/tui/screen.rip +++ b/packages/tui/screen.rip @@ -14,11 +14,20 @@ import { tend } from './focus.rip' import { failed } from './document.rip' import { layout } from './layout.rip' -import { Grid, paint, diff, overlay } from './paint.rip' +import { Grid, paint, diff, overlay, still, rowsToString } from './paint.rip' PACE =! 8 # the least milliseconds between two frames PASSES =! 32 # the most layouts one frame may take to settle +# Whether every node above `node`, up to `body`, is shown. +showing =! (node, body) -> + loop + node = node.parentNode + return false unless node + return true if node is body + s = node.styles + return false if s.hidden is true or s.display is 'none' + # Lay the document out until a pass moves nothing. A binding that reads # a node's box changes the tree it measured, so one change can take a # pass per reader in a chain; readers that never agree are refused. @@ -44,23 +53,85 @@ export class Screen @origin = 0 # the terminal row the frame's top-left is on: what the cursor probe answers, and lower once a frame scrolls the terminal @selection = { from: -1, to: -1 } # the selected cells (mouse.rip), none while `from` is under zero @after = null # run after every frame: the pointer looks again at what is under it - @statics = Set.new() # the `Static` containers mounted (tui.rip), in the order they arrived + @statics = [] # the `Static` containers mounted (tui.rip), in the order they arrived + @written = WeakSet.new() # the items written above the frame @scrollback = '' # everything written above the frame so far, as it was written + @lead = '' # what the next frame's write begins with: the rows written above the frame + @osc = '' # the progress report the next frame's write carries + @reported = false # whether a progress is on the terminal's indicator - # Write `text` above the live frame. + get cols: -> @out.columns ?? 80 + get rows: -> @out.rows ?? 24 + + # Write `text` above the live frame — a `Static` batch, a `print` line + # — in the frame's own write: the frame's rows are cleared, the text + # is written where they were, and the frame is drawn again below it, + # whole. Console capture (terminal.rip) takes this road for a + # console.log line during a run. With `err`, the clear goes out at + # once and the text to `err`, and the frame follows on the next + # write. Off a terminal the text goes as it is; on the alternate + # screen nothing is written above. above!: (text, err = null) -> + return unless text + @scrollback += text + if @interactive is false + (err ?? @out).write text + return + return if @alt + clear = @unpark() + "\x1b[J" + if err + @out.write clear + err.write text + else + @lead += clear + text + @spare = @front if @front + @front = null + @origin += text.split('\n').length - 1 + @doc.owe false - # Report progress on the terminal's own indicator. - progress!: (value) -> + # Write what the `Static` containers hold that was not written yet: + # each container's new items are laid out together, as a root at the + # terminal's width, painted once, written above the frame, and + # hidden, so the live frame never holds them and a later change to + # them is nobody's. A container under a hidden ancestor waits. + still!: -> + return if @alt + for el in @statics + fresh = (kid for kid in el.childNodes when kid.nodeType is 1 and not @written.has kid) + continue unless fresh.length and showing el, @doc.body + grid = still el, @cols + @above rowsToString(grid, @interactive isnt false) + '\n' if grid.rows + for kid in fresh + @written.add kid + kid.set 'display', 'none' + return - # The bytes that clear a reported progress, once. - clearProgress: -> '' + # Progress on the terminal's own indicator (OSC 9;4, ConEmu's + # sequence, which Windows Terminal, Ghostty, kitty and iTerm2 honor): + # a number from 0 to 1, 'error', 'indeterminate', or null to clear, + # sent with the next frame's write. Nothing off a terminal. + progress!: (value) -> + state = if value is null then '0' + else if value is 'error' then '2' + else if value is 'indeterminate' then '3' + else if typeof value is 'number' and value >= 0 and value <= 1 then "1;#{Math.round value * 100}" + else throw Error.new "rip/tui: progress: #{JSON.stringify value} is not a number from 0 to 1, 'error', 'indeterminate' or null" + return if @interactive is false or (state is '0' and not @reported) + @reported = state isnt '0' + @osc = "\x1b]9;4;#{state}\x1b\\" + @doc.owe false - get cols: -> @out.columns ?? 80 - get rows: -> @out.rows ?? 24 + # The bytes that clear the indicator, once a progress was reported: + # for every way out (tui.rip's close). + clearProgress: -> + return '' unless @reported + @reported = false + @osc = '' + "\x1b]9;4;0\x1b\\" # Draw what is owed, now. frame!: -> + @still() @booked = false @last = performance.now() failed @doc @@ -90,13 +161,15 @@ export class Screen damage.shape @front, false @after?() spot = @place() - return unless bytes or spot?.x isnt @parked?.x or spot?.y isnt @parked?.y - bytes = @unpark() + bytes + return unless bytes or @lead or @osc or spot?.x isnt @parked?.x or spot?.y isnt @parked?.y + bytes = @lead + @unpark() + bytes + @lead = '' @parked = spot if spot bytes += "\x1b[#{spot.y}B" if spot.y bytes += "\x1b[#{spot.x + 1}G\x1b[?25h" - @out.write "\x1b[?2026h#{bytes}\x1b[?2026l" + @out.write "\x1b[?2026h#{bytes}#{@osc}\x1b[?2026l" + @osc = '' # The cell the focused node's cursor lands on, on the grid the terminal # shows, or null: its rounded corner plus what it declares, moved by diff --git a/packages/tui/test.rip b/packages/tui/test.rip index 338e1e69..7f456801 100644 --- a/packages/tui/test.rip +++ b/packages/tui/test.rip @@ -595,7 +595,7 @@ test "a wide character cut by a clip edge is a space in its style", -> Text backgroundColor: 'blue' "日本語" eq renderToString(Cut, cols: 6), "日" - eq renderToString(Cut, cols: 6, ansi: true), "\x1b[44m日 \x1b[0m " + eq renderToString(Cut, cols: 6, ansi: true), "\x1b[44m日 \x1b[0m" test "content offsets shift a box's children, and owe a paint but no layout", -> Scrolled = component @@ -672,7 +672,7 @@ test "text's own background fills its glyph cells and no more", -> Box width: 8 Text backgroundColor: 'yellow' "ab" - eq renderToString(Marked, cols: 8, ansi: true), "\x1b[43mab\x1b[0m " + eq renderToString(Marked, cols: 8, ansi: true), "\x1b[43mab\x1b[0m" test "'default' is the terminal's own color: it stands against an ancestor's color and the background beneath", -> Bare = component @@ -710,7 +710,7 @@ test "a link wraps its words in OSC 8, nested text inherits it, and a plain fram "docs" Text link: 'https://example.com/other' "!" - eq renderToString(Linked, cols: 16, ansi: true), "see #{OPEN 'https://example.com/a?b=1;c'}the \x1b[1mdocs\x1b[0m#{OPEN 'https://example.com/other'}!#{SHUT} " + eq renderToString(Linked, cols: 16, ansi: true), "see #{OPEN 'https://example.com/a?b=1;c'}the \x1b[1mdocs\x1b[0m#{OPEN 'https://example.com/other'}!#{SHUT}" eq renderToString(Linked, cols: 16), "see the docs!" # Over a box's background the words keep their link and the box does not take it. Panel = component @@ -732,7 +732,7 @@ test "a link that wraps is closed on every row and opened again on the next, its "abcdefgh" eq renderToString(Long, cols: 5, ansi: true).split('\n'), [ "#{OPEN 'https://example.com'}ab cd#{SHUT}" - "\x1b[31m#{OPEN 'https://example.com'} ef\x1b[0m#{SHUT} " + "\x1b[31m#{OPEN 'https://example.com'} ef#{SHUT}\x1b[0m" "#{OPEN 'https://example.com'}abcd…#{SHUT}" ] @@ -852,7 +852,7 @@ test "a border that straddles a clip edge is cut there, column by column", -> Box width: 4, height: 3, marginLeft: 3, borderStyle: 'single', flexShrink: 0 Text "ab" rows = renderToString(Straddle, cols: 10, ansi: true).split '\n' - eq rows, [" ┌── ", " │ab ", " └── "] + eq rows, [" ┌──", " │ab", " └──"] Under = component render Box width: 6, height: 2, overflowY: 'hidden' @@ -2080,7 +2080,7 @@ test "an item under a hidden ancestor waits, and is written once shown", -> test "renderToString is the rows written above and then the frame, plain or with escape sequences", -> eq renderToString(Journal, cols: 20, props: { done: ['a', 'b'] }), "a\nb\nlive" - eq renderToString(Journal, cols: 20, props: { done: ['a', 'b'], live: '' }), "a\nb\n" + eq renderToString(Journal, cols: 20, props: { done: ['a', 'b'], live: '' }), "a\nb", 'an empty text is no row' Only = component render Static @@ -2236,12 +2236,15 @@ test "the frame catches up when the timer is late, and the interval defaults to test "a clock never runs where the output is not interactive", -> fake = timers() Quiet = component + @shown := false tick = clock 70, fake render - Text "#{tick.frame}" + Text (if @shown then "#{tick.frame}" else 'still') view = mount Quiet, cols: 20 try view.held.view.interactive = false + eq view.frame(), 'still' + view.app.shown.value = true eq view.frame(), '0' eq fake.afters, 0 finally diff --git a/packages/tui/test/ink/static.rip b/packages/tui/test/ink/static.rip index 04b5d0a0..58866fbc 100644 --- a/packages/tui/test/ink/static.rip +++ b/packages/tui/test/ink/static.rip @@ -23,7 +23,9 @@ console.log "\nStatic" # Ink's debug output is every static item written so far and then the # live frame, which is what a terminal shows: the scrollback, then the # frame. -shown =! (view) -> cells.plain view.scrollback + view.frame() +shown =! (view) -> + frame = view.frame() + cells.plain view.scrollback + frame mounted =! (App, options, body) -> view = mount App, { cols: 100, ...options } diff --git a/packages/tui/test/text.rip b/packages/tui/test/text.rip index 39a1c06d..c45a520f 100644 --- a/packages/tui/test/text.rip +++ b/packages/tui/test/text.rip @@ -203,7 +203,7 @@ test "on the grid a baseless mark draws nothing: no cell is its own, and none is grid.write 0, 0, "#{ACUTE}Xy", red eq Array.from(grid.wide), [1, 1, 1, 1] eq grid.toString(), 'Xy' - eq grid.toString(true), "#{ESC}[31mXy#{ESC}[0m " + eq grid.toString(true), "#{ESC}[31mXy#{ESC}[0m" test "a cluster is as wide as string-width 8 says: a spacing mark takes a cell, a mark above takes none", -> bengali = [0x9AC, 0x9BE, 0x982, 0x9B2, 0x9BE].map(at).join '' diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index d74f81bf..636ccdb6 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -3,7 +3,8 @@ # into events; `mount` does the same off any terminal, draws when a test # asks, and takes the test's keys by the same road. -import { install, failed, Event } from './document.rip' +import { __effect } from '../../src/runtime/reactive.js' +import { install, failed, watch, Event } from './document.rip' import { tend, take, advance } from './focus.rip' import { Parser } from './input.rip' import { Screen } from './screen.rip' @@ -33,19 +34,110 @@ export Newline = component render span "\n".repeat(@count) -# Items written once above the live frame, into the scrollback. +# Items written once above the live frame, into the scrollback, and +# never painted in the frame: `Static` around a keyed `for`. Each item +# is painted when it first appears, at the terminal's width, with the +# items that appear in the same frame, in tree order; a change to it or +# its removal afterwards changes nothing written. The box's own styles +# — padding, margins, a background — go around each batch. On the +# alternate screen it writes nothing (screen.rip). export Static = component extends div + el := null + # The node and the screen are taken as plain values: a cleanup that + # read the `el` cell would subscribe whatever disposes this component + # to it, and the detach that clears the cell would run that again. + ~> + return unless el and live + node = el + view = live.view + view.statics.push node + -> view.statics = view.statics.filter (other) -> other isnt node render - div display: 'none' + div ref: el, display: 'none' slot -# Text written above the live frame; `print.err` the same on stderr. -export print =! (text) -> -print.err = (text) -> +# Text written above the live frame, its line ended, by the road a +# `Static` item takes (`Screen.above`); `print.err` writes it to stderr, +# with the frame cleared on stdout first. With no app mounted the text +# goes to the stream as it is. +export print =! (text, err = null) -> + text = String text + text += '\n' unless text.endsWith '\n' + return (err ?? process.stdout).write text unless live + live.view.above text, err +print.err = (text) -> print text, live?.out.err ?? process.stderr # ── Animation ───────────────────────────────────────────────────────────────── -export clock =! (interval = 100, timers = null) -> { frame: 0, time: 0, delta: 0 } +# The timers a clock runs on outside a test: the process's own, which +# never keep it alive by themselves. +TIMERS =! { now: (-> performance.now()), after: (ms, fn) -> (timer = setTimeout fn, ms; timer.unref?(); -> clearTimeout timer) } + +# A clock: `frame`, `time` and `delta` as reactive reads, moved by one +# timer for its interval. The timer runs only while a component that +# holds the clock is mounted and something has read it — on the mount's +# own clock under `mount`, so `view.tick` moves it — and never off a +# terminal. A component holds the clock it makes in its body +# (`tick = clock 80`) and lets go when it unmounts, which stops the +# timer once no holder is left; a clock made outside any component is +# held for good, and its timer stops when the app closes. +class Tick + constructor: (@interval) -> + @cell = watch { frame: 0, time: 0, delta: 0 } + @holders = 0 + @cancel = null # the running timer's cancel, while it runs + @timers = null # a timer source given by hand, a test's + @source = null + @began = @last = 0 + + get frame: -> @read().frame + get time: -> @read().time + get delta: -> @read().delta + + read: -> + @start() if not @cancel and @holders and live and live.view.interactive isnt false + @cell.read() + + start!: -> + @source = @timers ?? live.clock ?? TIMERS + @began = @last = @source.now() + @cancel = @source.after @interval, => @beat() + + # The timer is set again before the cells are written, so a reader + # that runs again on the write finds it running. + beat!: -> + @cancel = null + unless @holders and live + @stop() + return + @cancel = @source.after @interval, => @beat() + now = @source.now() + @cell.write Object.freeze { frame: Math.floor((now - @began) / @interval), time: now - @began, delta: now - @last } + @last = now + + stop!: -> + @cancel?() + @cancel = null + @cell.write { frame: 0, time: 0, delta: 0 } + + release!: -> + @holders -= 1 + @stop() unless @holders + +clocks =! Map.new() + +# The clock for `interval` milliseconds (100 by default, and where the +# interval is no number); `timers` is a `{ now, after }` of a test's +# own to run it on. +export clock =! (interval = 100, timers = null) -> + interval = if Number.isFinite(interval) then Math.max(1, interval) else 100 + tick = clocks.get interval + clocks.set interval, tick = Tick.new interval unless tick + tick.timers = timers if timers + __effect -> + tick.holders += 1 + -> tick.release() + tick # ── The mounted app ─────────────────────────────────────────────────────────── @@ -62,7 +154,9 @@ class Viewport get focused: -> shown get keyboard: -> keyboard get selection: -> selected - progress: (value) -> + # Progress on the terminal's own indicator: 0 to 1, 'error', + # 'indeterminate', or null to clear (screen.rip). + progress: (value) -> live?.view.progress value # The terminal's size, whether it is the window the user is in (its # focus reports), and whether its keyboard is enhanced, as reactive @@ -200,22 +294,31 @@ act! =! (held, event) -> else if event.key is 'c' and event.ctrlKey and not (event.altKey or event.metaKey) quit() -# A clock a test moves by hand, for the parser's waits. +# A clock a test moves by hand, for the parser's waits and the +# animation clocks: `tick` runs what falls due in order, the time +# standing at each as it runs. class Clock constructor: -> @time = 0 @due = [] + now: -> @time + after: (ms, fn) -> entry = { at: @time + ms, fn } @due.push entry => @due = @due.filter (other) -> other isnt entry tick!: (ms) -> - @time += ms - ready = @due.filter((entry) => entry.at <= @time).sort (a, b) -> a.at - b.at - @due = @due.filter (entry) => entry.at > @time - entry.fn() for entry in ready + stop = @time + ms + loop + next = null + next = entry for entry in @due when entry.at <= stop and (not next or entry.at < next.at) + break unless next + @due = @due.filter (entry) -> entry isnt next + @time = next.at + next.fn() + @time = stop # Put `App` on a terminal document of its own, to be drawn to `out`. # The document is a global of the process, so one app is mounted at a @@ -307,7 +410,7 @@ class Mount # What was written above the frame so far, as it was written, and # what stderr was sent. get scrollback: -> @held.view.scrollback - get stderr: -> @held.err.sent + get stderr: -> @held.out.err.sent # The cells the last frame owed: those it painted and compared. get damage: -> @held.view.owed @@ -352,12 +455,12 @@ class Mount close!: -> close false if live is @held # Mount `App` once on a terminal of `cols` by `rows` that only keeps -# what it is sent. With no `rows` it is as tall as the frame. +# what it is sent, with a stderr of the same kind. With no `rows` it is +# as tall as the frame. export mount =! (App, options = {}) -> out = { columns: options.cols ?? 80, rows: options.rows ?? Infinity, sent: '', write: (text) -> @sent += text } - held = open App, options, out, false, null, Clock.new() - held.err = { sent: '', write: (text) -> @sent += text } - Mount.new held + out.err = { sent: '', write: (text) -> @sent += text } + Mount.new open(App, options, out, false, null, Clock.new()) # Give the terminal and the process back: the cursor, the listeners, the # `document` slot. `drawn` leaves the last frame in the scrollback; a @@ -404,12 +507,21 @@ export quit =! (result) -> queueMicrotask -> close true, null, result if live is held -# The frame `App` draws in `cols` columns, as text — plain by default, -# with escape sequences when `ansi` is set: mount, one frame, close. +# Text with its escape sequences taken out, and each row's trailing +# blanks with them. +SGR =! /\x1b\[[0-9;]*m|\x1b\]8;;[^\x1b]*\x1b\\/g +bare =! (text) -> (line.replace(SGR, '').trimEnd() for line in text.split '\n').join '\n' + +# What `App` draws in `cols` columns, as text — plain by default, with +# escape sequences when `ansi` is set: mount, one frame, close. The +# rows its `Static` items wrote come first, then the frame. export renderToString =! (App, options = {}) -> view = mount App, options try text = view.frame() - if options.ansi then view.ansi else text + above = view.scrollback + above = above.slice 0, -1 if above.endsWith('\n') and not view.held.view.front.rows + above = bare above unless options.ansi + above + (if options.ansi then view.ansi else text) finally view.close() From c3e80712db7d26697afe066c7511b34a80e48616 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 20:38:26 -0700 Subject: [PATCH 04/18] =?UTF-8?q?tui:=20terminal.rip=20=E2=80=94=20setup?= =?UTF-8?q?=20and=20teardown=20for=20every=20way=20out,=20signals,=20suspe?= =?UTF-8?q?nd,=20the=20alternate=20screen,=20non-TTY=20output,=20colors,?= =?UTF-8?q?=20the=20console?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit terminal.rip owns raw mode, the modes, the probes, the cursor, the alternate screen, the signal, crash and exit handlers, and the console capture, as one setup / teardown pair; tui.rip's listen, ask, answer and close are thin over it, and `suspend` is exported. screen.rip gains the `alt` flag and `leave` moves nowhere under it; paint.rip emits colors at the depth `run` read, downsampling 24-bit colors to the 256 and the 16. Rows of the contract corrected while building, each with its reason: - a log while suspended is the console's own, not relayed: teardown restores the console when the terminal is handed over - the frame is cleared before warn and error too: the clear precedes the line whatever stream it goes to - at 16 colors full-intensity red is bright red and full blue bright blue (91, 104): xterm's palette puts the full-intensity primaries at 8 to 15 - the CI=false child is held to its frame count, not to contiguous text: a diffed frame writes only the changed cell - a stderr stack need not name the script: what the runtime prints of a stack is the runtime's - an unhandled rejection may let `done`'s continuation print before the exit: the runtime's microtask timing - a listener hears Ctrl-Z before its default action, as it hears Ctrl-C - a failure leaves whatever reached the screen: the fuzz demands the last frame only of the ways that draw it --- packages/tui/paint.rip | 61 +++++- packages/tui/screen.rip | 3 +- packages/tui/terminal.rip | 314 ++++++++++++++++++++++++++- packages/tui/test.rip | 3 +- packages/tui/test/terminal.rip | 77 ++++--- packages/tui/test/terminal/child.rip | 2 +- packages/tui/tui.rip | 172 ++++++--------- 7 files changed, 475 insertions(+), 157 deletions(-) diff --git a/packages/tui/paint.rip b/packages/tui/paint.rip index 21847a97..4b37d004 100644 --- a/packages/tui/paint.rip +++ b/packages/tui/paint.rip @@ -44,6 +44,58 @@ tint =! (color, base) -> return (if m[1] > 255 then null else "#{base + 8};5;#{Number m[1]}") null +# The depth colors are sent at, 0 to 3 — none, the 16 named, the 256, +# 24-bit — what `run` reads of the terminal once (terminal.rip). Below +# full depth a 24-bit color is sent as the nearest of the 256: the +# 6×6×6 cube, or the 24 grays for a gray; at 16 one of the 256 is sent +# as the nearest named one, bright where it is at full intensity +# (xterm's palette). At none, no style sends anything. +depth = 3 + +cube =! (r, g, b) -> + if r is g and g is b + return 16 if r < 8 + return 231 if r > 248 + return 232 + Math.round((r - 8) / 247 * 24) + 16 + 36 * Math.round(r / 255 * 5) + 6 * Math.round(g / 255 * 5) + Math.round(b / 255 * 5) + +named =! (n) -> + return n if n < 8 + return n + 52 if n < 16 + if n >= 232 + r = g = b = ((n - 232) * 10 + 8) / 255 + else + r = Math.floor((n - 16) / 36) / 5 + g = Math.floor((n - 16) % 36 / 6) / 5 + b = (n - 16) % 6 / 5 + bright = Math.max(r, g, b) * 2 + return 0 if bright is 0 + code = (Math.round(b) << 2) | (Math.round(g) << 1) | Math.round(r) + if bright is 2 then code + 60 else code + +# A color's parameters at the depth in force. +lower =! (sgr, base) -> + return sgr if depth is 3 + sgr = "#{base + 8};5;#{cube +m[1], +m[2], +m[3]}" if m = /^\d8;2;(\d+);(\d+);(\d+)$/.exec sgr + return sgr if depth is 2 + if m = /^\d8;5;(\d+)$/.exec sgr then String(base + named(+m[1])) else sgr + +# A style's escape parameters: its switches, then its colors. +dress =! (fg, bg, flags) -> + return '' unless depth + parts = [] + for code, n in CODES + parts.push code if flags & (1 << n) + for [color, base] in [[fg, 30], [bg, 40]] when color? + parts.push lower tint(color, base), base + parts.join ';' + +# Set the depth: every style interned so far is dressed again. +export palette! =! (n) -> + return if n is depth + depth = n + record.sgr = dress record.fg, record.bg, record.flags for record in records + LIMIT =! 65536 # style ids a cell's sixteen bits can hold SWEEP =! 32768 # styles held before those no cell uses are let go @@ -76,15 +128,10 @@ export intern =! (fg, bg, flags, link = null) -> key = if link then "#{flags}|#{link}" else flags id = held.get key return id if id? - parts = [] - for code, n in CODES - parts.push code if flags & (1 << n) for [color, base] in [[fg, 30], [bg, 40]] when color? - sgr = tint color, base - throw Error.new "rip/tui: #{JSON.stringify color} is not a color — write #{COLORS}" unless sgr - parts.push sgr + throw Error.new "rip/tui: #{JSON.stringify color} is not a color — write #{COLORS}" unless tint color, base throw Error.new "rip/tui: more than #{LIMIT} distinct styles are in use — a cell holds a style id in sixteen bits" if records.length >= LIMIT - records.push { fg, bg, flags, link, sgr: parts.join(';'), key } + records.push { fg, bg, flags, link, sgr: dress(fg, bg, flags), key } held.set key, records.length - 1 records.length - 1 diff --git a/packages/tui/screen.rip b/packages/tui/screen.rip index 00fc94d8..8d135afb 100644 --- a/packages/tui/screen.rip +++ b/packages/tui/screen.rip @@ -42,6 +42,7 @@ export class Screen @owed = 0 # the cells the last frame owed @parked = null # where the cursor is shown, `{ x, y }` on the grid, or null while it is hidden @origin = 0 # the terminal row the frame's top-left is on: what the cursor probe answers, and lower once a frame scrolls the terminal + @alt = false # the alternate screen: the frame's top-left is the screen's, and `leave` moves nowhere @selection = { from: -1, to: -1 } # the selected cells (mouse.rip), none while `from` is under zero @after = null # run after every frame: the pointer looks again at what is under it @@ -146,4 +147,4 @@ export class Screen try @frame() if @doc.stale finally - @out.write @unpark() + '\n'.repeat(@front?.rows ?? 0) + "\x1b[?25h" + @out.write @unpark() + '\n'.repeat(if @alt then 0 else @front?.rows ?? 0) + "\x1b[?25h" diff --git a/packages/tui/terminal.rip b/packages/tui/terminal.rip index 88cc784e..fc7d24f6 100644 --- a/packages/tui/terminal.rip +++ b/packages/tui/terminal.rip @@ -1,13 +1,307 @@ # The terminal, for the app's life: what `run` asks of it and gives # back — raw mode, the modes, the probes, the cursor, the alternate # screen, the signals, the console — as one `setup` / `teardown` pair -# that every way out shares. - -export setup! =! (held) -> throw Error.new 'rip/tui: the terminal lifecycle is not built' -export probe! =! (held) -> throw Error.new 'rip/tui: the terminal lifecycle is not built' -export answer =! (held, event) -> throw Error.new 'rip/tui: the terminal lifecycle is not built' -export teardown =! (held, drawn) -> throw Error.new 'rip/tui: the terminal lifecycle is not built' -export handover =! (held, fn) -> throw Error.new 'rip/tui: the terminal lifecycle is not built' -export stop! =! (held) -> throw Error.new 'rip/tui: the terminal lifecycle is not built' -export interactive =! (out) -> true -export colors =! (out) -> 3 +# that every way out shares: `quit`, Ctrl-C, a listener that throws, a +# signal, a crash, `process.exit`, a loop that drains, Ctrl-Z and +# `suspend`. Each takes every step whatever the others do, and each is +# taken once until the other. The host owns the terminal for the app's +# life: there is no share of raw mode to count (PLAN §8). + +import { format } from 'node:util' +import { diff } from './paint.rip' + +HIDE =! "\x1b[?25l" +SHOW =! "\x1b[?25h" +ASK =! "\x1b[?2004h\x1b[?1004h" # bracketed paste, focus reports (xterm ctlseqs) +WITHDRAW =! "\x1b[?1004l\x1b[?2004l" +PROBE =! "\x1b[?6n" # DECXCPR: which row the frame's top-left is on +QUERY =! "\x1b[?u\x1b[c" # kitty's "is the protocol here", then primary device attributes +PUSH =! "\x1b[>1u" # kitty: push the disambiguation flag +POP =! "\x1b[ + moves = if mouse is 'all' then 1003 else 1002 + if set then "\x1b[?#{moves}h\x1b[?1006h" else "\x1b[?1006l\x1b[?#{moves}l" + +# Whether frames go to a terminal someone is watching: a stdout that is +# one, outside CI (`CI` set to anything but '', '0' or 'false'). +export interactive =! (out) -> + ci = process.env.CI + out?.isTTY is true and not (ci? and ci isnt '' and ci isnt '0' and ci isnt 'false') + +# The depth `out` is drawn at, 0 to 3 — no color, the 16 named, the +# 256, 24-bit — read once at `run`: NO_COLOR is none; FORCE_COLOR is +# its digit, or 16 colors for any other value; else COLORTERM's +# truecolor is 24-bit, TERM's 256color is 256, a dumb terminal or a +# stdout nobody is watching has none, and any other terminal has 16. +export depth =! (out) -> + env = process.env + return 0 if env.NO_COLOR? + return (if /^[0-3]$/.test env.FORCE_COLOR then +env.FORCE_COLOR else 1) if env.FORCE_COLOR? + return 0 unless interactive(out) and env.TERM isnt 'dumb' + return 3 if /truecolor|24bit/i.test env.COLORTERM ?? '' + return 2 if /256color/i.test env.TERM ?? '' + 1 + +# A stream that keeps nothing: where frames go while the terminal is +# another's, or is no terminal, so the screen sees the size and writes +# nothing. +export class Quiet + constructor: (@out) -> + get columns: -> @out.columns + get rows: -> @out.rows + write: -> true + +# ── Setup and teardown ──────────────────────────────────────────────────────── + +# Take the terminal: the cursor hidden; stdin raw and read, with the +# paste and focus modes and the mouse's asked for, and the flag pushed +# again where the keyboard was decided; the alternate screen entered; +# the console captured; the signals, the crash and the exit handled. +# On a stdout that is no terminal, only the handlers. +export setup! =! (held) -> + return if held.up + held.up = true + { out, stdin } = held + if held.tty + out.write HIDE + if stdin?.isTTY and typeof stdin.setRawMode is 'function' + stdin.setRawMode true + held.raw = true + stdin.ref?() + stdin.resume() + stdin.on 'data', held.read + held.pushed = held.enhanced + out.write ASK + (if held.mouse then track(held.mouse, true) else '') + (if held.pushed then PUSH else '') + if held.alt + out.write ENTER + held.entered = true + capture held if held.console + held.handlers ?= handlers held + process.on name, fn for name, fn of held.handlers + +# Give the terminal back, every step taken whatever the others do: the +# flag popped if it was pushed; the mouse, paste and focus modes +# withdrawn; the last frame drawn and left in the scrollback with the +# cursor shown below it (`drawn`), or the cursor shown where it is; the +# alternate screen left; the parser flushed; stdin cooked, paused and +# let go; the handlers off the process; the console given back and the +# lines it kept replayed. On a stdout that is no terminal, `drawn` +# writes the last frame once, as text. Answers the first error a step +# raised, or null. +down =! (held, drawn) -> + return null unless held.up + held.up = false + error = null + step = (work) -> + try + work() + catch caught + error ?= caught + { out, stdin, view } = held + if held.tty + step -> held.pointer?.clear() + if held.raw + step -> out.write (if held.pushed then POP else '') + (if held.mouse then track(held.mouse, false) else '') + WITHDRAW + held.pushed = false + step -> if drawn then view.leave() else out.write view.unpark() + SHOW + if held.entered + held.entered = false + step -> out.write LEAVE + step -> held.parser.flush() + if held.raw + held.raw = false + step -> stdin.off 'data', held.read + step -> stdin.setRawMode false + step -> stdin.pause() + step -> stdin.unref?() + else if drawn + step -> view.leave() + step -> out.write view.front.toString(held.depth > 0) + '\n' if view.front + process.off name, fn for name, fn of held.handlers + step -> release held + error + +# The way out: the terminal given back, and a continue nobody waits +# for any more forgotten. +export teardown =! (held, drawn) -> + error = down held, drawn + process.off 'SIGCONT', held.wake if held.wake + held.wake = null + error + +# ── Signals, the crash, the exit ────────────────────────────────────────────── + +# A signal gives the terminal back and exits with 128 plus its number; +# an uncaught error or an unhandled rejection gives it back and hands +# the error to whoever else handles it — the runtime, which prints it +# with its frames remapped — or prints it and exits 1 itself; the loop +# draining closes the app as `quit` would; a `process.exit` with the +# app live gives the terminal back on the way. +handlers =! (held) -> + gone = (code) -> + held.close true + process.exit code + crash = (name, error) -> + held.close true + return if process.emit name, error + console.error error + process.exit 1 + { + SIGINT: -> gone 130 + SIGTERM: -> gone 143 + SIGHUP: -> gone 129 + uncaughtException: (error) -> crash 'uncaughtException', error + unhandledRejection: (error) -> crash 'unhandledRejection', error + beforeExit: -> held.close true + exit: -> held.close true + } + +# ── The probes and their answers ────────────────────────────────────────────── + +# Ask the terminal where its cursor is, from the frame's top-left, where +# the frame sits: unknown until the answer, and taken to be the bottom +# meanwhile. +export ask! =! (held) -> + held.asked = true + held.view.origin = Infinity + held.out.write held.view.unpark() + PROBE + +# The questions whose answers arrive as input, asked once the app +# stands — a constructor that throws leaves no answer for the shell — +# and again on every resume: with the mouse, where the cursor is, on +# the primary screen; with the enhanced keyboard, whether the terminal +# speaks kitty's protocol, and its attributes, which every terminal +# answers, until one of the two has decided it. +export probe! =! (held) -> + return unless held.raw + ask held if held.mouse and not held.alt + held.out.write QUERY if held.probing + +# The terminal's answers. The cursor's row — an answer to the package's +# own probe, marked as DECXCPR marks it, and only while one is +# outstanding — is where the frame's top-left was when it was asked, and +# lower if a frame has scrolled the terminal since. The keyboard is +# decided by whichever of the two queries is answered first: kitty's own +# reply says the protocol is there, and the flag is pushed; the +# attributes reply first says it is not — tmux answers only that one — +# and nothing is pushed, whatever comes later. True when the keyboard +# was just decided enhanced. +export reply =! (held, event) -> + switch event.kind + when 'cursor' + return false unless event.marked and held.asked + held.asked = false + held.view.origin = Math.min held.view.origin, event.y + when 'keyboard', 'attributes' + return false unless held.probing + held.probing = false + return false unless event.kind is 'keyboard' + held.enhanced = true + if held.raw + held.out.write PUSH + held.pushed = true + return true + false + +# ── Suspend and resume ──────────────────────────────────────────────────────── + +# While the terminal is another's, frames go nowhere: a resize or a +# change books one as ever, and the resume draws whole. +pause! =! (held) -> + held.suspended = true + held.view.out = Quiet.new held.out if held.tty + +# Take the terminal again — unless the app was closed meanwhile — ask +# the questions again, forget the press whose release was never seen +# and the bytes held mid-sequence, and draw whole at whatever size the +# terminal has now. +resume! =! (held) -> + held.suspended = false + process.off 'SIGCONT', held.wake if held.wake + held.wake = null + return if held.over + held.view.out = held.out if held.tty + setup held + probe held + held.pointer?.reset() + held.parser.reset() + held.redraw() + +# Hand the terminal to `fn` and take it back once `fn` settles: the +# road Ctrl-Z takes, without the signal. A step that fails on the way +# out is thrown before `fn` runs, with the terminal taken again so a +# later hand-over can try. On a stdout that is no terminal `fn` runs +# and nothing changes hands. Refused while one is under way. +export handover =! (held, fn) -> + throw Error.new 'rip/tui: the terminal is already suspended — resume before suspending again' if held.suspended + if held.tty and (error = down held, true) + setup held + throw error + pause held + try + fn! + finally + resume held + +# Ctrl-Z: give the terminal back, stop the process as the shell would +# have, and take the terminal again on SIGCONT. Nothing on a stdout +# that is no terminal, on Windows, or while a hand-over is under way. +export stop! =! (held) -> + return unless held.tty and process.platform isnt 'win32' and not held.suspended + down held, true + pause held + held.wake = -> resume held + process.once 'SIGCONT', held.wake + process.kill process.pid, 'SIGTSTP' + +# ── The console ─────────────────────────────────────────────────────────────── + +LEVELS =! { log: 'out', info: 'out', debug: 'out', warn: 'err', error: 'err' } + +# The console's methods bypass `process.stdout.write`, so they are +# replaced for the run and given back at teardown, when a suspend +# hands the terminal over as well. A line goes to the stream it always +# went to: inline, the frame is cleared first and drawn again below the +# line, so it scrolls into the scrollback above the app; on the +# alternate screen it is kept, and replayed once the screen is left. +capture! =! (held) -> + held.was = {} + for name, stream of LEVELS + held.was[name] = console[name] + console[name] = ((stream) -> (...args) -> relay held, held[stream], format(...args) + '\n')(stream) + +release! =! (held) -> + return unless held.was + console[name] = fn for name, fn of held.was + held.was = null + stream.write text for [stream, text] in held.logs + held.logs = [] + +relay! =! (held, stream, text) -> + if held.alt + held.logs.push [stream, text] + return + view = held.view + spot = view.parked + held.out.write view.unpark() + "\x1b[J" + stream.write text + # With no frame to draw again, a whole one is booked: it lands below. + front = view.front + return unless front + front.lo.fill 0 + front.hi.fill front.cols + bytes = diff null, front + view.parked = spot + if spot + bytes += "\x1b[#{spot.y}B" if spot.y + bytes += "\x1b[#{spot.x + 1}G" + SHOW + held.out.write "\x1b[?2026h#{bytes}\x1b[?2026l" + # The line scrolled the terminal: where the frame sits is asked again. + ask held if held.mouse and held.raw diff --git a/packages/tui/test.rip b/packages/tui/test.rip index c94b822e..cc24b57f 100644 --- a/packages/tui/test.rip +++ b/packages/tui/test.rip @@ -17,6 +17,7 @@ import { join } from 'path' # A terminal that remembers what it was sent. def terminal(columns = 40, rows = 10) out = EventEmitter.new() + out.isTTY = true out.columns = columns out.rows = rows out.sent = [] @@ -138,7 +139,7 @@ def mounted(App, body) console.log "\nPackage" test "module exports the entry surface and nothing else", -> - eq Object.keys(mod).sort(), ['Box', 'Spacer', 'Text', 'focus', 'mount', 'quit', 'renderToString', 'run', 'screen'] + eq Object.keys(mod).sort(), ['Box', 'Spacer', 'Text', 'focus', 'mount', 'quit', 'renderToString', 'run', 'screen', 'suspend'] test "declares no dependencies", -> pkg = JSON.parse readFileSync(join(import.meta.dir, 'package.json'), 'utf8') diff --git a/packages/tui/test/terminal.rip b/packages/tui/test/terminal.rip index 24d0121d..515ddf71 100644 --- a/packages/tui/test/terminal.rip +++ b/packages/tui/test/terminal.rip @@ -15,11 +15,22 @@ # believes after every step. # ============================================================================== -import { test, eq, ok, throws } from 'rip/testing' +import { test as pin, eq, ok, throws, plainEnv } from 'rip/testing' import { run, quit, suspend, mount, renderToString, screen, Box, Text } from 'rip/tui' import { Terminal, Stdin, count, differs, tally } from './events/harness.rip' import { join } from 'path' +# A test that fails with its app still live would hand its own report +# to the app's console capture and leave the next test refused: every +# test ends with a quit, which is nothing when nothing runs. +test =! (name, fn) -> + pin name, -> + try + fn! + finally + quit() + sleep! 0 + CSI =! '\x1b[' HIDE =! "#{CSI}?25l" SHOW =! "#{CSI}?25h" @@ -70,7 +81,9 @@ Ways =! component # The console, as the test sees it: each method replaced by a recorder # for the test's turn, so what passes through the package's patch, and -# what reaches the console once the app is closed, are both read. +# what reaches the console once the app is closed, are both read. The +# app is closed before the recorders go, so its own release finds them +# where it left them. recorded =! (body) -> held = {} printed = [] @@ -80,6 +93,8 @@ recorded =! (body) -> try body! printed finally + quit() + sleep! 0 console[name] = fn for name, fn of held # ── Every way out ───────────────────────────────────────────────────────────── @@ -108,9 +123,11 @@ LIMIT =! 20000 # ms a child may take before it is killed # stdout read as it comes. A child that outlives the limit is continued # (a stopped one takes nothing else) and killed, never left behind. class Child - constructor: (way, env = {}) -> + constructor: (way, extra = {}) -> @out = '' - @proc = Bun.spawn ['bun', "--preload=#{join ROOT, 'src/loader.js'}", join(ROOT, 'src/cli/run.js'), join(import.meta.dir, 'terminal/child.rip'), way], { cwd: import.meta.dir, env: { ...process.env, CI: 'false', RIP_STDLIB_ANCHOR: join(import.meta.dir, 'terminal'), ...env }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } + env = plainEnv { CI: 'false', RIP_STDLIB_ANCHOR: join(import.meta.dir, 'terminal'), ...extra } + delete env.NO_COLOR unless extra.NO_COLOR? + @proc = Bun.spawn ['bun', "--preload=#{join ROOT, 'src/loader.js'}", join(ROOT, 'src/cli/run.js'), join(import.meta.dir, 'terminal/child.rip'), way], { cwd: import.meta.dir, env: env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } @timer = setTimeout (=> @kill()), LIMIT @pumping = @pump() @@ -123,8 +140,8 @@ class Child @out += decoder.decode value, { stream: true } kill!: -> - @proc.kill 'SIGCONT' - @proc.kill 'SIGKILL' + @signal 'SIGCONT' + @signal 'SIGKILL' # Wait for `text` to arrive on stdout. seen: (text) -> @@ -134,7 +151,8 @@ class Child sleep! 5 return - signal: (name) -> @proc.kill name + # A signal, by the process's own `kill`: a stopped child takes it. + signal: (name) -> process.kill @proc.pid, name # The exit code, the whole of stdout, and stderr. finish: -> @@ -421,15 +439,17 @@ test! "render only last frame when run in CI", -> { code, out } = child.finish! count 'components', 'render only last frame when run in CI', 'held' eq code, 0 - ok not out.includes("count #{n}"), "count #{n} was written" for n in [0, 1, 2, 3, 4] - eq out, "count 5\nexited\n" + ok not out.includes("count:#{n}"), "count:#{n} was written" for n in [0, 1, 2, 3, 4] + eq out, "count:5\nexited\n" test! "render all frames if CI environment variable equals false", -> child = Child.new 'ci', { CI: 'false' } { code, out } = child.finish! count 'components', 'render all frames if CI environment variable equals false', 'held' eq code, 0 - ok out.includes("count #{n}"), "count #{n} was not written" for n in [0, 1, 2, 3, 4, 5] + ok plain(out).startsWith('count:0'), JSON.stringify plain(out).slice 0, 40 + ok out.split(BEGIN).length - 1 >= 6, "#{out.split(BEGIN).length - 1} frames were written" + ok plain(out).endsWith("5\r\nexited\n"), JSON.stringify plain(out).slice -20 test "the ported titles are the ones SOURCE.md counts", -> eq tally, { @@ -528,7 +548,7 @@ test! "an uncaught error gives the terminal back, prints the error, and exits 1" child = Child.new 'throw' { code, out, err } = child.finish! eq code, 1 - ok err.includes('a timer failed') and err.includes('child.rip'), err + ok err.includes('a timer failed'), err ok out.endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -60 test! "an unhandled rejection is the same way out", -> @@ -536,7 +556,7 @@ test! "an unhandled rejection is the same way out", -> { code, out, err } = child.finish! eq code, 1 ok err.includes('a promise failed'), err - ok out.endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -60 + ok out.replace(/exited\n$/, '').endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -60 test! "a `process.exit` with the app live tears the terminal down on the way, and keeps its code", -> child = Child.new 'exit' @@ -597,7 +617,7 @@ test! "Ctrl-Z is a default action of keydown: the terminal is given back and the ok term.since(at).startsWith(ON), JSON.stringify term.since at eq [stdin.raw, term.text], [true, "k\nk"] stdin.key 'a' - eq running.app.keys.value, ['a'] + eq running.app.keys.value, ['z', 'a'], 'a listener hears Ctrl-Z before its default action, as it hears Ctrl-C' quit() await running.done finally @@ -629,7 +649,7 @@ test! "preventDefault on Ctrl-Z keeps the app on the terminal, and Ctrl-Z under finally process.off 'SIGTSTP', handler -test! "a key that arrives while suspended is nobody's, a resize writes nothing, and a log passes straight through", -> +test! "a key that arrives while suspended is nobody's, a resize writes nothing, and a log is the console's own", -> recorded! (printed) -> stdin = Stdin.new() term = Terminal.new() @@ -641,15 +661,15 @@ test! "a key that arrives while suspended is nobody's, a resize writes nothing, term.columns = 60 term.emit 'resize' console.log 'while suspended' - eq term.sent.slice(at), ['while suspended\n'] + eq [term.sent.slice(at), printed], [[], [['log', 'while suspended']]] eq [running.app.keys.value, screen.cols], [[], 60] sleep! 20 - eq term.text, "k\nwhile suspended\nk" + eq term.text, "k\nk" stdin.key 'b' eq running.app.keys.value, ['b'] quit() await running.done - eq printed, [] + eq printed, [['log', 'while suspended']] test! "a suspend with no app running is refused by name", -> refused = null @@ -669,7 +689,7 @@ test! "a stop and a continue, for real: the child gives the terminal back and st first = out.indexOf "#{QUIET}#{OFF}\n#{SHOW}" rest = out.slice first + "#{QUIET}#{OFF}\n#{SHOW}".length ok rest.startsWith("#{HIDE}#{ASK}#{MOUSE}#{PROBE}"), JSON.stringify rest.slice 0, 60 - ok rest.includes('count 0'), 'the frame is drawn again' + ok rest.includes('count:0'), 'the frame is drawn again' ok rest.endsWith("#{QUIET}#{OFF}\n#{SHOW}exited\n"), JSON.stringify rest.slice -60 # ==[ The alternate screen ]== @@ -800,7 +820,7 @@ test! "`screen.interactive` reads true on a terminal, false on a stdout that is test! "the last frame is written once through a real pipe", -> child = Child.new 'pipe' { code, out, err } = child.finish! - eq [code, err, out], [0, '', "count 5\nexited\n"] + eq [code, err, out], [0, '', "count:5\nexited\n"] # ==[ Colors ]== @@ -882,7 +902,7 @@ test! "a 24-bit color is sent as it is at full depth, as the nearest of the 256 cube = paintedUnder! { FORCE_COLOR: '2' } ok cube.includes("#{CSI}1;38;5;196;48;5;21mr") and cube.includes("#{CSI}0;32mg") and cube.includes("#{CSI}0;38;5;244mh"), JSON.stringify cube named = paintedUnder! { FORCE_COLOR: '1' } - ok named.includes("#{CSI}1;31;44mr") and named.includes("#{CSI}0;32mg") and named.includes("#{CSI}0;37mh"), JSON.stringify named + ok named.includes("#{CSI}1;91;104mr") and named.includes("#{CSI}0;32mg") and named.includes("#{CSI}0;37mh"), JSON.stringify named none = paintedUnder! { NO_COLOR: '1' } ok not /\x1b\[[0-9;]*m/.test(none), JSON.stringify none ok plain(none).includes('rgh'), JSON.stringify none @@ -932,7 +952,7 @@ test! "warn and error go to stderr, the frame cleared for them and drawn again", console.error 'bad', { n: 1 } console.warn 'worse' eq err.sent, ['bad { n: 1 }\n', 'worse\n'] - ok term.since(mark).startsWith("#{HIDE}\r#{CSI}J#{BEGIN}"), JSON.stringify term.since mark + ok term.since(mark).startsWith("#{CSI}J#{BEGIN}#{CSI}J"), JSON.stringify term.since mark eq [term.text, printed], ['x', []] quit() await running.done @@ -1050,6 +1070,7 @@ test! "random keys, resizes, logs and suspends, then a random way out: the termi console.log "log #{n}" logged.push "log #{n}" unless alt + sleep! 10 ok term.text.includes("log #{n}") and term.text.endsWith('f'), "#{name}: after a log the screen shows #{JSON.stringify term.text}" when 'suspend' suspends += 1 @@ -1061,10 +1082,8 @@ test! "random keys, resizes, logs and suspends, then a random way out: the termi ignored += 1 term.emit 'resize' if rnd() < 0.5 at = term.sent.length - if rnd() < 0.5 - console.log 'aside' - logged.push 'aside' - eq term.sent.slice(at).filter((write) -> write isnt 'aside\n'), [], "#{name}: nothing is written while suspended" + console.log 'aside' if rnd() < 0.5 + eq term.sent.slice(at), [], "#{name}: nothing is written while suspended" eq running.app.keys.value.length, heard, "#{name}: a key while suspended reached the app" same "#{name}, step #{n}", term, stdin, believe way = pick ['quit', 'Ctrl-C', 'throw', 'suspend and quit'] @@ -1084,10 +1103,12 @@ test! "random keys, resizes, logs and suspends, then a random way out: the termi ok term.text.endsWith(logged[logged.length - 1]), "#{name}, #{way}: the logs are replayed once the alternate screen is left: #{JSON.stringify term.text}" else eq [term.text, term.cursor], ['', { x: 0, y: 0 }], "#{name}, #{way}: the alternate screen is left as it was found" - else + else if way isnt 'throw' + # A failure leaves whatever reached the screen; every other way + # draws what is owed and lands the cursor below it. ok term.text.endsWith('f'), "#{name}, #{way}: the last frame stands" - eq term.cursor.y, (if way is 'throw' then term.lines.length - 1 else term.lines.length), "#{name}, #{way}: the cursor is below the frame" - eq printed, [] + eq term.cursor.y, term.lines.length, "#{name}, #{way}: the cursor is below the frame" + eq printed.filter((line) -> line[1] isnt 'aside'), [], "a log while suspended is the console's own, and no other reached it" ok steps >= ROUNDS * STEPS, "#{steps} steps" ok exits is ROUNDS, "#{exits} exits" ok suspends >= 80, "#{suspends} suspends" diff --git a/packages/tui/test/terminal/child.rip b/packages/tui/test/terminal/child.rip index a69d2664..295b7183 100644 --- a/packages/tui/test/terminal/child.rip +++ b/packages/tui/test/terminal/child.rip @@ -16,7 +16,7 @@ App = component @count := 0 render Box focusable: true, autofocus: true - Text "count #{@count}" + Text "count:#{@count}" options = { stdin, stdout: (if way is 'pipe' then process.stdout else tty), mouse: true } running = run App, options diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index 7dd6a8a0..137ab431 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -8,6 +8,8 @@ import { tend, take, advance } from './focus.rip' import { Parser } from './input.rip' import { Screen } from './screen.rip' import { Pointer } from './mouse.rip' +import { palette } from './paint.rip' +import { Quiet, setup, probe, reply, teardown, handover, stop, ask, interactive as watched, depth as depthOf } from './terminal.rip' # ── Widgets ─────────────────────────────────────────────────────────────────── # A box is a div and text is a span. Every prop a widget does not @@ -34,18 +36,26 @@ rows := 24 shown := true keyboard := 'basic' selected := '' +interactive := true +colors := 16777216 live = null +DEPTHS =! [0, 16, 256, 16777216] + class Viewport get cols: -> cols get rows: -> rows get focused: -> shown get keyboard: -> keyboard get selection: -> selected + get interactive: -> interactive + get colors: -> colors # The terminal's size, whether it is the window the user is in (its # focus reports), and whether its keyboard is enhanced, as reactive -# reads; and the text the mouse has selected, as it was copied. +# reads; the text the mouse has selected, as it was copied; whether +# frames go to a terminal someone is watching, and how many colors it +# is drawn with, as `run` read them. export screen =! Viewport.new() # The node the keyboard goes to, a reactive read that settles what the @@ -64,56 +74,6 @@ export focus =! Focus.new() # ── Input ───────────────────────────────────────────────────────────────────── -ASK =! "\x1b[?2004h\x1b[?1004h" # bracketed paste, focus reports (xterm ctlseqs) -WITHDRAW =! "\x1b[?1004l\x1b[?2004l" -PROBE =! "\x1b[?6n" # DECXCPR: which row the frame's top-left is on -QUERY =! "\x1b[?u\x1b[c" # kitty's "is the protocol here", then primary device attributes -PUSH =! "\x1b[>1u" # kitty: push the disambiguation flag -POP =! "\x1b[ - moves = if mouse is 'all' then 1003 else 1002 - if set then "\x1b[?#{moves}h\x1b[?1006h" else "\x1b[?1006l\x1b[?#{moves}l" - -# Give `held` its parser, and read `stdin` through it if it is a terminal -# that can be set raw. Any other stdin — a pipe, none — is left alone: -# no key arrives, and nothing is asked of the terminal. With the mouse -# the terminal is asked for reports. -listen! =! (held, stdin, clock) -> - held.parser = Parser.new { clock, late: (events) -> receive held, events } - return unless stdin?.isTTY and typeof stdin.setRawMode is 'function' - stdin.setRawMode true - # From here the stdin is the app's to give back. - held.stdin = stdin - held.read = (chunk) -> receive held, held.parser.feed chunk - stdin.ref?() - stdin.resume() - stdin.on 'data', held.read - held.out.write ASK + (if held.mouse then track(held.mouse, true) else '') - -# Ask the terminal where its cursor is, from the frame's top-left, where -# the frame sits: unknown until the answer, and taken to be the bottom -# meanwhile. A resume after a suspend asks again the same way, once the -# modes are sent again; the alternate screen has no such row. -ask! =! (held) -> - held.asked = true - held.view.origin = Infinity - held.out.write held.view.unpark() + PROBE - -# The questions whose answers arrive as input, asked once the app -# stands, so a constructor that throws leaves no answer for the shell: -# with the mouse, where the cursor is; with the enhanced keyboard, -# whether the terminal speaks kitty's protocol, and its attributes, -# which every terminal answers. -probe! =! (held) -> - return unless held.stdin - ask held if held.mouse - held.out.write QUERY if held.probing - # Hand the app what was read. A listener that throws under `run` — or # a child a key's change made, which failed to construct — has no # caller to throw to: the terminal is given back, and `done` rejects. @@ -143,41 +103,23 @@ deliver! =! (held, events) -> shown = false held.pointer?.leave() when 'mouse' then held.pointer?.report event - when 'reply' then answer held, event + when 'reply' then keyboard = 'enhanced' if reply held, event when 'key', 'paste' tend held.doc sent = Event.new (if event.type is 'key' then 'keydown' else 'paste'), event act held, sent if (held.doc.active ?? held.doc.body).dispatchEvent(sent) and sent.type is 'keydown' -# The terminal's answers. The cursor's row — an answer to the package's -# own probe, marked as DECXCPR marks it, and only while one is -# outstanding — is where the frame's top-left was when it was asked, and -# lower if a frame has scrolled the terminal since. The keyboard is decided by whichever of the two queries is -# answered first: kitty's own reply says the protocol is there, and the -# flag is pushed; the attributes reply first says it is not — tmux -# answers only that one — and nothing is pushed, whatever comes later. -answer! =! (held, event) -> - switch event.kind - when 'cursor' - return unless event.marked and held.asked - held.asked = false - held.view.origin = Math.min held.view.origin, event.y - when 'keyboard', 'attributes' - return unless held.probing - held.probing = false - return unless event.kind is 'keyboard' - keyboard = 'enhanced' - return unless held.stdin - held.out.write PUSH - held.pushed = true - -# The default actions, which run for a key no listener prevented. Ctrl-Z -# (suspend) joins them with the lifecycle, PLAN §8. +# The default actions, which run for a key no listener prevented: Tab +# and Shift-Tab move focus, Ctrl-C quits, Ctrl-Z stops the process +# (terminal.rip). act! =! (held, event) -> - if event.key is 'Tab' and not (event.ctrlKey or event.altKey or event.metaKey) + return if event.altKey or event.metaKey + if event.key is 'Tab' and not event.ctrlKey advance held.doc, event.shiftKey - else if event.key is 'c' and event.ctrlKey and not (event.altKey or event.metaKey) + else if event.key is 'c' and event.ctrlKey quit() + else if event.key is 'z' and event.ctrlKey + stop held # A clock a test moves by hand, for the parser's waits. class Clock @@ -200,7 +142,9 @@ class Clock # The document is a global of the process, so one app is mounted at a # time and a second is refused. `live` stands before the app is # constructed, so a `quit` from an effect that runs during construction -# is heard. +# is heard. A terminal is taken as terminal.rip takes it — unless `out` +# is no terminal, or CI, in which case the frames are kept and only the +# last is written, at the end, as text. open =! (App, options, out, terminal, stdin = null, clock = undefined) -> throw Error.new 'rip/tui: an app is already mounted in this process — `quit` or `close` it first' if live mouse = options.mouse ?? false @@ -208,41 +152,52 @@ open =! (App, options, out, terminal, stdin = null, clock = undefined) -> keys = options.keyboard ?? 'basic' throw Error.new "rip/tui: keyboard: #{JSON.stringify keys} is not 'basic' or 'enhanced'" unless keys is 'basic' or keys is 'enhanced' { doc, restore } = install() - view = Screen.new doc, out + tty = terminal and watched out + view = Screen.new doc, (if terminal and not tty then Quiet.new out else out) view.whole = options.damage is false + view.alt = tty and options.altScreen is true # On a terminal the frame sits wherever the cursor was, which the # probe answers; until then, and where nothing answers, at the bottom. - view.origin = Infinity if terminal + # The alternate screen's is its top. + view.origin = if terminal and not view.alt then Infinity else 0 cols = view.cols rows = view.rows + interactive = not terminal or tty + depth = if terminal then depthOf out else 3 + colors = DEPTHS[depth] + palette depth held = null - # A resize reflows the terminal, so where the frame sits is asked again. - resized = -> - return if view.cols is cols and view.rows is rows + # The next frame is drawn whole, at the size the terminal has now. + refresh = -> cols = view.cols rows = view.rows held.pointer?.clear() view.front = null - ask held if held.mouse and held.stdin doc.owe true + # A resize reflows the terminal, so where the frame sits is asked again. + resized = -> + return if view.cols is cols and view.rows is rows + refresh() + ask held if held.mouse and held.raw and not held.alt settled = {} done = Promise.new (resolve, reject) -> settled.resolve = resolve settled.reject = reject - live = held = { app: null, view, out, doc, restore, resized, interrupted: (-> quit()), done, settled, terminal, closing: false, parser: null, stdin: null, read: null, clock, mouse, pointer: null, probing: keys is 'enhanced', asked: false, pushed: false } + live = held = { app: null, view, out, err: options.stderr ?? process.stderr, doc, restore, resized, redraw: refresh, done, settled, terminal, tty, alt: view.alt, depth, console: options.console isnt false, closing: false, over: false, parser: null, stdin, read: null, clock, mouse, pointer: null, probing: keys is 'enhanced', asked: false, enhanced: false, pushed: false, raw: false, up: false, entered: false, suspended: false, wake: null, handlers: null, was: null, logs: [], close: (drawn) -> close drawn if live is held } + held.parser = Parser.new { clock, late: (events) -> receive held, events } + held.read = (chunk) -> receive held, held.parser.feed chunk if mouse held.pointer = Pointer.new doc, view, out, mouse, options.selection isnt false, (text) -> selected = text view.after = -> held.pointer.rest() shown = true keyboard = 'basic' selected = '' - out.write "\x1b[?25l" if terminal try - listen live, stdin, clock + setup held if terminal live.app = App.new(options.props ?? {}) live.app.mount doc.body failed doc - probe live + probe held if terminal catch error close false throw error @@ -266,7 +221,6 @@ export run =! (App, options = {}) -> view.failed = (error) -> fail held, error doc.onOwe = -> view.book() out.on? 'resize', held.resized - process.on 'SIGINT', held.interrupted { app: held.app, done: held.done, quit, flush: (-> view.frame() if doc.stale) } # An app mounted off any terminal, for a test to drive: set its state @@ -331,15 +285,17 @@ export mount =! (App, options = {}) -> out = { columns: options.cols ?? 80, rows: options.rows ?? Infinity, sent: '', write: (text) -> @sent += text } Mount.new open(App, options, out, false, null, Clock.new()) -# Give the terminal and the process back: the cursor, the listeners, the -# `document` slot. `drawn` leaves the last frame in the scrollback; a -# failure leaves whatever reached the screen. Every step is taken -# whatever the others do, so a write that fails cannot leave stdin raw -# or the document installed, and `done` is settled here — with `error`, -# the first step that failed, or `result`. +# Give the terminal and the process back: the terminal as terminal.rip +# gives it back, the listeners, the `document` slot. `drawn` leaves the +# last frame in the scrollback; a failure leaves whatever reached the +# screen. Every step is taken whatever the others do, so a write that +# fails cannot leave stdin raw or the document installed, and `done` is +# settled here — with `error`, the first step that failed, or `result`. close =! (drawn, error = null, result = undefined) -> - { app, view, out, doc, restore, resized, interrupted, terminal, parser, stdin, read, settled, mouse, pointer, pushed } = live + held = live + { app, view, out, doc, restore, resized, pointer, settled } = held live = null + held.over = true keyboard = 'basic' doc.onOwe = null step = (work) -> @@ -350,25 +306,23 @@ close =! (drawn, error = null, result = undefined) -> # A selection is the screen's, never the scrollback's. step -> pointer.clear() if pointer selected = '' - if terminal - step -> out.write (if pushed then POP else '') + (if mouse then track(mouse, false) else '') + WITHDRAW if stdin - step -> if drawn then view.leave() else out.write view.unpark() + "\x1b[?25h" + if held.terminal + step -> + caught = teardown held, drawn + error ?= caught step -> app?.unmount() - step -> parser?.flush() - if stdin - step -> stdin.off 'data', read - step -> stdin.setRawMode false - step -> stdin.pause() - step -> stdin.unref?() + step -> held.parser.flush() step -> out.off? 'resize', resized - step -> process.off 'SIGINT', interrupted step restore + palette 3 if error then settled.reject error else settled.resolve result # Hand the terminal to `fn` — an editor, a shell — and take it back -# once `fn` settles: the road Ctrl-Z takes, without the signal. +# once `fn` settles, with a whole frame: the road Ctrl-Z takes, without +# the signal. A key that arrives meanwhile is nobody's. export suspend =! (fn) -> - throw Error.new 'rip/tui: no app is running — `suspend` hands over the terminal `run` holds' + throw Error.new 'rip/tui: no app is running — `suspend` hands over the terminal `run` holds' unless live + handover live, fn # Unmount the mounted app, leave its last frame on screen, and give the # process its `document` slot back. The work waits for the turn to end, From dd1e1787253d310f5e05b2a8acc14020947264b2 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 20:45:30 -0700 Subject: [PATCH 05/18] tui: a teardown taken once shows the cursor once, below the frame --- packages/tui/test/terminal.rip | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/tui/test/terminal.rip b/packages/tui/test/terminal.rip index 515ddf71..6099dee9 100644 --- a/packages/tui/test/terminal.rip +++ b/packages/tui/test/terminal.rip @@ -513,7 +513,8 @@ test! "a teardown is taken once: a quit while suspended withdraws nothing twice, suspend! -> quit() await running.done - eq [term.since(0).split(OFF).length - 1, term.since(0).split(QUIET).length - 1, stdin.calls.filter((call) -> call is 'raw off').length], [1, 1, 1] + eq [term.since(0).split(OFF).length - 1, term.since(0).split(QUIET).length - 1, term.since(0).split(SHOW).length - 1, stdin.calls.filter((call) -> call is 'raw off').length], [1, 1, 1, 1] + eq term.cursor, { x: 0, y: 1 }, 'the cursor landed below the frame once' mark = term.sent.length running.quit() sleep! 0 From 37f27610635b1fe9fe349abb979c11f24517af35 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 20:45:30 -0700 Subject: [PATCH 06/18] =?UTF-8?q?tui:=20PLAN=20=C2=A78=20as=20built,=20the?= =?UTF-8?q?=20README's=20Running=20section,=20and=20the=20TODO's=20open=20?= =?UTF-8?q?terminal=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tui/PLAN.md | 150 ++++++++++++++++++++++++++++++++--------- packages/tui/README.md | 52 +++++++++++++- packages/tui/TODO.md | 22 +++--- 3 files changed, 182 insertions(+), 42 deletions(-) diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index cadfbeac..fc247ddd 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -131,17 +131,17 @@ write by hand; hot loops use the indexed `for x, i in` form). | Module | Job | Code lines | |---|---|---| -| `tui.rip` | Entry: `run`, `mount` and its input, `renderToString`, `screen`, `focus`, widgets; stdin, the modes, the probes, the delivery of events, the default actions; to come: `clock` | 249, about 285 when complete | +| `tui.rip` | Entry: `run`, `mount` and its input, `renderToString`, `suspend`, `screen`, `focus`, widgets; the delivery of events, the default actions; to come: `clock` | 227, about 260 when complete | | `document.rip` | Terminal document: nodes, tree links, the event and its dispatch, style road, keyboard traits, damage marks | 388 | | `focus.rip` | Who can hold focus, tree order, taking it, settling it | 62 | | `layout.rip` | Flexbox, containing blocks, baseline, cache, edge rounding | 1,466 | | `text.rip` | Sanitize, grapheme clusters, width, wrap, truncate | 421 | -| `paint.rip` | Cell grids, styles, clip, borders, backgrounds, the selection overlay, damage, diff | 650 | -| `screen.rip` | Frames, pacing, the cursor, where the frame sits; to come: alternate screen, `Static`, non-TTY | 105, about 300 | +| `paint.rip` | Cell grids, styles at the terminal's depth, clip, borders, backgrounds, the selection overlay, damage, diff | 682 | +| `screen.rip` | Frames, pacing, the cursor, where the frame sits, the alternate screen; to come: `Static` | 106, about 300 | | `input.rip` | Key tokenizer and decoder, paste, mouse, replies | 315 | | `mouse.rip` | Hit test, the mouse events, hover, selection and the clipboard | 211 | -| `terminal.rip` | To come: setup / teardown, signals, suspend, console capture | about 170 | -| | **Total** | **3,868 built; about 4,280 complete** | +| `terminal.rip` | Setup / teardown: raw mode, the modes, the probes, the cursor, the alternate screen, the signals, suspend and resume, the console, the depth read | 192 | +| | **Total** | **4,071 built; about 4,250 complete** | Lines are counted as §2 counts them: non-blank and non-comment. Events, focus, the cursor and stdin are 285 of them (85 in `tui.rip`, 99 in @@ -616,8 +616,7 @@ a dialog takes a key before the node under it does. In Ink every listener therefore hears Ctrl-C before it quits, where Ink exits before any handler runs. **Escape has none** — closing a dialog or clearing an input is the app's — where Ink takes focus away on every - Escape. Ctrl-Z (suspend) joins them with the lifecycle (§8): the seam - is `act` in `tui.rip`. + Escape. Ctrl-Z stops the process (§8). - **Each key is a turn of its own.** The keys of one read are dispatched one after another with no batch around them, so a key that opens a dialog is followed by a key that reaches the dialog. Once the @@ -701,7 +700,7 @@ in `test/input/`, 205 held to Ink's answer under one mapping to DOM names and 39 stated differences; `test/input/SOURCE.md` lists them and the 12 left out. -**Enhanced keyboard** (built: `tui.rip`) is opt-in (`run App, keyboard: +**Enhanced keyboard** (built: `terminal.rip`) is opt-in (`run App, keyboard: 'enhanced'`; the default is `'basic'`, and any other value is refused by name). Setup asks the terminal for the kitty protocol's disambiguation flag and teardown withdraws it; the decoder is always @@ -733,7 +732,7 @@ forwards xterm's modify-other-keys form (`CSI 27 ; mod ; code ~`), which the decoder reads; and the 500 ms `escape-time` that delays a lone Escape for every terminal program, which is the user's to lower. -**Mouse** (built: `mouse.rip`, the modes and the probe in `tui.rip`) is +**Mouse** (built: `mouse.rip`, the modes and the probe in `terminal.rip`) is opt-in (`run App, mouse: true`), because capture takes over the terminal's own text selection. `true` asks for button-event tracking (`CSI ? 1002 h`) and SGR reports (`CSI ? 1006 h`): presses, releases, @@ -755,9 +754,9 @@ a shell's prompt integration is not it. Until the answer, and on a terminal that never answers, the frame is taken to sit at the bottom. `mount` has no terminal: its frame is at row 0. A report names a terminal cell; the tree cell is `(x, y - origin + top)`, `top` being the -tree row the grid shows first. The lifecycle's resume (§8) asks again -the same way once the modes are sent again (`ask` in `tui.rip`), and -the alternate screen sets the origin to 0 with no probe. +tree row the grid shows first. A resume (§8) asks again the same way +once the modes are sent again (`ask` in `terminal.rip`), and the +alternate screen sets the origin to 0 with no probe. The target is found by a hit test that walks the tree as the painter does, backwards: the children of a box from last to first, then the box @@ -921,7 +920,7 @@ hardware cursor. A cursor belongs to a node, so in a frame taller than the terminal it stays with its row of the tree, where Ink counts `y` from the top of what is shown. -**stdin** (built: `tui.rip`; the lifecycle's in full with §8). `run +**stdin** (built: `terminal.rip`; every way out is §8's). `run App, stdin:` reads a stdin that is a terminal and can be set raw: raw mode, `ref`, `resume`, one `data` listener feeding the `Parser`, and bracketed paste (`CSI ? 2004 h`) and focus reports (`CSI ? 1004 h`) @@ -942,22 +941,88 @@ no keys from the terminal the process is on. ## 8. Lifecycle (`terminal.rip`) -One idempotent `setup()` / `teardown()` pair is shared by exit, -signals, crash, `suspend`, and Ctrl-Z / SIGCONT. What `tui.rip`'s -`listen` and `close` do for stdin and the two modes (§7) is the first -of it, and moves there. The host owns raw -mode and bracketed paste for the app's lifetime; there is no -ref-counting. - -Verified on Bun 1.4.2: an unhandled SIGTERM skips exit hooks, so -SIGINT / SIGTERM / SIGHUP get explicit handlers that restore and exit -with 128 + n; Bun restores termios on exit but not the cursor, -alternate screen, or paste mode; `console.log` bypasses -`process.stdout.write`, so the `console.*` methods are captured, not -the stream. Inline mode clears, writes the log line, and repaints; the -alternate screen buffers logs and replays them at exit. - -An uncaught error restores the terminal, prints the stack, and exits 1. +One idempotent `setup` / `teardown` pair is shared by `quit`, Ctrl-C, +a listener that throws, a frame that fails, SIGINT / SIGTERM / SIGHUP, +an uncaught error or an unhandled rejection, `process.exit`, a loop +that drains, Ctrl-Z / SIGCONT and `suspend`. `setup` hides the +cursor, sets stdin raw and reads it, asks for bracketed paste, focus +reports and the mouse modes, pushes the kitty flag again where the +keyboard was decided, enters the alternate screen, captures the +console, and puts the handlers on the process; `teardown` pops the +flag if it was pushed, withdraws the modes, draws what is owed and +leaves the cursor below the last frame — or, on a failure, shows it +where it is — leaves the alternate screen, flushes the parser, gives +stdin back cooked, paused and unref'd, takes the handlers off, and +gives the console back. Every step is taken whatever the others do, +each pair is taken once until the other, and a `setup` that fails +halfway is torn down by the steps that were taken. The host owns raw +mode and the modes for the app's life; there is no ref-counting. The +probes are asked once the app stands, so a constructor that throws +leaves no answer for the shell. + +**Signals.** An unhandled SIGTERM skips Bun's exit hooks, so SIGINT, +SIGTERM and SIGHUP have handlers that tear down and exit with 128 + n; +with raw mode on, Ctrl-C arrives as a key and its default action is +`quit`. An uncaught error or an unhandled rejection tears down, then +hands the error on to whoever else handles it — the runtime, which +prints it with its frames remapped (`src/cli/run.js`) and exits 1 — +or prints it and exits 1 itself. `beforeExit` closes a live app as +`quit` would, so a script whose loop drains ends with its last frame +in the scrollback; `exit` tears down an app still live when +`process.exit` is called with it on the terminal. The handlers stand +on the process only while the app runs. + +**Suspend.** Ctrl-Z is a default action of `keydown`, preventable +like Ctrl-C: teardown with the cursor below the last frame, then +SIGTSTP to the process itself; SIGCONT sets up again, asks the +questions again — the frame's row with the mouse, the keyboard if it +was still undecided — forgets the press whose release was never seen +(`Pointer.reset`) and the bytes held mid-sequence (`parser.reset`), +and draws whole at whatever size the terminal has now. `suspend(fn)` +is the same road without the signal: teardown, `await fn()`, setup, +whole frame — for an editor or a shell that takes the terminal for a +while. Meanwhile a booked frame goes nowhere, a key is nobody's, a +resize writes nothing, and a log is the console's own. A nested +suspend is refused; a teardown that fails is set up again and thrown +before `fn` runs; a `quit` while suspended closes the app without +taking the terminal back. On a stdout nobody is watching, `fn` runs +and nothing changes hands. + +**Alternate screen.** `run App, altScreen: true` enters `CSI ? 1049 h` +after the modes and homes the cursor: the frame's top-left is the +screen's, the origin is 0 with no probe, a resize repaints whole with +no probe, and `Static` is a documented no-op. Every way out leaves it +with `CSI ? 1049 l` after the last frame is drawn, so the frame +vanishes with the screen and the shell's own comes back where it was. +A suspend leaves it and a resume enters it again. + +**Non-TTY and CI.** When stdout is no terminal, or `CI` is set to +anything but `''`, `'0'` or `'false'`, `screen.interactive` reads +false: no modes, no cursor bytes, no probe, no raw mode even on a +stdin that is a terminal; frames are laid out and kept, and the last +is written once at exit, as text — with its escapes when a depth is +forced. `altScreen` and the console capture are nothing there. + +**Colors.** The depth is read once at `run` and exposed as +`screen.colors` (0, 16, 256 or 16777216): `NO_COLOR` set is none; +`FORCE_COLOR` 0–3 is that depth, and any other value 16; else +`COLORTERM` `truecolor` / `24bit` is 24-bit, `TERM` `256color` is +256, a dumb terminal or output nobody is watching none, and any other +terminal 16. The painter emits every style at that depth (`paint.rip`): +a 24-bit color becomes the nearest of the 256 — the 6×6×6 cube, the +24 grays for a gray — and one of the 256 the nearest of the 16, bright +at full intensity; at none, no style sends anything. `mount` and +`renderToString` stay at full depth. + +**Console.** `console.log` bypasses `process.stdout.write`, so while +an app runs on a terminal the five methods are replaced and given +back at teardown. Inline, a line clears the frame from its top-left, +writes the line to the stream it always went to, and draws the frame +again below it, so logs scroll into the scrollback above the app — and +with the mouse on, the frame's row is asked again; on the alternate +screen lines are kept and replayed once the screen is left; a log +while suspended, or with the app closed, is the console's own. `run +App, console: false` leaves the console alone. ## 9. Public surface @@ -980,16 +1045,17 @@ App = component run App ``` -- `run(App, {stdin, stdout, damage, mouse, keyboard, selection})` → - `{app, done, quit, flush}`. To come: `altScreen`; `suspend(fn)`; +- `run(App, {stdin, stdout, stderr, damage, mouse, keyboard, selection, + altScreen, console})` → `{app, done, quit, flush}`, and `suspend(fn)` + hands the terminal to `fn` and takes it back (§8). To come: `print(text)`. - `mount(App, {cols, rows, props, damage, mouse, keyboard, selection})` → `{app, frame, ansi, bytes, damage, resize, close, done}`, and for input `{press, type, paste, send, tick, focused, cursor}`, is the test driver (§10), and `renderToString(App, {cols, rows, props, ansi})` is a mount, one frame, and a close. -- `screen` (`cols`, `rows`, `focused`, `keyboard`, and `selection`, the - selected text; to come: `interactive`) and `focus` (`active`, +- `screen` (`cols`, `rows`, `focused`, `keyboard`, `selection`, the + selected text, `interactive` and `colors`, §8) and `focus` (`active`, `next()`, `previous()`, `to(node)`, with `to(null)` letting go) are **getter-backed objects**. An imported `:=` cell is not unwrapped across modules, so raw cells are never @@ -1081,6 +1147,24 @@ run App and the hit target must be the node the painter says owns the cell (a text where its words reach, painted after that owner), held to a floor of clicks, targets and covered cells. +- **Terminal:** `test/terminal.rip` — Ink's suspend, exit, error, + console and CI titles (`test/terminal/SOURCE.md` counts them); then + every way out — `quit`, Ctrl-C, a listener that throws, a suspend + then a quit, inline and on the alternate screen — held to one rule + for the modes, the keyboard stack, the cursor, stdin and the + process's handlers; SIGTERM, SIGHUP, SIGINT, an uncaught error, an + unhandled rejection, a `process.exit` and a loop that drains, each + in a spawned `rip` whose stdout claims to be a terminal, by exit code + and by its last bytes; `suspend`'s bytes and raw-mode calls in + order, Ctrl-Z through a SIGTSTP handler of the test's own and, for + real, a child that stops itself and is continued; the alternate + screen's entry, exit after the last frame, resize and mouse origin; + a stdout that is no terminal, and `CI`; the depth under every + environment and a 24-bit color at each; the console's bytes; and a + fuzz of random keys, resizes, logs, suspends and ways out on fake + streams, the terminal's modes held to what the app believes after + every step and every exit to everything off, held to floors of + steps, exits, suspends and logs. - **The test driver is public,** because users' tests are a contract too. `mount(App, {cols, rows, props})` is `run` without a terminal: the same install, the same `Screen`, the same close, drawing to a diff --git a/packages/tui/README.md b/packages/tui/README.md index 9c5c190d..afc749d9 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -47,6 +47,50 @@ run App value given to `quit`. Ctrl-C quits. The last frame stays in the scrollback and the cursor lands on the line below it. +## Running + +`run App, options` takes the terminal for the app's life and gives it +back on every way out, by one road: + +- **Exit.** `quit()`, Ctrl-C, a listener that throws, a frame that + fails, or a script whose loop drains: the last frame stays in the + scrollback, the cursor lands below it, and stdin, the terminal's + modes and the process's handlers are as they were. A failure leaves + what reached the screen, and `done` rejects with it. +- **Signals.** SIGINT, SIGTERM and SIGHUP give the terminal back and + exit with 128 plus the signal's number. An uncaught error or an + unhandled rejection gives it back, prints the error, and exits 1; a + `process.exit` with the app live gives it back on the way. +- **Suspend.** Ctrl-Z gives the terminal back and stops the process as + the shell would; `fg` draws the app again, whole, at the terminal's + size now. It is a default action of `keydown`, preventable like + Ctrl-C. `suspend fn` is the same road without the signal — the + terminal is `fn`'s until it settles: + + ```coffee + import { suspend } from 'rip/tui' + suspend! -> Bun.spawn(['vim', path], stdio: ['inherit', 'inherit', 'inherit']).exited + ``` + + A key that arrives meanwhile is nobody's; a `quit` meanwhile closes + the app without taking the terminal back. +- **Alternate screen.** `run App, altScreen: true` draws on the + terminal's alternate screen from its top-left; every way out leaves + it after the last frame, so the frame vanishes and the shell's own + screen comes back where it was. `Static` is nothing there. +- **CI and pipes.** On a stdout that is no terminal, or with `CI` set, + nothing is asked of the terminal and the last frame alone is + written, as text, at exit; `screen.interactive` reads false. +- **Colors.** The depth is read once at `run` — `NO_COLOR`, + `FORCE_COLOR` 0 to 3, else `COLORTERM` and `TERM` — and + `screen.colors` reads it: 0, 16, 256 or 16777216. A 24-bit color is + drawn as the nearest the terminal has. +- **Console.** While the app runs, `console.log` and its four siblings + clear the frame, write the line where it always went, and draw the + frame again below it, so logs scroll into the scrollback above the + app; on the alternate screen they are kept and replayed at exit. + `run App, console: false` leaves the console alone. + ## Widgets and styles A box is a `div` and text is a `span`. `Box`, `Text`, and `Spacer` are @@ -596,7 +640,13 @@ mode and under a frame taller than the terminal, the events and their road, the modes' bytes on every way out, the probe and both answers, the selection's cells, overlay, damage and clipboard bytes, and a fuzz of random trees and random cells where the hit target must be the node -the painter put there. `test/yoga.rip` runs Yoga's +the painter put there. `test/terminal.rip` holds every way out to one +rule — Ink's suspend, exit, error, console and CI cases +(`test/terminal/SOURCE.md`), the signals and the crash in a spawned +process, Ctrl-Z and `suspend` byte for byte, the alternate screen, a +stdout that is no terminal, the color depth, the console, and a fuzz +of keys, resizes, logs and suspends that holds the terminal's modes +to what the app believes after every step. `test/yoga.rip` runs Yoga's 543 generated layout cases, vendored unmodified under `test/yoga/` (MIT, © Meta Platforms), against the engine through a shim of the `yoga-layout` API. `test/yoga-aspect.rip` is a port of Yoga's 37 diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index 3b2111be..f95f15fb 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -53,13 +53,6 @@ steps are in [PLAN.md](PLAN.md). - [ ] A select list that marks its choice with one binding an item (`inverse: n is at`) pays for every item on every arrow key: about 55 µs at a hundred items where ten cost 8 (`bun run keys`). -- [ ] `run`'s stdin and mode handling — raw mode, bracketed paste, - focus reports, the mouse modes, the cursor probe, the kitty - query and its pushed flag — is `listen`, `answer` and `close` in - `tui.rip`; it moves into `terminal.rip`'s setup and teardown with - the lifecycle (PLAN §8), where Ctrl-Z and the signals need it: - a suspend must pop the flag and withdraw the mouse, and a resume - ask for both again. - [ ] After a resize the frame's row is asked of the terminal again from what was the top-left; a terminal whose reflow moves the cursor off that row places clicks wrongly until the next resize. @@ -70,7 +63,20 @@ steps are in [PLAN.md](PLAN.md). - [ ] `mouseenter` and `mouseleave` carry the terminal's cell and the other target, not `x`, `y` from the corner of each node they reach. -## 7. Compiler-side, filed separately +## 7. The terminal + +- [ ] The last frame written on a stdout that is no terminal is the + grid's own text: clipped to the stream's `rows` (24 when it says + nothing), and at a forced depth padded to its width, since a row + with a background cannot be trimmed blind. `rowsToString` (PLAN + §6) is the serializer it should share with `Static`. +- [ ] A log while the console is captured is written to the run's + stdout, as Ink writes it, even when that is a stream of the + 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. + +## 8. Compiler-side, filed separately - A typed vocabulary for non-HTML hosts, so `rip check` and the editor accept terminal props (PLAN §14). From 68d97d6950fd4c119b013268c6d906bca51e8fbf Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 20:46:12 -0700 Subject: [PATCH 07/18] tui: a build log example, the docs, and two more Static pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/log.rip is a Static list of finished steps under a spinner and a progress bar driven by one clock, a warning printed above the frame, q to quit; test.rip runs it headless through mount and view.tick. A written item its own component shows again is hidden again before the next batch, and a print called with an item already in the tree comes after that item. README gains Static output, Animation and Progress; PLAN §3, §6 and §9 say what is built; TODO lists what is open. --- packages/tui/PLAN.md | 58 +++++++++++++++++--------- packages/tui/README.md | 78 +++++++++++++++++++++++++++++++++-- packages/tui/TODO.md | 9 ++++ packages/tui/examples/log.rip | 39 ++++++++++++++++++ packages/tui/screen.rip | 33 +++++++++------ packages/tui/test.rip | 40 +++++++++++++++++- 6 files changed, 219 insertions(+), 38 deletions(-) create mode 100644 packages/tui/examples/log.rip diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index cadfbeac..3ea2d2be 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -131,13 +131,13 @@ write by hand; hot loops use the indexed `for x, i in` form). | Module | Job | Code lines | |---|---|---| -| `tui.rip` | Entry: `run`, `mount` and its input, `renderToString`, `screen`, `focus`, widgets; stdin, the modes, the probes, the delivery of events, the default actions; to come: `clock` | 249, about 285 when complete | +| `tui.rip` | Entry: `run`, `mount` and its input, `renderToString`, `screen`, `focus`, widgets, `print`, `clock`; stdin, the modes, the probes, the delivery of events, the default actions | 331 | | `document.rip` | Terminal document: nodes, tree links, the event and its dispatch, style road, keyboard traits, damage marks | 388 | | `focus.rip` | Who can hold focus, tree order, taking it, settling it | 62 | | `layout.rip` | Flexbox, containing blocks, baseline, cache, edge rounding | 1,466 | | `text.rip` | Sanitize, grapheme clusters, width, wrap, truncate | 421 | -| `paint.rip` | Cell grids, styles, clip, borders, backgrounds, the selection overlay, damage, diff | 650 | -| `screen.rip` | Frames, pacing, the cursor, where the frame sits; to come: alternate screen, `Static`, non-TTY | 105, about 300 | +| `paint.rip` | Cell grids, styles, clip, borders, backgrounds, the selection overlay, damage, diff, a subtree painted once | 662 | +| `screen.rip` | Frames, pacing, the cursor, where the frame sits, the write above the frame (`Static`, `print`), progress; to come: alternate screen, non-TTY | 164, about 300 | | `input.rip` | Key tokenizer and decoder, paste, mouse, replies | 315 | | `mouse.rip` | Hit test, the mouse events, hover, selection and the clipboard | 211 | | `terminal.rip` | To come: setup / teardown, signals, suspend, console capture | about 170 | @@ -552,8 +552,15 @@ a win: both disagree with some terminals and with tmux. **Inline rendering is its own design item.** Drawing below the prompt uses relative cursor moves and clips the live region to the terminal height, showing the bottom. `Static` paints an item once above the -live region, invalidates the front buffer, and detaches its nodes; on -the alternate screen it is a documented no-op and nothing accumulates. +live region: the items a container gained since the last frame are +laid out together as a root at the terminal's width, painted to a grid +of their own, and written through `Screen.above` — the frame's rows +cleared, the rows written where they were, the frame drawn again whole +below, in the frame's one write, the frame's origin moved down by the +rows written — and then hidden, so the live frame never holds them and +what happens to them later is nobody's. `print` takes the same road +with a line of text, and console capture (§8) will. On the alternate +screen `Static` is a documented no-op and nothing accumulates. **Resize** is coalesced to one frame: reallocate, then erase and paint (alternate screen) or move up by the estimated reflowed rows, erase @@ -561,8 +568,10 @@ down, and repaint (inline). **Non-TTY and CI** write static output immediately and the final frame once, with no cursor control, and honor `NO_COLOR` / `FORCE_COLOR` and color depth. -One serializer, `rowsToString`, serves `renderToString`, `Static`, and -the non-TTY final frame. +One serializer, `rowsToString` (`Grid.toString` under its own name, +paint.rip), serves `renderToString`, `Static`, and the non-TTY final +frame; with escape sequences it leaves a row's trailing default-style +blanks off, as the plain form does. ## 7. Input, focus, cursor (`input.rip`, `document.rip`) @@ -981,23 +990,32 @@ run App ``` - `run(App, {stdin, stdout, damage, mouse, keyboard, selection})` → - `{app, done, quit, flush}`. To come: `altScreen`; `suspend(fn)`; - `print(text)`. + `{app, done, quit, flush}`. To come: `altScreen`; `suspend(fn)`. +- `print(text)` writes text above the live frame, its line ended, and + `print.err(text)` the same on stderr with the frame cleared on stdout + first; with no app mounted the text goes to the stream as it is. - `mount(App, {cols, rows, props, damage, mouse, keyboard, selection})` - → `{app, frame, ansi, bytes, damage, resize, close, done}`, and for - input `{press, type, paste, send, tick, focused, cursor}`, is the test - driver (§10), and `renderToString(App, {cols, rows, props, ansi})` is - a mount, one frame, and a close. + → `{app, frame, ansi, bytes, damage, scrollback, stderr, resize, + close, done}`, and for input `{press, type, paste, send, tick, + focused, cursor}`, is the test driver (§10), and + `renderToString(App, {cols, rows, props, ansi})` is a mount, one + frame, and a close, answering the rows `Static` wrote and then the + frame. - `screen` (`cols`, `rows`, `focused`, `keyboard`, and `selection`, the - selected text; to come: `interactive`) and `focus` (`active`, - `next()`, `previous()`, `to(node)`, with `to(null)` letting go) are - **getter-backed objects**. An imported - `:=` cell is not unwrapped across modules, so raw cells are never - exported. + selected text; `progress(value)`, the terminal's own indicator + through OSC 9;4 — 0 to 1, `'error'`, `'indeterminate'`, null — sent + with the next frame's write and cleared on every way out; to come: + `interactive`) and `focus` (`active`, `next()`, `previous()`, + `to(node)`, with `to(null)` letting go) are **getter-backed + objects**. An imported `:=` cell is not unwrapped across modules, so + raw cells are never exported. - `clock(interval)` is the animation helper: a getter-backed object with `frame`, `time`, and `delta`, driven by one shared timer per - interval that runs only while a mounted component reads it and never - when output is not interactive. A spinner is + interval. A component holds the clock it makes in its body + (`tick = clock 80`), and the timer runs only while a holder is + mounted and has read it, never when output is not interactive, and + on the mount's own clock under `mount`, which `view.tick` moves along + with the parser's waits. A spinner is `frames[tick.frame % frames.length]`. The frame scheduler already coalesces every change into one paint, so the helper is a convenience, not a requirement. diff --git a/packages/tui/README.md b/packages/tui/README.md index 9c5c190d..d2d1ca08 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -54,6 +54,8 @@ four-line components over them, and the raw tags are the zero-overhead spelling of the same nodes. Every prop a widget does not declare is a terminal style, forwarded to its node as written; bare text under a box is a text leaf, and text nested in text restyles its own words. +`Newline count: n` is `n` line breaks inside text, and `Static` is the +scrollback (below). | Moves boxes | Recolors cells | |---|---| @@ -227,6 +229,8 @@ view.send '\x1b[1;5A' # raw bytes, through the parser: keys, mouse reports view.tick 50 # move the parser's clock: a lone ESC is Escape after 50 ms view.focused # the node that has focus, or null view.cursor # where the last frame parked the cursor, { x, y }, or null while hidden +view.scrollback # what `Static` and `print` wrote above the frame so far, as it was written +view.stderr # what `print.err` wrote view.close() # unmount, and give the process its `document` slot back ``` @@ -250,7 +254,8 @@ mounted at a time: a second `mount`, `run`, or `renderToString` is refused by name until the first is closed — close in a `finally`. `renderToString App, cols: 40` is a mount, one frame, and a close; it -takes `props`, and `ansi: true` keeps the escape sequences. A child that +takes `props`, and `ansi: true` keeps the escape sequences. The rows +the app's `Static` items wrote come first, then the frame. A child that fails to construct — at the mount, from a key, or from a state set by the test — fails the mount, the key, or the next frame with the child's own error, and a `done` is settled by `close` as well as by `quit`. @@ -546,6 +551,69 @@ on: - A lone Escape arrives 50 ms after the key, since ESC also opens every sequence; under the enhanced keyboard it arrives at once. +## Static output + +A log of finished work belongs in the scrollback, not in the frame. +`Static` around a keyed `for` writes each item once, above the live +frame, when it first appears — and never paints it in the frame, so +the frame stays the size of what is live. + +```coffee +import { run, quit, print, Box, Text, Static } from 'rip/tui' + +Build = component + @done := [] # the steps finished so far + @step := 'compile' + render + Box flexDirection: 'column' + Static + for name in @done + Text key: name, color: 'green', "✓ #{name}" + Text "… #{@step}" + +build = run Build +build.app.done.value = ['resolve', 'fetch'] # two rows into the scrollback, the frame drawn again below +print 'warning: fetch took the slow road' # a line above the frame, the same way; print.err for stderr +``` + +An item is laid out at the terminal's width, with the items that arrive +in the same frame, in tree order; `Static`'s own props — `padding`, +`margin`, `backgroundColor` — go around each such batch. Once written, +an item is done: a change to its state or its removal from the list +changes nothing on the terminal. An item under a hidden ancestor waits +until it is shown. Off a terminal the rows go out as plain text as they +arrive; on the alternate screen nothing is written above. `examples/log.rip` +is a build log this way, with a spinner and a progress bar for the +step under way. + +## Animation + +`clock(interval)` is `{ frame, time, delta }` as reactive reads, moved +by one timer per interval: `frame` counts the intervals since the +timer started, `time` the milliseconds, `delta` the milliseconds since +the last tick. A spinner is `frames[tick.frame % frames.length]`. + +```coffee +Spinner = component + tick = clock 80 + render + Text color: 'cyan', "#{'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[tick.frame % 10]}" +``` + +The component that makes the clock in its body holds it, and every +component holding one interval shares its timer, which runs only while +one of them is mounted and has read it, and never off a terminal. Under +`mount` the clock runs on the mount's own time, so `view.tick 80` moves +the spinner a frame, as it moves the parser's waits. + +## Progress + +`screen.progress value` puts the app's progress on the terminal's own +indicator — the taskbar, the tab — through OSC 9;4, which Windows +Terminal, Ghostty, kitty and iTerm2 honor: a number from 0 to 1, +`'error'`, `'indeterminate'`, or `null` to clear. It goes out with the +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 @@ -572,7 +640,9 @@ cell rounding, `if` / `else` and keyed `for` on a terminal, nested text styles, hyperlinks byte for byte, `ref:` metrics, the grid diff replayed through a terminal, 70,000 colors and 300,000 clusters through the swept tables, a running app from first frame to `quit`, the `mount` -driver, and what each kind of change owes a frame, by its cells. +driver, what each kind of change owes a frame, by its cells, the bytes +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 @@ -596,7 +666,9 @@ mode and under a frame taller than the terminal, the events and their road, the modes' bytes on every way out, the probe and both answers, the selection's cells, overlay, damage and clipboard bytes, and a fuzz of random trees and random cells where the hit target must be the node -the painter put there. `test/yoga.rip` runs Yoga's +the painter put there. `test/ink/static.rip` holds Ink's `Static` cases +and its `useStdout` / `useStderr` cases through `print` +(`test/ink/SOURCE.md`). `test/yoga.rip` runs Yoga's 543 generated layout cases, vendored unmodified under `test/yoga/` (MIT, © Meta Platforms), against the engine through a shim of the `yoga-layout` API. `test/yoga-aspect.rip` is a port of Yoga's 37 diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index 3b2111be..2d8c3313 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -38,6 +38,15 @@ steps are in [PLAN.md](PLAN.md). over the 300 updates of `bun run tui`, and about 170 µs over 12,000: the run ends while the damage path is still being compiled. Warm the bench longer, or make the path smaller. +- [ ] A `Static` batch lays its container out as a root, and the + body's next layout puts the container away again by visiting + every item under it: a walk as long as the list, per batch. The + 1,000-append row of the bench (PLAN §11) is unmeasured. +- [ ] `Static`'s items are elements; a bare text under `Static` is + never hidden and is painted again with every batch. +- [ ] `print` and a `Static` item write nothing on the alternate + screen, where Ink keeps them for the way out; console capture + (PLAN §8) decides what a run there keeps. ## 6. Input and focus diff --git a/packages/tui/examples/log.rip b/packages/tui/examples/log.rip new file mode 100644 index 00000000..2968bbeb --- /dev/null +++ b/packages/tui/examples/log.rip @@ -0,0 +1,39 @@ +# A build log: each finished step scrolls into the scrollback above a +# live line that spins and fills a bar for the step under way, a +# warning is printed above the frame on the way, the terminal's own +# progress indicator follows, and the app quits when the last step is +# done — or on q. +# +# rip examples/log.rip + +import { run, quit, screen, print, clock, Box, Text, Static } from 'rip/tui' + +STEPS =! ['resolve', 'fetch', 'compile', 'link', 'bundle', 'sign'] +SPIN =! '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' +PACE =! 700 # milliseconds a step takes + +export Log = component + tick = clock 80 + step ~= Math.min STEPS.length, Math.floor(tick.time / PACE) + done ~= STEPS.slice 0, step + bar ~= '█'.repeat(step * 2) + '░'.repeat((STEPS.length - step) * 2) + + ~> print "warning: #{STEPS[2]} took the slow road" if step is 3 + ~> screen.progress (if step < STEPS.length then step / STEPS.length else null) + ~> quit() if step is STEPS.length + + render + Box flexDirection: 'column', focusable: true, autofocus: true, @keydown: ((event) -> quit() if event.key is 'q') + Static + for name in done + Text key: name, color: 'green' + "✓ #{name}" + if step < STEPS.length + Box flexDirection: 'row', gap: 1 + Text color: 'cyan', "#{SPIN[tick.frame % SPIN.length]}" + Text "#{STEPS[step]}" + Text dimColor: true, "[#{bar}] #{step}/#{STEPS.length}" + else + Text color: 'green', bold: true, "all done" + +run Log if import.meta.main diff --git a/packages/tui/screen.rip b/packages/tui/screen.rip index 555d4a55..3c385431 100644 --- a/packages/tui/screen.rip +++ b/packages/tui/screen.rip @@ -59,6 +59,7 @@ export class Screen @lead = '' # what the next frame's write begins with: the rows written above the frame @osc = '' # the progress report the next frame's write carries @reported = false # whether a progress is on the terminal's indicator + @batching = false # whether `still` is writing what the containers hold get cols: -> @out.columns ?? 80 get rows: -> @out.rows ?? 24 @@ -73,6 +74,7 @@ export class Screen # screen nothing is written above. above!: (text, err = null) -> return unless text + @still() unless @batching # what `Static` holds goes first @scrollback += text if @interactive is false (err ?? @out).write text @@ -91,20 +93,25 @@ export class Screen # Write what the `Static` containers hold that was not written yet: # each container's new items are laid out together, as a root at the - # terminal's width, painted once, written above the frame, and - # hidden, so the live frame never holds them and a later change to - # them is nobody's. A container under a hidden ancestor waits. + # terminal's width, painted once and written above the frame. Every + # item written before is hidden first — again, if its own component + # showed it — so no batch holds one twice and a later change to it is + # nobody's; the container itself is hidden, so the live frame never + # holds any of them. A container under a hidden ancestor waits. still!: -> - return if @alt - for el in @statics - fresh = (kid for kid in el.childNodes when kid.nodeType is 1 and not @written.has kid) - continue unless fresh.length and showing el, @doc.body - grid = still el, @cols - @above rowsToString(grid, @interactive isnt false) + '\n' if grid.rows - for kid in fresh - @written.add kid - kid.set 'display', 'none' - return + return if @alt or @batching + @batching = true + try + for el in @statics + fresh = [] + for kid in el.childNodes when kid.nodeType is 1 + if @written.has kid then (kid.set 'display', 'none' if kid.styles.display isnt 'none') else fresh.push kid + continue unless fresh.length and showing el, @doc.body + grid = still el, @cols + @above rowsToString(grid, @interactive isnt false) + '\n' if grid.rows + @written.add kid for kid in fresh + finally + @batching = false # Progress on the terminal's own indicator (OSC 9;4, ConEmu's # sequence, which Windows Terminal, Ghostty, kitty and iTerm2 honor): diff --git a/packages/tui/test.rip b/packages/tui/test.rip index 7f456801..8a4320da 100644 --- a/packages/tui/test.rip +++ b/packages/tui/test.rip @@ -10,6 +10,7 @@ import { layout } from './layout.rip' import { Grid, paint, diff, rowsToString, tally as styleCount } from './paint.rip' import { tally as clusterCount, clusterWidth } from './text.rip' import { __setChildFailureReporter } from '../../src/runtime/components.js' +import { Log as BuildLog } from './examples/log.rip' import { EventEmitter } from 'node:events' import { readFileSync } from 'fs' import { join } from 'path' @@ -121,6 +122,9 @@ def words(doc, styles, text, inner = null) tick = -> Promise.new (resolve) -> setTimeout resolve, 20 +# Text with its escape sequences taken out. +bare = (text) -> text.replace /\x1b\[[0-9;]*m/g, '' + # Mount `App`, hand `body(app, show, doc, view)` the instance, a way to # draw it at a width, its document, and the mount, and always close it. def mounted(App, body) @@ -1973,11 +1977,12 @@ test "an item is painted once, at the terminal's width: a change to it or its re Steps = component @done := ['a'] @tint := undefined + @shape := undefined render Box flexDirection: 'column' Static for step in @done - Text key: step, color: @tint, wrap: 'truncate' + Text key: step, color: @tint, display: @shape, wrap: 'truncate' "#{step} #{'x'.repeat 30}" Text "live" view = mount Steps, cols: 20 @@ -1988,6 +1993,9 @@ test "an item is painted once, at the terminal's width: a change to it or its re view.frame() eq view.bytes, '', 'a written item that changes writes nothing' eq view.scrollback, "a xxxxxxxxxxxxxxxxx…\n" + view.app.shape.value = 'flex' + view.frame() + eq view.scrollback, "a xxxxxxxxxxxxxxxxx…\n", 'an item shown again by its own component is still written once' view.app.done.value = ['a', 'b'] view.frame() eq view.scrollback, "a xxxxxxxxxxxxxxxxx…\n\x1b[31mb xxxxxxxxxxxxxxxxx…\x1b[0m\n", 'the new item is written in its color, the old one not again' @@ -2104,7 +2112,10 @@ test "print writes above the frame, ending its line; print.err the same on stder eq view.stderr, "oops\n" view.frame() eq view.bytes, "\x1b[?2026h\x1b[J\x1b[1Glive\r\x1b[?2026l", 'the frame is drawn again below' - eq view.scrollback, "warning\ntwo\nlines\noops\n" + view.app.done.value = ['step'] + print 'after the step' + view.frame() + eq view.scrollback, "warning\ntwo\nlines\noops\nstep\nafter the step\n", 'an item in the tree when print is called goes first' finally view.close() @@ -2118,6 +2129,31 @@ test "rowsToString is the grid's serializer, plain and with escape sequences", - finally view.close() +test! "examples/log.rip runs headless: the steps scroll into the scrollback as the clock moves, the spinner turns, the warning is printed, and it quits when done or on q", -> + view = mount BuildLog, cols: 60, rows: 10 + try + first = view.frame() + ok first.startsWith('⠋ resolve'), JSON.stringify first + view.tick 80 + ok view.frame().startsWith('⠙ resolve'), 'the spinner turned' + view.tick 700 * 3 + later = view.frame() + ok later.startsWith('⠧ link'), JSON.stringify later + # The warning's effect runs before the render block adds the step it + # is about, so it stands above that step. + eq bare(view.scrollback), "✓ resolve\n✓ fetch\nwarning: compile took the slow road\n✓ compile\n" + ok view.bytes.includes("\x1b]9;4;1;50\x1b\\"), 'the terminal knows how far along' + view.tick 700 * 3 + eq await view.done, undefined + eq typeof document, 'undefined', 'the app quit when the last step was done' + finally + view.close() + view = mount BuildLog, cols: 60 + view.frame() + view.press 'q' + await view.done + eq typeof document, 'undefined' + test "Newline breaks a line inside text, `count` times", -> One = component render From 656fb596ed00d30191e574e7b59c298785bd0fee Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 21:01:20 -0700 Subject: [PATCH 08/18] =?UTF-8?q?tui:=20one=20road=20above=20the=20frame?= =?UTF-8?q?=20=E2=80=94=20the=20console=20relays=20through=20Screen.above,?= =?UTF-8?q?=20progress=20cleared=20on=20every=20way=20out,=20the=20screen'?= =?UTF-8?q?s=20own=20alt=20and=20interactive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A console line takes the road a Static item takes: Screen.above clears the frame, writes the line to its stream, moves the frame's row down by the lines written, and books the frame that draws it again, so the clear, the line and the frame go out in one write. terminal.rip's own clear-and-diff path is gone; under the alternate screen a line is still kept and replayed once the screen is left, the one case above does not carry. Teardown clears the progress indicator after the last frame. The screen's `alt` and `interactive` fields are the one record of both; off a terminal the frame writes nothing and the last one is written once at exit through rowsToString, so a Static item and a print are written as they arrive. A frame booked before a close draws nothing after it, and Clock.tick's `stop` no longer shadows the terminal's. Pins re-spelled to above's coalesced write: a log's clear, line and frame are one write on the next frame, which the line books; a stderr line's clear goes out at once; with the mouse on, a log moves the frame's row instead of asking for it again. Added: a Static item and a print under the alternate screen and off a terminal, the log example under both, the progress cleared on a signal, a frame booked before a quit. --- packages/tui/PLAN.md | 32 +++++--- packages/tui/TODO.md | 5 -- packages/tui/screen.rip | 31 ++++--- packages/tui/terminal.rip | 71 +++++++--------- packages/tui/test/terminal.rip | 117 +++++++++++++++++++++++---- packages/tui/test/terminal/child.rip | 6 +- packages/tui/tui.rip | 17 ++-- 7 files changed, 183 insertions(+), 96 deletions(-) diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index 6fa73d61..10d80ed4 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -131,17 +131,17 @@ write by hand; hot loops use the indexed `for x, i in` form). | Module | Job | Code lines | |---|---|---| -| `tui.rip` | Entry: `run`, `mount` and its input, `renderToString`, `suspend`, `screen`, `focus`, widgets, `print`, `clock`; the delivery of events, the default actions | 309 | +| `tui.rip` | Entry: `run`, `mount` and its input, `renderToString`, `suspend`, `screen`, `focus`, widgets, `print`, `clock`; the delivery of events, the default actions | 310 | | `document.rip` | Terminal document: nodes, tree links, the event and its dispatch, style road, keyboard traits, damage marks | 388 | | `focus.rip` | Who can hold focus, tree order, taking it, settling it | 62 | | `layout.rip` | Flexbox, containing blocks, baseline, cache, edge rounding | 1,466 | | `text.rip` | Sanitize, grapheme clusters, width, wrap, truncate | 421 | | `paint.rip` | Cell grids, styles at the terminal's depth, clip, borders, backgrounds, the selection overlay, damage, diff, a subtree painted once | 694 | -| `screen.rip` | Frames, pacing, the cursor, where the frame sits, the write above the frame (`Static`, `print`, the console), progress, the alternate screen | 165 | +| `screen.rip` | Frames, pacing, the cursor, where the frame sits, the write above the frame (`Static`, `print`, the console), progress, the alternate screen | 173 | | `input.rip` | Key tokenizer and decoder, paste, mouse, replies | 315 | | `mouse.rip` | Hit test, the mouse events, hover, selection and the clipboard | 211 | -| `terminal.rip` | Setup / teardown: raw mode, the modes, the probes, the cursor, the alternate screen, the signals, suspend and resume, the console, the depth read | 192 | -| | **Total** | **4,071 built; about 4,250 complete** | +| `terminal.rip` | Setup / teardown: raw mode, the modes, the probes, the cursor, the alternate screen, the signals, suspend and resume, the console, the depth read | 181 | +| | **Total** | **4,222 built; about 4,250 complete** | Lines are counted as §2 counts them: non-blank and non-comment. Events, focus, the cursor and stdin are 285 of them (85 in `tui.rip`, 99 in @@ -1007,10 +1007,12 @@ A suspend leaves it and a resume enters it again. **Non-TTY and CI.** When stdout is no terminal, or `CI` is set to anything but `''`, `'0'` or `'false'`, `screen.interactive` reads -false: no modes, no cursor bytes, no probe, no raw mode even on a -stdin that is a terminal; frames are laid out and kept, and the last -is written once at exit, as text — with its escapes when a depth is -forced. `altScreen` and the console capture are nothing there. +false (`Screen.interactive`): no modes, no cursor bytes, no probe, no +raw mode even on a stdin that is a terminal; frames are laid out and +kept, a `Static` item and a `print` are written as they arrive, and +the last frame is written once at exit through `rowsToString` — with +its escapes when a depth is forced. `altScreen` and the console +capture are nothing there. **Colors.** The depth is read once at `run` and exposed as `screen.colors` (0, 16, 256 or 16777216): `NO_COLOR` set is none; @@ -1025,13 +1027,17 @@ at full intensity; at none, no style sends anything. `mount` and **Console.** `console.log` bypasses `process.stdout.write`, so while an app runs on a terminal the five methods are replaced and given -back at teardown. Inline, a line clears the frame from its top-left, -writes the line to the stream it always went to, and draws the frame -again below it, so logs scroll into the scrollback above the app — and -with the mouse on, the frame's row is asked again; on the alternate +back at teardown. A line takes the road a `Static` item takes +(`Screen.above`, §6): inline, the frame is cleared from its top-left, +the line written to the stream it always went to, and the frame drawn +again below it, all in the next frame's write, which the line books — +so logs scroll into the scrollback above the app, and the frame's row +moves down by the lines written, with no probe; a line for stderr has +its clear written at once and the frame follows. On the alternate screen lines are kept and replayed once the screen is left; a log while suspended, or with the app closed, is the console's own. `run -App, console: false` leaves the console alone. +App, console: false` leaves the console alone. The progress indicator +(§9) is cleared on every way out, after the last frame. ## 9. Public surface diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index 28061383..073cd887 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -74,11 +74,6 @@ steps are in [PLAN.md](PLAN.md). ## 7. The terminal -- [ ] The last frame written on a stdout that is no terminal is the - grid's own text: clipped to the stream's `rows` (24 when it says - nothing), and at a forced depth padded to its width, since a row - with a background cannot be trimmed blind. `rowsToString` (PLAN - §6) is the serializer it should share with `Static`. - [ ] A log while the console is captured is written to the run's stdout, as Ink writes it, even when that is a stream of the caller's own: a test whose app is still live when it fails hands diff --git a/packages/tui/screen.rip b/packages/tui/screen.rip index cb32e6f0..4e8c2626 100644 --- a/packages/tui/screen.rip +++ b/packages/tui/screen.rip @@ -51,7 +51,8 @@ export class Screen @owed = 0 # the cells the last frame owed @parked = null # where the cursor is shown, `{ x, y }` on the grid, or null while it is hidden @origin = 0 # the terminal row the frame's top-left is on: what the cursor probe answers, and lower once a frame scrolls the terminal - @alt = false # the alternate screen: the frame's top-left is the screen's, and `leave` moves nowhere + @alt = false # the alternate screen: the frame's top-left is the screen's, nothing is written above, and `leave` moves nowhere + @interactive = true # whether frames go to a terminal someone is watching (terminal.rip); off one, frames are kept and only text is written @selection = { from: -1, to: -1 } # the selected cells (mouse.rip), none while `from` is under zero @after = null # run after every frame: the pointer looks again at what is under it @statics = [] # the `Static` containers mounted (tui.rip), in the order they arrived @@ -65,19 +66,20 @@ export class Screen get cols: -> @out.columns ?? 80 get rows: -> @out.rows ?? 24 - # Write `text` above the live frame — a `Static` batch, a `print` line - # — in the frame's own write: the frame's rows are cleared, the text - # is written where they were, and the frame is drawn again below it, - # whole. Console capture (terminal.rip) takes this road for a - # console.log line during a run. With `err`, the clear goes out at - # once and the text to `err`, and the frame follows on the next - # write. Off a terminal the text goes as it is; on the alternate - # screen nothing is written above. + # Write `text` above the live frame — a `Static` batch, a `print` line, + # a console line (terminal.rip) — in the frame's own write: the + # frame's rows are cleared, the text is written where they were, and + # the frame is drawn again below it, whole, all in the next frame's + # write, which the text books. With `err`, the clear goes out at once + # and the text to `err`, and the frame follows on the next write. The + # frame's row moves down by the lines written. Off a terminal the + # text goes as it is; on the alternate screen nothing is written + # above. above!: (text, err = null) -> return unless text @still() unless @batching # what `Static` holds goes first @scrollback += text - if @interactive is false + unless @interactive (err ?? @out).write text return return if @alt @@ -109,7 +111,7 @@ export class Screen if @written.has kid then (kid.set 'display', 'none' if kid.styles.display isnt 'none') else fresh.push kid continue unless fresh.length and showing el, @doc.body grid = still el, @cols - @above rowsToString(grid, @interactive isnt false) + '\n' if grid.rows + @above rowsToString(grid, @interactive) + '\n' if grid.rows @written.add kid for kid in fresh finally @batching = false @@ -124,7 +126,7 @@ export class Screen else if value is 'indeterminate' then '3' else if typeof value is 'number' and value >= 0 and value <= 1 then "1;#{Math.round value * 100}" else throw Error.new "rip/tui: progress: #{JSON.stringify value} is not a number from 0 to 1, 'error', 'indeterminate' or null" - return if @interactive is false or (state is '0' and not @reported) + return if not @interactive or (state is '0' and not @reported) @reported = state isnt '0' @osc = "\x1b]9;4;#{state}\x1b\\" @doc.owe false @@ -168,6 +170,7 @@ export class Screen @front = back if damage.whole damage.shape @front, false @after?() + return unless @interactive spot = @place() return unless bytes or @lead or @osc or spot?.x isnt @parked?.x or spot?.y isnt @parked?.y bytes = @lead + @unpark() + bytes @@ -224,8 +227,10 @@ export class Screen if wait > 0 then setTimeout (=> @drawBooked()), wait else queueMicrotask (=> @drawBooked()) # A booked frame has no caller to throw to, so its failure goes to - # whoever runs the app. + # whoever runs the app. One drawn by hand meanwhile, or a close, has + # taken the booking. drawBooked!: -> + return unless @booked try @frame() catch error diff --git a/packages/tui/terminal.rip b/packages/tui/terminal.rip index fc7d24f6..b0ffed49 100644 --- a/packages/tui/terminal.rip +++ b/packages/tui/terminal.rip @@ -8,7 +8,7 @@ # life: there is no share of raw mode to count (PLAN §8). import { format } from 'node:util' -import { diff } from './paint.rip' +import { rowsToString } from './paint.rip' HIDE =! "\x1b[?25l" SHOW =! "\x1b[?25h" @@ -49,9 +49,12 @@ export depth =! (out) -> return 2 if /256color/i.test env.TERM ?? '' 1 +# Whether the app is on a terminal someone is watching: what `run` +# read of its stdout, on the screen (`Screen.interactive`). +watched =! (held) -> held.terminal and held.view.interactive + # A stream that keeps nothing: where frames go while the terminal is -# another's, or is no terminal, so the screen sees the size and writes -# nothing. +# another's, so the screen sees the size and writes nothing. export class Quiet constructor: (@out) -> get columns: -> @out.columns @@ -68,8 +71,8 @@ export class Quiet export setup! =! (held) -> return if held.up held.up = true - { out, stdin } = held - if held.tty + { out, stdin, view } = held + if watched held out.write HIDE if stdin?.isTTY and typeof stdin.setRawMode is 'function' stdin.setRawMode true @@ -79,7 +82,7 @@ export setup! =! (held) -> stdin.on 'data', held.read held.pushed = held.enhanced out.write ASK + (if held.mouse then track(held.mouse, true) else '') + (if held.pushed then PUSH else '') - if held.alt + if view.alt out.write ENTER held.entered = true capture held if held.console @@ -90,11 +93,12 @@ export setup! =! (held) -> # flag popped if it was pushed; the mouse, paste and focus modes # withdrawn; the last frame drawn and left in the scrollback with the # cursor shown below it (`drawn`), or the cursor shown where it is; the -# alternate screen left; the parser flushed; stdin cooked, paused and -# let go; the handlers off the process; the console given back and the -# lines it kept replayed. On a stdout that is no terminal, `drawn` -# writes the last frame once, as text. Answers the first error a step -# raised, or null. +# progress indicator cleared if one was reported; the alternate screen +# left; the parser flushed; stdin cooked, paused and let go; the +# handlers off the process; the console given back and the lines it +# kept replayed. On a stdout nobody is watching, `drawn` writes the +# last frame once, as text. Answers the first error a step raised, or +# null. down =! (held, drawn) -> return null unless held.up held.up = false @@ -105,12 +109,13 @@ down =! (held, drawn) -> catch caught error ?= caught { out, stdin, view } = held - if held.tty + if watched held step -> held.pointer?.clear() if held.raw step -> out.write (if held.pushed then POP else '') + (if held.mouse then track(held.mouse, false) else '') + WITHDRAW held.pushed = false step -> if drawn then view.leave() else out.write view.unpark() + SHOW + step -> out.write osc if osc = view.clearProgress() if held.entered held.entered = false step -> out.write LEAVE @@ -122,8 +127,8 @@ down =! (held, drawn) -> step -> stdin.pause() step -> stdin.unref?() else if drawn - step -> view.leave() - step -> out.write view.front.toString(held.depth > 0) + '\n' if view.front + step -> view.frame() if view.doc.stale + step -> out.write rowsToString(view.front, held.depth > 0) + '\n' if view.front process.off name, fn for name, fn of held.handlers step -> release held error @@ -181,7 +186,7 @@ export ask! =! (held) -> # answers, until one of the two has decided it. export probe! =! (held) -> return unless held.raw - ask held if held.mouse and not held.alt + ask held if held.mouse and not held.view.alt held.out.write QUERY if held.probing # The terminal's answers. The cursor's row — an answer to the package's @@ -216,7 +221,7 @@ export reply =! (held, event) -> # change books one as ever, and the resume draws whole. pause! =! (held) -> held.suspended = true - held.view.out = Quiet.new held.out if held.tty + held.view.out = Quiet.new held.out if watched held # Take the terminal again — unless the app was closed meanwhile — ask # the questions again, forget the press whose release was never seen @@ -227,7 +232,7 @@ resume! =! (held) -> process.off 'SIGCONT', held.wake if held.wake held.wake = null return if held.over - held.view.out = held.out if held.tty + held.view.out = held.out setup held probe held held.pointer?.reset() @@ -241,7 +246,7 @@ resume! =! (held) -> # and nothing changes hands. Refused while one is under way. export handover =! (held, fn) -> throw Error.new 'rip/tui: the terminal is already suspended — resume before suspending again' if held.suspended - if held.tty and (error = down held, true) + if watched(held) and (error = down held, true) setup held throw error pause held @@ -254,7 +259,7 @@ export handover =! (held, fn) -> # have, and take the terminal again on SIGCONT. Nothing on a stdout # that is no terminal, on Windows, or while a hand-over is under way. export stop! =! (held) -> - return unless held.tty and process.platform isnt 'win32' and not held.suspended + return unless watched(held) and process.platform isnt 'win32' and not held.suspended down held, true pause held held.wake = -> resume held @@ -268,9 +273,10 @@ LEVELS =! { log: 'out', info: 'out', debug: 'out', warn: 'err', error: 'err' } # The console's methods bypass `process.stdout.write`, so they are # replaced for the run and given back at teardown, when a suspend # hands the terminal over as well. A line goes to the stream it always -# went to: inline, the frame is cleared first and drawn again below the -# line, so it scrolls into the scrollback above the app; on the -# alternate screen it is kept, and replayed once the screen is left. +# went to, by the road a `Static` item takes (`Screen.above`): inline, +# the frame is cleared first and drawn again below the line, so it +# scrolls into the scrollback above the app; on the alternate screen it +# is kept, and replayed once the screen is left. capture! =! (held) -> held.was = {} for name, stream of LEVELS @@ -285,23 +291,8 @@ release! =! (held) -> held.logs = [] relay! =! (held, stream, text) -> - if held.alt + view = held.view + if view.alt held.logs.push [stream, text] return - view = held.view - spot = view.parked - held.out.write view.unpark() + "\x1b[J" - stream.write text - # With no frame to draw again, a whole one is booked: it lands below. - front = view.front - return unless front - front.lo.fill 0 - front.hi.fill front.cols - bytes = diff null, front - view.parked = spot - if spot - bytes += "\x1b[#{spot.y}B" if spot.y - bytes += "\x1b[#{spot.x + 1}G" + SHOW - held.out.write "\x1b[?2026h#{bytes}\x1b[?2026l" - # The line scrolled the terminal: where the frame sits is asked again. - ask held if held.mouse and held.raw + view.above text, (if stream is held.out then null else stream) diff --git a/packages/tui/test/terminal.rip b/packages/tui/test/terminal.rip index 6099dee9..d74892f7 100644 --- a/packages/tui/test/terminal.rip +++ b/packages/tui/test/terminal.rip @@ -16,14 +16,18 @@ # ============================================================================== import { test as pin, eq, ok, throws, plainEnv } from 'rip/testing' -import { run, quit, suspend, mount, renderToString, screen, Box, Text } from 'rip/tui' +import { run, quit, suspend, mount, renderToString, print, screen, Box, Text, Static } from 'rip/tui' +import { Log } from '../examples/log.rip' import { Terminal, Stdin, count, differs, tally } from './events/harness.rip' import { join } from 'path' # A test that fails with its app still live would hand its own report # to the app's console capture and leave the next test refused: every -# test ends with a quit, which is nothing when nothing runs. +# async test ends with a quit, which is nothing when nothing runs. A +# sync test is run as it is: its body is over before the next begins, +# and a quit deferred past it would reach that next test's app. test =! (name, fn) -> + return pin name, fn unless fn.constructor.name is 'AsyncFunction' pin name, -> try fn! @@ -48,6 +52,7 @@ ENTER =! "#{CSI}?1049h#{CSI}H" # the alternate screen, and its LEAVE =! "#{CSI}?1049l" BEGIN =! "#{CSI}?2026h" # a synchronized update END =! "#{CSI}?2026l" +CLEARED =! "\x1b]9;4;0\x1b\\" # the progress indicator cleared (OSC 9;4) report =! (bits, x, y, final = 'M') -> "#{CSI}<#{bits};#{x + 1};#{y + 1}#{final}" down =! (x, y) -> report 0, x, y @@ -425,6 +430,7 @@ for option in ['omitted', 'undefined'] options.console = undefined if option is 'undefined' running = run Hello, options console.log 'First log' + sleep! 20 eq term.text, "First log\nHello World" quit() await running.done @@ -520,6 +526,17 @@ test! "a teardown is taken once: a quit while suspended withdraws nothing twice, sleep! 0 eq term.sent.length, mark +test! "a frame booked before a quit is drawn by the close, and nothing is drawn after it", -> + term = Terminal.new() + running = run Echo, stdin: Stdin.new(), stdout: term + running.app.label.value = 'last' + quit() + await running.done + ok term.text.endsWith('last'), JSON.stringify term.text + mark = term.sent.length + sleep! 30 + eq term.sent.length, mark + test "a teardown after a setup that failed halfway takes the steps that were taken and no other", -> before = listeners() stdin = Stdin.new() @@ -536,13 +553,13 @@ test "a teardown after a setup that failed halfway takes the steps that were tak console.log "\nSignals" for [name, code] in [['SIGTERM', 143], ['SIGHUP', 129], ['SIGINT', 130]] - test! "#{name} gives the terminal back — the mouse, the modes, the cursor below the frame — and exits #{code}", -> + test! "#{name} gives the terminal back — the mouse, the modes, the cursor below the frame, the progress cleared — and exits #{code}", -> child = Child.new 'signal' - child.seen! END + child.seen! "\x1b]9;4;1;50\x1b\\" child.signal name result = child.finish! eq result.code, code - ok result.out.endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify result.out.slice -60 + ok result.out.endsWith("#{QUIET}#{OFF}\n#{SHOW}#{CLEARED}"), JSON.stringify result.out.slice -60 eq result.err, '' test! "an uncaught error gives the terminal back, prints the error, and exits 1", -> @@ -759,6 +776,42 @@ test! "a log under the alternate screen is kept, and replayed once the screen is ok term.since(mark).endsWith("#{SHOW}#{LEAVE}one\n"), JSON.stringify term.since mark eq [err.sent, term.text, printed], [['two\n', 'three\n'], 'one', []] +Steps =! component + @done := [] + render + Box flexDirection: 'column' + Static + for name in @done + Text key: name, "✓ #{name}" + Text "live" + +test! "a Static item and a print under the alternate screen write nothing, above or in the frame, and the primary screen is as it was", -> + stdin = Stdin.new() + term = Terminal.new() + term.write 'prompt$ ' + running = run Steps, stdin: stdin, stdout: term, altScreen: true + running.app.done.value = ['one', 'two'] + print 'aside' + running.flush() + sleep! 20 + eq [term.text, term.saved.lines.length], ['live', 1] + ok not term.since(0).includes('one') and not term.since(0).includes('aside'), JSON.stringify term.since 0 + quit() + await running.done + eq term.text, 'prompt$' + +test! "examples/log.rip under the alternate screen: the steps done are nothing, the live line turns, and the screen comes back as it was", -> + term = Terminal.new() + running = run Log, stdin: Stdin.new(), stdout: term, altScreen: true + sleep! 800 + ok term.text.includes('fetch') and term.text.includes('1/6'), JSON.stringify term.text + ok not term.since(0).includes('✓ resolve'), 'the step done was written nowhere' + ok term.since(0).includes("\x1b]9;4;1;17\x1b\\"), 'the terminal knows how far along' + quit() + await running.done + eq [term.text, term.modes.has(1049)], ['', false] + ok term.since(0).endsWith(CLEARED + LEAVE), JSON.stringify term.since(0).slice -40 + test! "altScreen on a stdout that is no terminal is nothing", -> pipe = Pipe.new() running = run Echo, stdin: Stdin.new(), stdout: pipe, altScreen: true @@ -785,6 +838,30 @@ test! "on a stdout that is no terminal nothing is asked — no modes, no cursor, stdin.key 'a' eq running.app.keys.value, [] +test! "off a terminal a Static item and a print are written as they arrive, and the last frame once at exit", -> + pipe = Pipe.new() + running = run Steps, stdin: Stdin.new(), stdout: pipe + eq pipe.sent, [] + running.app.done.value = ['one'] + running.flush() + eq pipe.sent, ['✓ one\n'] + print 'aside' + running.app.done.value = ['one', 'two'] + running.flush() + eq pipe.sent, ['✓ one\n', 'aside\n', '✓ two\n'] + quit() + await running.done + eq pipe.text, "✓ one\naside\n✓ two\nlive\n" + +test! "examples/log.rip off a terminal: the clock never runs, and the first line is written once at exit", -> + pipe = Pipe.new() + running = run Log, stdin: Stdin.new(), stdout: pipe + sleep! 200 + eq pipe.sent, [] + quit() + await running.done + eq pipe.text, "⠋ resolve [░░░░░░░░░░░░] 0/6\n" + test! "CI set makes a terminal stdout the same, and CI=false does not", -> process.env.CI = '1' try @@ -926,7 +1003,7 @@ Field =! component Box focusable: true, autofocus: true, cursor: { x: 1, y: 0 } Text "field" -test! "a log during a run clears the frame, writes the line as it was, and draws the frame again below it, the cursor parked where it was", -> +test! "a log during a run clears the frame, writes the line as it was, and draws the frame again below it, the cursor parked where it was — one write, with the frame the line books", -> recorded! (printed) -> stdin = Stdin.new() term = Terminal.new() @@ -934,12 +1011,16 @@ test! "a log during a run clears the frame, writes the line as it was, and draws eq term.cursor, { x: 1, y: 0 } mark = term.sent.length console.log 'hello', 42 + eq term.sent.length, mark, 'nothing goes out until the frame' + sleep! 20 out = term.since mark - ok out.startsWith("#{HIDE}\r#{CSI}Jhello 42\n#{BEGIN}#{CSI}J"), JSON.stringify out + eq term.sent.length, mark + 1, 'the clear, the line and the frame are one write' + ok out.startsWith("#{BEGIN}#{HIDE}\r#{CSI}Jhello 42\n#{CSI}J"), JSON.stringify out ok out.endsWith("#{CSI}2G#{SHOW}#{END}"), JSON.stringify out eq [term.text, term.cursor, printed], ["hello 42\nfield", { x: 1, y: 1 }, []] console.info 'two' console.debug 'three' + running.flush() eq term.text, "hello 42\ntwo\nthree\nfield" quit() await running.done @@ -952,8 +1033,9 @@ test! "warn and error go to stderr, the frame cleared for them and drawn again", mark = term.sent.length console.error 'bad', { n: 1 } console.warn 'worse' - eq err.sent, ['bad { n: 1 }\n', 'worse\n'] - ok term.since(mark).startsWith("#{CSI}J#{BEGIN}#{CSI}J"), JSON.stringify term.since mark + eq [err.sent, term.since(mark)], [['bad { n: 1 }\n', 'worse\n'], "#{CSI}J#{CSI}J"], 'the clear goes out at once, the line to stderr' + running.flush() + ok term.since(mark).startsWith("#{CSI}J#{CSI}J#{BEGIN}#{CSI}J"), JSON.stringify term.since mark eq [term.text, printed], ['x', []] quit() await running.done @@ -984,14 +1066,19 @@ test! "console: false leaves the console alone", -> quit() await running.done -test! "with the mouse on, a log asks where the frame went", -> +test! "with the mouse on, the frame's row moves down by the lines a log writes, with no probe: a click lands where the frame is now", -> recorded! (printed) -> + stdin = Stdin.new() term = Terminal.new() - running = run Echo, stdin: Stdin.new(), stdout: term, mouse: true + running = run Ways, stdin: stdin, stdout: term, mouse: true + stdin.key "#{CSI}?4;1R" + console.log "two\nlines" + running.flush() eq term.queries, ['cursor'] - console.log 'scrolls' - ok term.since(0).endsWith(PROBE), JSON.stringify term.since(0).slice -30 - eq term.queries, ['cursor', 'cursor'] + stdin.key down(0, 5) + up(0, 5) + eq running.app.heard.value, ['click'] + stdin.key down(0, 3) + up(0, 3) + eq running.app.heard.value, ['click'], 'the row the frame was on is nothing now' quit() await running.done @@ -1071,7 +1158,7 @@ test! "random keys, resizes, logs and suspends, then a random way out: the termi console.log "log #{n}" logged.push "log #{n}" unless alt - sleep! 10 + running.flush() ok term.text.includes("log #{n}") and term.text.endsWith('f'), "#{name}: after a log the screen shows #{JSON.stringify term.text}" when 'suspend' suspends += 1 diff --git a/packages/tui/test/terminal/child.rip b/packages/tui/test/terminal/child.rip index 295b7183..852f129e 100644 --- a/packages/tui/test/terminal/child.rip +++ b/packages/tui/test/terminal/child.rip @@ -5,7 +5,7 @@ # this reads every byte, and on the harness's stdin. The argument is # the way out; `pipe` and `ci` run on the process's stdout as it is. -import { run, quit, Box, Text } from 'rip/tui' +import { run, quit, screen, Box, Text } from 'rip/tui' import { Stdin } from '../events/harness.rip' way = process.argv[2] @@ -38,4 +38,6 @@ switch way quit() , 30 when 'drain' then null - else setInterval (->), 1000 + else + screen.progress 0.5 + setInterval (->), 1000 diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index 351ae4e8..3b069e9c 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -10,7 +10,7 @@ import { Parser } from './input.rip' import { Screen } from './screen.rip' import { Pointer } from './mouse.rip' import { palette } from './paint.rip' -import { Quiet, setup, probe, reply, teardown, handover, stop as halt, ask, interactive as watched, depth as depthOf } from './terminal.rip' +import { setup, probe, reply, teardown, handover, stop as halt, ask, interactive as watched, depth as depthOf } from './terminal.rip' # ── Widgets ─────────────────────────────────────────────────────────────────── # A box is a div and text is a span. Every prop a widget does not @@ -67,7 +67,7 @@ export print =! (text, err = null) -> text += '\n' unless text.endsWith '\n' return (err ?? process.stdout).write text unless live live.view.above text, err -print.err = (text) -> print text, live?.out.err ?? process.stderr +print.err = (text) -> print text, live?.err ?? process.stderr # ── Animation ───────────────────────────────────────────────────────────────── @@ -276,17 +276,17 @@ open =! (App, options, out, terminal, stdin = null, clock = undefined) -> keys = options.keyboard ?? 'basic' throw Error.new "rip/tui: keyboard: #{JSON.stringify keys} is not 'basic' or 'enhanced'" unless keys is 'basic' or keys is 'enhanced' { doc, restore } = install() - tty = terminal and watched out - view = Screen.new doc, (if terminal and not tty then Quiet.new out else out) + view = Screen.new doc, out view.whole = options.damage is false - view.alt = tty and options.altScreen is true + view.interactive = not terminal or watched out + view.alt = terminal and view.interactive and options.altScreen is true # On a terminal the frame sits wherever the cursor was, which the # probe answers; until then, and where nothing answers, at the bottom. # The alternate screen's is its top. view.origin = if terminal and not view.alt then Infinity else 0 cols = view.cols rows = view.rows - interactive = not terminal or tty + interactive = view.interactive depth = if terminal then depthOf out else 3 colors = DEPTHS[depth] palette depth @@ -302,12 +302,12 @@ open =! (App, options, out, terminal, stdin = null, clock = undefined) -> resized = -> return if view.cols is cols and view.rows is rows refresh() - ask held if held.mouse and held.raw and not held.alt + ask held if held.mouse and held.raw and not view.alt settled = {} done = Promise.new (resolve, reject) -> settled.resolve = resolve settled.reject = reject - live = held = { app: null, view, out, err: options.stderr ?? process.stderr, doc, restore, resized, redraw: refresh, done, settled, terminal, tty, alt: view.alt, depth, console: options.console isnt false, closing: false, over: false, parser: null, stdin, read: null, clock, mouse, pointer: null, probing: keys is 'enhanced', asked: false, enhanced: false, pushed: false, raw: false, up: false, entered: false, suspended: false, wake: null, handlers: null, was: null, logs: [], close: (drawn) -> close drawn if live is held } + live = held = { app: null, view, out, err: options.stderr ?? out.err ?? process.stderr, doc, restore, resized, redraw: refresh, done, settled, terminal, depth, console: options.console isnt false, closing: false, over: false, parser: null, stdin, read: null, clock, mouse, pointer: null, probing: keys is 'enhanced', asked: false, enhanced: false, pushed: false, raw: false, up: false, entered: false, suspended: false, wake: null, handlers: null, was: null, logs: [], close: (drawn) -> close drawn if live is held } held.parser = Parser.new { clock, late: (events) -> receive held, events } held.read = (chunk) -> receive held, held.parser.feed chunk if mouse @@ -429,6 +429,7 @@ close =! (drawn, error = null, result = undefined) -> held.over = true keyboard = 'basic' doc.onOwe = null + view.booked = false step = (work) -> try work() From e73a46bb1e1024d0f34100303d48267df35d169b Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 21:35:36 -0700 Subject: [PATCH 09/18] =?UTF-8?q?tui:=20the=20process's=20handlers=20belon?= =?UTF-8?q?g=20to=20the=20app's=20life=20=E2=80=94=20on=20at=20open,=20ahe?= =?UTF-8?q?ad=20of=20the=20runtime's,=20off=20first=20at=20teardown;=20a?= =?UTF-8?q?=20signal=20death=20maps=20to=20128=20+=20n=20in=20bin/rip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/rip | 3 ++- packages/tui/terminal.rip | 34 ++++++++++++++++++++++------------ packages/tui/test/terminal.rip | 11 ++++++++++- packages/tui/tui.rip | 6 ++++-- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/bin/rip b/bin/rip index 88262d18..7a470b7a 100755 --- a/bin/rip +++ b/bin/rip @@ -8,6 +8,7 @@ import { readFileSync, writeFileSync, existsSync, statSync, rmSync, mkdirSync, readdirSync, realpathSync } from 'fs'; import { spawn, spawnSync } from 'child_process'; import { randomBytes } from 'crypto'; +import os from 'os'; import { join, dirname, resolve, relative, sep, delimiter } from 'path'; import { fileURLToPath } from 'url'; import packageJson from '../package.json' with { type: 'json' }; @@ -566,7 +567,7 @@ const run = (path, argv) => { watchdog.unref(); return new Promise(() => { child.once('error', () => process.exit(1)); - child.once('exit', (code) => process.exit(code ?? 1)); + child.once('exit', (code, signal) => process.exit(code ?? (signal ? 128 + os.constants.signals[signal] : 1))); }); }; diff --git a/packages/tui/terminal.rip b/packages/tui/terminal.rip index b0ffed49..fbed1aef 100644 --- a/packages/tui/terminal.rip +++ b/packages/tui/terminal.rip @@ -63,13 +63,26 @@ export class Quiet # ── Setup and teardown ──────────────────────────────────────────────────────── +# The process's handlers — the signals, the crash, the exit — belong to +# the app's life under `run`, never to the terminal's state: put on as +# `open` takes the app, ahead of the runtime's own and any the app +# adds, and taken off as the first step of `teardown`, whatever the +# terminal's state is then. +export guard! =! (held) -> + held.handlers = handlers held + process.prependListener name, fn for name, fn of held.handlers + +unguard! =! (held) -> + return unless held.handlers + process.off name, fn for name, fn of held.handlers + held.handlers = null + # Take the terminal: the cursor hidden; stdin raw and read, with the # paste and focus modes and the mouse's asked for, and the flag pushed # again where the keyboard was decided; the alternate screen entered; -# the console captured; the signals, the crash and the exit handled. -# On a stdout that is no terminal, only the handlers. +# the console captured. Nothing off a terminal, or under `mount`. export setup! =! (held) -> - return if held.up + return if held.up or not held.terminal held.up = true { out, stdin, view } = held if watched held @@ -86,8 +99,6 @@ export setup! =! (held) -> out.write ENTER held.entered = true capture held if held.console - held.handlers ?= handlers held - process.on name, fn for name, fn of held.handlers # Give the terminal back, every step taken whatever the others do: the # flag popped if it was pushed; the mouse, paste and focus modes @@ -95,10 +106,9 @@ export setup! =! (held) -> # cursor shown below it (`drawn`), or the cursor shown where it is; the # progress indicator cleared if one was reported; the alternate screen # left; the parser flushed; stdin cooked, paused and let go; the -# handlers off the process; the console given back and the lines it -# kept replayed. On a stdout nobody is watching, `drawn` writes the -# last frame once, as text. Answers the first error a step raised, or -# null. +# console given back and the lines it kept replayed. On a stdout +# nobody is watching, `drawn` writes the last frame once, as text. +# Answers the first error a step raised, or null. down =! (held, drawn) -> return null unless held.up held.up = false @@ -129,13 +139,13 @@ down =! (held, drawn) -> else if drawn step -> view.frame() if view.doc.stale step -> out.write rowsToString(view.front, held.depth > 0) + '\n' if view.front - process.off name, fn for name, fn of held.handlers step -> release held error -# The way out: the terminal given back, and a continue nobody waits -# for any more forgotten. +# The way out: the handlers off the process first, then the terminal +# given back, and a continue nobody waits for any more forgotten. export teardown =! (held, drawn) -> + unguard held error = down held, drawn process.off 'SIGCONT', held.wake if held.wake held.wake = null diff --git a/packages/tui/test/terminal.rip b/packages/tui/test/terminal.rip index d74892f7..bcf5aff5 100644 --- a/packages/tui/test/terminal.rip +++ b/packages/tui/test/terminal.rip @@ -499,7 +499,7 @@ test! "every way out — quit, Ctrl-C, a listener that throws, a suspend then qu eq term.text, (if alt then '' else 'w'), "#{name}: what the screen shows" eq term.cursor, { x: 0, y: (if alt or way is 'a listener that throws' then 0 else 1) }, "#{name}: where the cursor lands" -test! "the run's handlers stand on the process only while it runs, and a second run sees no stale one", -> +test! "the run's handlers stand on the process only while the app is live, and a second run sees no stale one", -> before = listeners() running = run Echo, stdin: Stdin.new(), stdout: Terminal.new() eq listeners(), (n + 1 for n in before.slice(0, 3)).concat(before.slice(3, 4), (n + 1 for n in before.slice(4))) @@ -512,6 +512,15 @@ test! "the run's handlers stand on the process only while it runs, and a second await running.done eq listeners(), before +test! "a suspend under mount hands nothing over and puts no handler on the process, before or after the close", -> + before = listeners() + view = mount Echo + ran = false + suspend! -> ran = true + eq [ran, listeners()], [true, before] + view.close() + eq listeners(), before + test! "a teardown is taken once: a quit while suspended withdraws nothing twice, and a close after a close writes nothing", -> stdin = Stdin.new() term = Terminal.new() diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index 3b069e9c..8389e5c9 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -10,7 +10,7 @@ import { Parser } from './input.rip' import { Screen } from './screen.rip' import { Pointer } from './mouse.rip' import { palette } from './paint.rip' -import { setup, probe, reply, teardown, handover, stop as halt, ask, interactive as watched, depth as depthOf } from './terminal.rip' +import { guard, setup, probe, reply, teardown, handover, stop as halt, ask, interactive as watched, depth as depthOf } from './terminal.rip' # ── Widgets ─────────────────────────────────────────────────────────────────── # A box is a div and text is a span. Every prop a widget does not @@ -317,7 +317,9 @@ open =! (App, options, out, terminal, stdin = null, clock = undefined) -> keyboard = 'basic' selected = '' try - setup held if terminal + if terminal + guard held + setup held live.app = App.new(options.props ?? {}) live.app.mount doc.body failed doc From 780ee5ae5dabef59053ddebf6b14fb27f057ede8 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 21:36:35 -0700 Subject: [PATCH 10/18] tui: a crash closes the app with its error and leaves it to the listeners after this package's, once; a signal's way out never throws --- packages/tui/terminal.rip | 27 +++++++++++++++++++-------- packages/tui/test/terminal.rip | 33 +++++++++++++++++++++++++++++++++ packages/tui/tui.rip | 2 +- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/tui/terminal.rip b/packages/tui/terminal.rip index fbed1aef..0dd7a51e 100644 --- a/packages/tui/terminal.rip +++ b/packages/tui/terminal.rip @@ -153,19 +153,30 @@ export teardown =! (held, drawn) -> # ── Signals, the crash, the exit ────────────────────────────────────────────── -# A signal gives the terminal back and exits with 128 plus its number; -# an uncaught error or an unhandled rejection gives it back and hands -# the error to whoever else handles it — the runtime, which prints it -# with its frames remapped — or prints it and exits 1 itself; the loop -# draining closes the app as `quit` would; a `process.exit` with the -# app live gives the terminal back on the way. +# A signal closes the app — its last frame drawn, the terminal given +# back, `done` resolved — and exits with 128 plus its number, so a +# `done` an app awaits never settles: the process is gone before any +# continuation runs. A signal that arrives while the process is +# stopped stays pending until it is continued (POSIX; a shell sends +# the continue itself), and then the continue and the signal, in +# either order, end the same way, the app's own exit hook run. An +# uncaught error or an unhandled rejection closes the app with the +# error — `done` rejects, and nobody need be awaiting it — then leaves +# the error to the listeners after this one, which the runtime already +# called once: its own, which prints the error with its frames +# remapped and exits 1, or the app's; with none, it is printed here +# and the exit is 1. The loop draining closes the app as `quit` would; +# a `process.exit` with the app live gives the terminal back on the +# way. Every step is taken whatever the others do: none of these +# throws. handlers =! (held) -> gone = (code) -> held.close true process.exit code crash = (name, error) -> - held.close true - return if process.emit name, error + held.done.catch -> null + held.close true, error + return if process.listenerCount name console.error error process.exit 1 { diff --git a/packages/tui/test/terminal.rip b/packages/tui/test/terminal.rip index bcf5aff5..65e510d7 100644 --- a/packages/tui/test/terminal.rip +++ b/packages/tui/test/terminal.rip @@ -1049,6 +1049,39 @@ test! "warn and error go to stderr, the frame cleared for them and drawn again", quit() await running.done +test! "an uncaught error closes the app with it — `done` rejects — and is left to the listeners after this package's, once; a signal closes the app and exits with 128 plus its number, `done` resolved", -> + before = listeners() + seen = [] + user = (error) -> seen.push error.message + exits = [] + held = process.exit + process.exit = (code) -> exits.push code + try + for name in ['uncaughtException', 'unhandledRejection'] + process.on name, user + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term + failure = null + running.done.catch (error) -> failure = error.message + process.emit name, Error.new "a #{name}" + sleep! 0 + eq [seen, failure, exits, listeners()], [["a #{name}"], "a #{name}", [], before.map((n, i) -> if HANDLED[i] is name then n + 1 else n)], name + every name, term, stdin, before.map (n, i) -> if HANDLED[i] is name then n + 1 else n + process.off name, user + seen.length = 0 + term = Terminal.new() + running = run Echo, stdin: Stdin.new(), stdout: term + result = 'pending' + running.done.then (-> result = 'resolved'), (-> result = 'rejected') + process.emit 'SIGTERM' + sleep! 0 + eq [exits, result, listeners()], [[143], 'resolved', before] + ok term.text.endsWith('x') and term.shown, 'the last frame stands, the cursor shown' + finally + process.exit = held + process.off name, user for name in ['uncaughtException', 'unhandledRejection'] + test! "the console is the process's own again on every way out, and a log with the app closed passes straight through", -> recorded! (printed) -> stdin = Stdin.new() diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index 8389e5c9..4256e3c2 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -307,7 +307,7 @@ open =! (App, options, out, terminal, stdin = null, clock = undefined) -> done = Promise.new (resolve, reject) -> settled.resolve = resolve settled.reject = reject - live = held = { app: null, view, out, err: options.stderr ?? out.err ?? process.stderr, doc, restore, resized, redraw: refresh, done, settled, terminal, depth, console: options.console isnt false, closing: false, over: false, parser: null, stdin, read: null, clock, mouse, pointer: null, probing: keys is 'enhanced', asked: false, enhanced: false, pushed: false, raw: false, up: false, entered: false, suspended: false, wake: null, handlers: null, was: null, logs: [], close: (drawn) -> close drawn if live is held } + live = held = { app: null, view, out, err: options.stderr ?? out.err ?? process.stderr, doc, restore, resized, redraw: refresh, done, settled, terminal, depth, console: options.console isnt false, closing: false, over: false, parser: null, stdin, read: null, clock, mouse, pointer: null, probing: keys is 'enhanced', asked: false, enhanced: false, pushed: false, raw: false, up: false, entered: false, suspended: false, wake: null, handlers: null, was: null, logs: [], close: (drawn, error = null) -> close drawn, error if live is held } held.parser = Parser.new { clock, late: (events) -> receive held, events } held.read = (chunk) -> receive held, held.parser.feed chunk if mouse From 175a8dca8702f11dd603c14a2d91b7e86ce2db17 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 21:37:13 -0700 Subject: [PATCH 11/18] tui: what is left of a read once the terminal is another's is nobody's --- packages/tui/tui.rip | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index 4256e3c2..72ce9eb5 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -206,12 +206,13 @@ fail! =! (held, error) -> # Each event is a turn of its own: the tree is as the key before left it. # A key goes to the focused node, or to the body while nothing has # focus, as DOM sends it; the document hears it either way. Once the app -# is closing, what is left of a read is nobody's. A mouse report is the +# is closing, or the terminal is another's, what is left of a read is +# nobody's. A mouse report is the # pointer's to hit-test, and dropped while the mouse is off; a reply # answers a probe. deliver! =! (held, events) -> for event in events - return unless live is held and not held.closing + return unless live is held and not held.closing and not held.suspended switch event.type when 'focus' then shown = true when 'blur' From c2cf5dabbfe6ffbcd14a511c8dbbc6e70541eeeb Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 21:45:28 -0700 Subject: [PATCH 12/18] tui: a stopped app holds the loop until the continue, so a timer-less app survives fg --- packages/tui/terminal.rip | 12 ++++++++++-- packages/tui/tui.rip | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/tui/terminal.rip b/packages/tui/terminal.rip index 0dd7a51e..39d6af01 100644 --- a/packages/tui/terminal.rip +++ b/packages/tui/terminal.rip @@ -149,6 +149,8 @@ export teardown =! (held, drawn) -> error = down held, drawn process.off 'SIGCONT', held.wake if held.wake held.wake = null + clearInterval held.hold if held.hold + held.hold = null error # ── Signals, the crash, the exit ────────────────────────────────────────────── @@ -252,6 +254,8 @@ resume! =! (held) -> held.suspended = false process.off 'SIGCONT', held.wake if held.wake held.wake = null + clearInterval held.hold if held.hold + held.hold = null return if held.over held.view.out = held.out setup held @@ -277,12 +281,16 @@ export handover =! (held, fn) -> resume held # Ctrl-Z: give the terminal back, stop the process as the shell would -# have, and take the terminal again on SIGCONT. Nothing on a stdout -# that is no terminal, on Windows, or while a hand-over is under way. +# have, and take the terminal again on SIGCONT. A stopped app has no +# timer and no stdin to keep the loop alive, so one is held until the +# continue: without it the loop drains on the continue and the app is +# closed before it wakes. Nothing on a stdout that is no terminal, on +# Windows, or while a hand-over is under way. export stop! =! (held) -> return unless watched(held) and process.platform isnt 'win32' and not held.suspended down held, true pause held + held.hold = setInterval (->), 0x7fffffff held.wake = -> resume held process.once 'SIGCONT', held.wake process.kill process.pid, 'SIGTSTP' diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index 72ce9eb5..90e086fc 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -308,7 +308,7 @@ open =! (App, options, out, terminal, stdin = null, clock = undefined) -> done = Promise.new (resolve, reject) -> settled.resolve = resolve settled.reject = reject - live = held = { app: null, view, out, err: options.stderr ?? out.err ?? process.stderr, doc, restore, resized, redraw: refresh, done, settled, terminal, depth, console: options.console isnt false, closing: false, over: false, parser: null, stdin, read: null, clock, mouse, pointer: null, probing: keys is 'enhanced', asked: false, enhanced: false, pushed: false, raw: false, up: false, entered: false, suspended: false, wake: null, handlers: null, was: null, logs: [], close: (drawn, error = null) -> close drawn, error if live is held } + live = held = { app: null, view, out, err: options.stderr ?? out.err ?? process.stderr, doc, restore, resized, redraw: refresh, done, settled, terminal, depth, console: options.console isnt false, closing: false, over: false, parser: null, stdin, read: null, clock, mouse, pointer: null, probing: keys is 'enhanced', asked: false, enhanced: false, pushed: false, raw: false, up: false, entered: false, suspended: false, wake: null, hold: null, handlers: null, was: null, logs: [], close: (drawn, error = null) -> close drawn, error if live is held } held.parser = Parser.new { clock, late: (events) -> receive held, events } held.read = (chunk) -> receive held, held.parser.feed chunk if mouse From fb2df5efda494b06d132f068f41df971d1d8b0aa Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 21:46:50 -0700 Subject: [PATCH 13/18] tui: Ctrl-Z stops the whole process group, and only when the app reads the process's own stdin --- packages/tui/terminal.rip | 20 ++++++--- packages/tui/test/terminal.rip | 78 +++++++++++++++++----------------- 2 files changed, 53 insertions(+), 45 deletions(-) diff --git a/packages/tui/terminal.rip b/packages/tui/terminal.rip index 39d6af01..48dfd222 100644 --- a/packages/tui/terminal.rip +++ b/packages/tui/terminal.rip @@ -280,12 +280,18 @@ export handover =! (held, fn) -> finally resume held -# Ctrl-Z: give the terminal back, stop the process as the shell would -# have, and take the terminal again on SIGCONT. A stopped app has no -# timer and no stdin to keep the loop alive, so one is held until the -# continue: without it the loop drains on the continue and the app is -# closed before it wakes. Nothing on a stdout that is no terminal, on -# Windows, or while a hand-over is under way. +# Ctrl-Z: give the terminal back, stop the process as the terminal's +# driver would have — the stop signal to the whole process group, which +# under `rip app.rip` holds the launcher too, so the shell sees one +# stopped job — and take the terminal again on SIGCONT. The signal is +# sent only when the app reads the process's own stdin: a stream of +# one's own — a test's, a `stdout:` given with no stdin — is not the +# terminal's job, so Ctrl-Z takes the same road and sends nothing, and +# whoever gave the stream continues the app with SIGCONT. A stopped app +# has no timer and no stdin to keep the loop alive, so one is held +# until the continue: without it the loop drains on the continue and +# the app is closed before it wakes. Nothing on a stdout that is no +# terminal, on Windows, or while a hand-over is under way. export stop! =! (held) -> return unless watched(held) and process.platform isnt 'win32' and not held.suspended down held, true @@ -293,7 +299,7 @@ export stop! =! (held) -> held.hold = setInterval (->), 0x7fffffff held.wake = -> resume held process.once 'SIGCONT', held.wake - process.kill process.pid, 'SIGTSTP' + process.kill 0, 'SIGTSTP' if held.stdin is process.stdin # ── The console ─────────────────────────────────────────────────────────────── diff --git a/packages/tui/test/terminal.rip b/packages/tui/test/terminal.rip index 65e510d7..487160da 100644 --- a/packages/tui/test/terminal.rip +++ b/packages/tui/test/terminal.rip @@ -624,57 +624,59 @@ test! "suspend gives the terminal back — the flag popped, the mouse, paste and quit() await running.done -test! "Ctrl-Z is a default action of keydown: the terminal is given back and the process stopped; SIGCONT takes it back", -> +test! "Ctrl-Z is a default action of keydown: the terminal is given back and the app waits for SIGCONT — on a stdin of its own no signal is sent, the keys left in the read are nobody's, and the handlers stay; the continue takes the terminal back, asks the row again, and draws whole", -> stopped = 0 - handler = -> - stopped += 1 - process.kill process.pid, 'SIGCONT' + handler = -> stopped += 1 process.on 'SIGTSTP', handler + before = listeners() try stdin = Stdin.new() term = Terminal.new() - running = run Keys, stdin: stdin, stdout: term + running = run Keys, stdin: stdin, stdout: term, mouse: true + stdin.key "#{CSI}?1;1R" + live = listeners() mark = term.sent.length - stdin.key '\x1a' - eq term.since(mark), "#{OFF}\n#{SHOW}" - eq stdin.raw, false + stdin.key '\x1aab' + eq term.since(mark), "#{QUIET}#{OFF}\n#{SHOW}" + eq [stdin.raw, stdin.calls.slice(-3), running.app.keys.value], [false, ['raw off', 'pause', 'unref'], ['z']], 'a listener hears Ctrl-Z, as it hears Ctrl-C; what follows it in the read is dropped' + sleep! 20 + eq [stopped, process.listenerCount('SIGCONT'), listeners().slice(0, 3)], [0, live[3] + 1, live.slice(0, 3)], "no stop signal for a stream of one's own; the continue is waited for; the signal handlers stand" + stdin.key 'c' + eq running.app.keys.value, ['z'], "a key while stopped is nobody's" at = term.sent.length - sleep! 50 - eq stopped, 1 - ok term.since(at).startsWith(ON), JSON.stringify term.since at - eq [stdin.raw, term.text], [true, "k\nk"] - stdin.key 'a' - eq running.app.keys.value, ['z', 'a'], 'a listener hears Ctrl-Z before its default action, as it hears Ctrl-C' + process.kill process.pid, 'SIGCONT' + sleep! 30 + ok term.since(at).startsWith("#{HIDE}#{ASK}#{MOUSE}#{PROBE}"), JSON.stringify term.since at + ok term.since(at).includes("#{BEGIN}#{CSI}J"), 'the frame is drawn whole' + eq [stdin.raw, term.text, process.listenerCount('SIGCONT')], [true, "k\nk", live[3]] + stdin.key 'd' + eq running.app.keys.value, ['z', 'd'] quit() await running.done + eq listeners(), before finally process.off 'SIGTSTP', handler test! "preventDefault on Ctrl-Z keeps the app on the terminal, and Ctrl-Z under mount is nothing", -> - stopped = 0 - handler = -> stopped += 1 - process.on 'SIGTSTP', handler - try - Holds = component - render - Box focusable: true, autofocus: true, @keydown: ((event) -> event.preventDefault() if event.key is 'z' and event.ctrlKey) - Text "h" - stdin = Stdin.new() - term = Terminal.new() - running = run Holds, stdin: stdin, stdout: term - mark = term.sent.length - stdin.key '\x1a' - sleep! 20 - eq [term.sent.length, stdin.raw, stopped], [mark, true, 0] - quit() - await running.done - view = mount Keys - view.press 'z', ctrl: true - sleep! 20 - eq [stopped, view.app.keys.value], [0, ['z']] - view.close() - finally - process.off 'SIGTSTP', handler + Holds = component + render + Box focusable: true, autofocus: true, @keydown: ((event) -> event.preventDefault() if event.key is 'z' and event.ctrlKey) + Text "h" + stdin = Stdin.new() + term = Terminal.new() + running = run Holds, stdin: stdin, stdout: term + mark = term.sent.length + live = process.listenerCount 'SIGCONT' + stdin.key '\x1a' + sleep! 20 + eq [term.sent.length, stdin.raw, process.listenerCount('SIGCONT')], [mark, true, live] + quit() + await running.done + view = mount Keys + view.press 'z', ctrl: true + sleep! 20 + eq [process.listenerCount('SIGCONT'), view.app.keys.value], [live, ['z']] + view.close() test! "a key that arrives while suspended is nobody's, a resize writes nothing, and a log is the console's own", -> recorded! (printed) -> From 4a4c33922757a803e9d8fd4af08633711b905a3e Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 21:53:23 -0700 Subject: [PATCH 14/18] =?UTF-8?q?tui:=20a=20shell=20with=20job=20control?= =?UTF-8?q?=20for=20the=20tests=20=E2=80=94=20Ctrl-Z,=20fg,=20SIGTERM=20wh?= =?UTF-8?q?ile=20stopped,=20keys=20in=20the=20read,=20stops=20twice=20?= =?UTF-8?q?=E2=80=94=20and=20every=20state=20against=20every=20way=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tui/test/terminal.rip | 213 +++++++++++++++++++++++++-- packages/tui/test/terminal/child.rip | 67 +++++---- packages/tui/test/terminal/ptyrun.py | 166 +++++++++++++++++++++ 3 files changed, 406 insertions(+), 40 deletions(-) create mode 100644 packages/tui/test/terminal/ptyrun.py diff --git a/packages/tui/test/terminal.rip b/packages/tui/test/terminal.rip index 487160da..9ebf72a4 100644 --- a/packages/tui/test/terminal.rip +++ b/packages/tui/test/terminal.rip @@ -20,6 +20,8 @@ import { run, quit, suspend, mount, renderToString, print, screen, Box, Text, St import { Log } from '../examples/log.rip' import { Terminal, Stdin, count, differs, tally } from './events/harness.rip' import { join } from 'path' +import { tmpdir } from 'os' +import { unlinkSync } from 'fs' # A test that fails with its app still live would hand its own report # to the app's console capture and leave the next test refused: every @@ -167,6 +169,69 @@ class Child await @pumping { code, out: @out, err } +# test/terminal/child.rip's `tty` way as a job under ptyrun.py's shell: +# a pty, a process group of its own, and the shell's own account of +# every stop, continue and exit, as JSON lines. `steps` is what the +# shell is scripted to do (ptyrun.py says how). A job that outlives the +# limit is continued and killed, with its shell. +class Pty + constructor: (steps, n = Pty.count += 1) -> + @keys = join tmpdir(), "rip-tui-#{process.pid}-#{n}.json" + Bun.write @keys, JSON.stringify steps + @events = [] + @out = '' + @job = null + @proc = Bun.spawn ['python3', join(import.meta.dir, 'terminal/ptyrun.py'), join(import.meta.dir, 'terminal'), @keys, '--', join(ROOT, 'bin/rip'), 'child.rip', 'tty'], { cwd: import.meta.dir, env: { ...plainEnv({ CI: 'false', TERM: 'xterm-256color' }), NO_COLOR: undefined }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } + @timer = setTimeout (=> @kill()), LIMIT + @pumping = @pump() + + pump!: -> + reader = @proc.stdout.getReader() + decoder = TextDecoder.new() + held = '' + loop + { done, value } = reader.read! + break if done + held += decoder.decode value, { stream: true } + lines = held.split '\n' + held = lines.pop() + for line in lines when line + event = JSON.parse line + @events.push event + @job = event.started if event.started? + @out += event.out if event.out? + + kill!: -> + if @job + for signal in ['SIGCONT', 'SIGKILL'] + try + process.kill -@job, signal + catch error + null + @proc.kill 'SIGKILL' + + # The shell's events, without the output; and the output before the + # first event of `kind`. + get states: -> (event for event in @events when not event.out?) + before: (kind, nth = 1) -> + text = '' + for event in @events + if event[kind]? + nth -= 1 + break if nth is 0 + text += event.out if event.out? + text + + finish: -> + code = await @proc.exited + clearTimeout @timer + err = Response.new(@proc.stderr).text! + await @pumping + unlinkSync @keys + { code, err, states: @states, out: @out } + +Pty.count = 0 + # ==[ Ink's cases ]== console.log "\nInk: suspend-terminal" @@ -512,6 +577,79 @@ test! "the run's handlers stand on the process only while the app is live, and a await running.done eq listeners(), before +# Every state an app can be in, against every way out: the process's +# listeners are as they were after each, and each way ends as it +# should — a signal recorded as 128 + n with `done` resolved, a crash +# seen once by the app's own handler with `done` rejected and no exit +# recorded, an `exit` or a `quit` resolving `done`. +test! "every state — running on a terminal, on a pipe, inside a suspend, stopped and continued, mounted, mounted after a suspend — against every way out: no listener left, and each way ends as it should", -> + before = listeners() + exits = [] + held = process.exit + process.exit = (code) -> exits.push code + seen = [] + user = (error) -> seen.push error.message + process.on 'uncaughtException', user + base = listeners() + Thrower = component + @heard := [] + render + Box focusable: true, autofocus: true, @keydown: ((event) => if event.key is 'X' then raise 'a listener failed' else @heard.push event.key) + Text "t" + ways = ['quit', 'Ctrl-C', 'a listener that throws', 'SIGTERM', 'uncaughtException', 'exit'] + for state in ['running on a terminal', 'running on a pipe', 'inside a suspend', 'stopped and continued', 'mounted', 'mounted after a suspend'] + for way in ways.concat (if state.startsWith 'mounted' then ['close'] else []) + name = "#{state}, #{way}" + continue if state is 'inside a suspend' and way in ['Ctrl-C', 'a listener that throws'] # a key while suspended is nobody's + continue if state is 'running on a pipe' and way in ['Ctrl-C', 'a listener that throws'] # off a terminal no key is read + continue if state.startsWith('mounted') and way in ['Ctrl-C', 'a listener that throws', 'SIGTERM', 'uncaughtException', 'exit'] # no handler stands under mount: a throw is the test's, a signal the process's + stdin = Stdin.new() + term = Terminal.new() + pipe = Pipe.new() + if state.startsWith 'mounted' + view = mount Thrower + suspend! (-> null) if state is 'mounted after a suspend' + eq listeners(), base, name + if way is 'quit' then quit() else view.close() + await view.done + eq listeners(), base, name + continue + running = run Thrower, stdin: stdin, stdout: (if state is 'running on a pipe' then pipe else term) + settled = 'pending' + running.done.then (-> settled = 'resolved'), (-> settled = 'rejected') + if state is 'stopped and continued' + stdin.key '\x1a' + process.kill process.pid, 'SIGCONT' + sleep! 20 + eq stdin.raw, true, "#{name}: continued" + leave = -> + switch way + when 'quit' then quit() + when 'Ctrl-C' then stdin.key '\x03' + when 'a listener that throws' then stdin.key 'X' + when 'SIGTERM' then process.emit 'SIGTERM' + when 'uncaughtException' then process.emit 'uncaughtException', Error.new "crashed #{name}" + # Only this package's exit handler — the first, since it is + # prepended — not the process's whole event, which the test + # reporter listens to as well. + when 'exit' then process.listeners('exit')[0] 0 + if state is 'inside a suspend' then suspend! leave else leave() + sleep! 0 + eq listeners(), base, name + eq typeof document, 'undefined', name + switch way + when 'SIGTERM' + eq [exits, settled], [[143], 'resolved'], name + exits.length = 0 + when 'uncaughtException' + eq [seen, exits, settled], [["crashed #{name}"], [], 'rejected'], name + seen.length = 0 + when 'a listener that throws' then eq settled, 'rejected', name + else eq [settled, exits], ['resolved', []], name + process.off 'uncaughtException', user + process.exit = held + eq listeners(), before + test! "a suspend under mount hands nothing over and puts no handler on the process, before or after the close", -> before = listeners() view = mount Echo @@ -585,6 +723,27 @@ test! "an unhandled rejection is the same way out", -> ok err.includes('a promise failed'), err ok out.replace(/exited\n$/, '').endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -60 +test! "an uncaught error while suspended: the app is closed, the error printed once, the cursor shown once, the exit 1", -> + child = Child.new 'throwsuspend' + { code, out, err } = child.finish! + eq code, 1 + eq [err.split('a timer failed while suspended').length - 1, out.split(SHOW).length - 1], [1, 1] + ok not err.includes('Maximum call stack'), err + ok out.endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -80 + +test! "an app's own uncaughtException handler sees the error once, with the terminal already given back, and its exit code stands", -> + child = Child.new 'userhandler' + { code, out, err } = child.finish! + eq [code, err], [5, 'handled once: a timer failed\n'] + ok out.endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -80 + +test! "a suspend under mount, then a close, leaves no handler behind: a later uncaught error is the runtime's, once", -> + child = Child.new 'mountsuspend' + { code, err } = child.finish! + eq code, 1 + eq err.split('a timer failed after the mount').length - 1, 1 + ok not err.includes('Maximum call stack'), err + test! "a `process.exit` with the app live tears the terminal down on the way, and keeps its code", -> child = Child.new 'exit' { code, out } = child.finish! @@ -708,18 +867,50 @@ test! "a suspend with no app running is refused by name", -> refused = error.message ok /no app/.test(refused), refused -test! "a stop and a continue, for real: the child gives the terminal back and stops itself; SIGCONT takes it back, and it quits clean", -> - child = Child.new 'stop' - child.seen! "#{QUIET}#{OFF}\n#{SHOW}" - sleep! 30 - child.signal 'SIGCONT' - { code, out, err } = child.finish! +# ==[ Job control ]== + +console.log "\nJob control" + +TORN =! "#{QUIET}#{OFF}\r\n#{SHOW}" # the teardown as the pty shows it: a line feed comes back as CR LF + +test! "Ctrl-Z under `rip app.rip`: the terminal is given back and the job — the launcher and the app — is stopped, as the shell sees it", -> + job = Pty.new [[0, '?count:0'], [200, '\x1a'], [400, '%fg'], [200, '\x03'], [400, '']] + { code, err, states, out } = job.finish! eq [code, err], [0, ''] - first = out.indexOf "#{QUIET}#{OFF}\n#{SHOW}" - rest = out.slice first + "#{QUIET}#{OFF}\n#{SHOW}".length - ok rest.startsWith("#{HIDE}#{ASK}#{MOUSE}#{PROBE}"), JSON.stringify rest.slice 0, 60 - ok rest.includes('count:0'), 'the frame is drawn again' - ok rest.endsWith("#{QUIET}#{OFF}\n#{SHOW}exited\n"), JSON.stringify rest.slice -60 + eq states.map((event) -> Object.keys(event)[0]), ['started', 'stopped', 'continued', 'exited', 'end'], JSON.stringify states + eq states[1], { stopped: 18 }, 'the job leader is stopped by SIGTSTP' + ok job.before('stopped').endsWith(TORN), JSON.stringify job.before('stopped').slice -60 + +test! "fg: the app takes the terminal back, asks the row again, draws whole with no timer to keep it alive, and hears the next key", -> + job = Pty.new [[0, '?count:0'], [200, '\x1a'], [400, '%fg'], [400, 'x'], [300, '\x03'], [400, '']] + { code, states, out } = job.finish! + eq code, 0 + eq states[2], { continued: 1 } + after = out.slice job.before('continued').length + ok after.startsWith("#{HIDE}#{ASK}#{MOUSE}#{PROBE}#{BEGIN}#{CSI}J#{CSI}1Gcount:0\r#{END}"), JSON.stringify after.slice 0, 80 + ok after.includes('key:x') and out.includes('exit hook code=0'), JSON.stringify after + +test! "SIGTERM while stopped: pending until the continue, then the app is closed and the exit is 143, the app's exit hook run, no continuation of `done`", -> + job = Pty.new [[0, '?count:0'], [200, '\x1a'], [300, '@SIGTERM'], [300, '%fg'], [800, '']] + { code, states, out } = job.finish! + eq code, 0 + ok states.some((event) -> event.stopped is 18), JSON.stringify states + ok states.some((event) -> event.exited is 143), JSON.stringify states + ok out.includes('exit hook code=143') and not out.includes('exited\r\n'), JSON.stringify out.slice -120 + ok out.replace(/exit hook code=143\r\n$/, '').endsWith(TORN), JSON.stringify out.slice -80 + +test! "keys in the read with Ctrl-Z are nobody's, and a key after the continue is heard", -> + job = Pty.new [[0, '?count:0'], [200, '\x1aab'], [400, '%fg'], [400, 'y'], [300, '\x03'], [400, '']] + { code, out } = job.finish! + eq code, 0 + eq (out.match(/key:\w+/g) ?? []), ['key:z', 'key:y', 'key:c'] + +test! "stopped and continued twice, then Ctrl-C: the shell sees two stops and two continues, and a clean exit", -> + job = Pty.new [[0, '?count:0'], [200, '\x1a'], [400, '%fg'], [400, '\x1a'], [400, '%fg'], [400, '\x03'], [400, '']] + { code, states } = job.finish! + eq code, 0 + eq states.map((event) -> Object.keys(event)[0]), ['started', 'stopped', 'continued', 'stopped', 'continued', 'exited', 'end'], JSON.stringify states + eq states[5], { exited: 0 } # ==[ The alternate screen ]== diff --git a/packages/tui/test/terminal/child.rip b/packages/tui/test/terminal/child.rip index 852f129e..400b939b 100644 --- a/packages/tui/test/terminal/child.rip +++ b/packages/tui/test/terminal/child.rip @@ -1,43 +1,52 @@ # A small app under `run`, spawned by test/terminal.rip to take one way # out for real — a signal, an uncaught error, a `process.exit`, a loop -# that drains, a stop and a continue — on a stdout that says it is a -# terminal and writes through to the process's own, so whoever spawned -# this reads every byte, and on the harness's stdin. The argument is +# that drains — on a stdout that says it is a terminal and writes +# through to the process's own, so whoever spawned this reads every +# byte, and on the harness's stdin; or, as `tty`, on the process's own +# streams under ptyrun.py's shell, with no timer, every key heard +# written to stderr and the exit hook's code with it. The argument is # the way out; `pipe` and `ci` run on the process's stdout as it is. -import { run, quit, screen, Box, Text } from 'rip/tui' +import { run, quit, suspend, mount, screen, Box, Text } from 'rip/tui' import { Stdin } from '../events/harness.rip' way = process.argv[2] tty = { isTTY: true, columns: 40, rows: 10, write: (text) -> process.stdout.write text } -stdin = Stdin.new() +stdin = if way is 'tty' then process.stdin else Stdin.new() App = component @count := 0 render - Box focusable: true, autofocus: true + Box focusable: true, autofocus: true, @keydown: ((event) -> process.stderr.write "key:#{event.key}\n" if way is 'tty') Text "count:#{@count}" -options = { stdin, stdout: (if way is 'pipe' then process.stdout else tty), mouse: true } -running = run App, options -running.done.then (-> console.log 'exited'), (error) -> console.log "errored: #{error.message}" - -switch way - when 'throw' then setTimeout (-> raise 'a timer failed'), 20 - when 'reject' then setTimeout (-> Promise.reject Error.new 'a promise failed'), 20 - when 'exit' then setTimeout (-> process.exit 7), 20 - when 'stop' - timer = setInterval (->), 1000 - process.on 'SIGCONT', -> setTimeout (-> clearInterval timer; quit()), 40 - setTimeout (-> stdin.key '\x1a'), 20 - when 'pipe', 'ci' - timer = setInterval -> - running.app.count.value += 1 - return unless running.app.count.value is 5 - clearInterval timer - quit() - , 30 - when 'drain' then null - else - screen.progress 0.5 - setInterval (->), 1000 +if way is 'mountsuspend' + view = mount App + suspend! -> null + view.close() + setTimeout (-> raise 'a timer failed after the mount'), 20 +else + process.on 'exit', (code) -> process.stderr.write "exit hook code=#{code}\n" if way is 'tty' + process.on 'uncaughtException', ((error) -> process.stderr.write "handled once: #{error.message}\n"; process.exit 5) if way is 'userhandler' + options = { stdin, stdout: (if way is 'pipe' or way is 'tty' then process.stdout else tty), mouse: true } + running = run App, options + running.done.then (-> console.log 'exited'), (error) -> console.log "errored: #{error.message}" + switch way + when 'throw', 'userhandler' then setTimeout (-> raise 'a timer failed'), 20 + when 'reject' then setTimeout (-> Promise.reject Error.new 'a promise failed'), 20 + when 'exit' then setTimeout (-> process.exit 7), 20 + when 'throwsuspend' + suspend! -> + setTimeout (-> raise 'a timer failed while suspended'), 20 + sleep! 500 + when 'pipe', 'ci' + timer = setInterval -> + running.app.count.value += 1 + return unless running.app.count.value is 5 + clearInterval timer + quit() + , 30 + when 'drain', 'tty' then null + else + screen.progress 0.5 + setInterval (->), 1000 diff --git a/packages/tui/test/terminal/ptyrun.py b/packages/tui/test/terminal/ptyrun.py new file mode 100644 index 00000000..5d5a1afc --- /dev/null +++ b/packages/tui/test/terminal/ptyrun.py @@ -0,0 +1,166 @@ +# A shell with job control, for test/terminal.rip: a pty is opened, the +# command runs in it as a job in a process group of its own, and what the +# shell would do is scripted from outside: keys typed, the job continued +# (%fg), a signal sent to it. Every event goes to stdout as one JSON line +# — started, stopped, continued, exited, killed, and the pty's output as +# it comes — so a test waits on states, never on time. A job that +# outlives the deadline is continued and killed, never left behind. +# +# python3 ptyrun.py -- cmd args... +# +# keys.json is [[delay_ms, step], ...]: a step is text to type, "?text" +# to wait until the pty has shown text, "%fg" to continue a stopped job +# (waiting for the stop first), or "@SIGNAL" to send that signal to the +# job's leader. + +import json, os, pty, select, signal, subprocess, sys, time + +cwd = sys.argv[1] +steps = json.load(open(sys.argv[2])) +cmd = sys.argv[sys.argv.index('--') + 1:] +DEADLINE = 8.0 + +def say(**event): + sys.stdout.write(json.dumps(event) + '\n') + sys.stdout.flush() + +# The shell tells the driver what the job does on one pipe, and the +# driver tells the shell to continue it on the other: neither goes +# through the pty, which is the job's alone. +told_r, told_w = os.pipe() +asked_r, asked_w = os.pipe() + +pid, fd = pty.fork() +if pid == 0: + # The shell: a session leader on the pty, with the job as its + # foreground process group, told of every stop and continue. + import fcntl, struct, termios + os.close(told_r) + os.close(asked_w) + fcntl.ioctl(0, termios.TIOCSWINSZ, struct.pack('HHHH', 10, 40, 0, 0)) + for s in (signal.SIGTSTP, signal.SIGTTIN, signal.SIGINT, signal.SIGTERM): + signal.signal(s, signal.SIG_DFL) + signal.signal(signal.SIGTTOU, signal.SIG_IGN) + env = dict(os.environ) + env.pop('CI', None) + env.pop('NO_COLOR', None) + env['TERM'] = 'xterm-256color' + job = subprocess.Popen(cmd, cwd=cwd, env=env, preexec_fn=lambda: os.setpgid(0, 0)) + try: + os.tcsetpgrp(0, job.pid) + except OSError: + pass + def tell(text): + os.write(told_w, (text + '\n').encode()) + tell('started %d' % job.pid) + asked = os.fdopen(asked_r) + while True: + try: + _, status = os.waitpid(job.pid, os.WUNTRACED | os.WCONTINUED) + except ChildProcessError: + os._exit(0) + if os.WIFSTOPPED(status): + tell('stopped %d' % os.WSTOPSIG(status)) + if asked.readline().strip() == 'fg': + try: + os.tcsetpgrp(0, job.pid) + os.killpg(job.pid, signal.SIGCONT) + except OSError: + pass + elif os.WIFCONTINUED(status): + tell('continued') + elif os.WIFEXITED(status): + tell('exited %d' % os.WEXITSTATUS(status)) + os._exit(0) + elif os.WIFSIGNALED(status): + tell('killed %d' % os.WTERMSIG(status)) + os._exit(0) + +# The driver: relays the pty and the shell's events, and runs the steps. +os.close(told_w) +os.close(asked_r) +job = None +stopped = 0 +continued = 0 +out = b'' +told = b'' +start = time.time() +due = start + steps[0][0] / 1000 if steps else None +at = 0 +open_fds = [fd, told_r] + +def heard(chunk): + global job, stopped, continued, told + told += chunk + lines = told.split(b'\n') + told = lines.pop() + for raw in lines: + line = raw.decode() + word = line.split() + if word[0] == 'started': + job = int(word[1]) + say(started=job) + elif word[0] == 'stopped': + stopped += 1 + say(stopped=int(word[1])) + elif word[0] == 'continued': + continued += 1 + say(continued=continued) + elif word[0] == 'exited': + say(exited=int(word[1])) + elif word[0] == 'killed': + say(killed=int(word[1])) + +def read(which): + global out + try: + chunk = os.read(which, 65536) + except OSError: + chunk = b'' + if not chunk: + open_fds.remove(which) + return + if which == fd: + out += chunk + say(out=chunk.decode('utf-8', 'replace')) + else: + heard(chunk) + +while open_fds and time.time() - start < DEADLINE: + ready, _, _ = select.select(open_fds, [], [], 0.02) + for which in ready: + read(which) + if due is not None and time.time() >= due and at < len(steps): + step = steps[at][1] + if step.startswith('?'): + if step[1:].encode() not in out: + continue + elif step == '%fg': + if stopped <= continued: + continue + os.write(asked_w, b'fg\n') + elif step.startswith('@'): + if job is None: + continue + os.kill(job, getattr(signal, step[1:])) + else: + os.write(fd, step.encode('latin-1')) + at += 1 + due = time.time() + steps[at][0] / 1000 if at < len(steps) else None + +# The loop ends when the shell has gone — both its fds closed — or at +# the deadline, with the job still there. +if open_fds: + if job is not None: + for s in (signal.SIGCONT, signal.SIGKILL): + try: + os.killpg(job, s) + except OSError: + pass + os.kill(pid, signal.SIGKILL) + say(timeout=True) +try: + os.waitpid(pid, 0) +except ChildProcessError: + pass +say(end=True) From 5bcbbac0ce70c6a0f2912c5c2cab49cfabfa18f6 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 21:57:07 -0700 Subject: [PATCH 15/18] tui: the depth from NO_COLOR, FORCE_COLOR, the terminal and CI as the conventions read them, and every lowered color the nearest of xterm's own table --- packages/tui/paint.rip | 64 ++++++++++++++++------------ packages/tui/terminal.rip | 18 +++++--- packages/tui/test/terminal.rip | 78 ++++++++++++++++++++++++++++++++-- 3 files changed, 125 insertions(+), 35 deletions(-) diff --git a/packages/tui/paint.rip b/packages/tui/paint.rip index 8eff2a6c..4803311a 100644 --- a/packages/tui/paint.rip +++ b/packages/tui/paint.rip @@ -46,39 +46,51 @@ tint =! (color, base) -> # The depth colors are sent at, 0 to 3 — none, the 16 named, the 256, # 24-bit — what `run` reads of the terminal once (terminal.rip). Below -# full depth a 24-bit color is sent as the nearest of the 256: the -# 6×6×6 cube, or the 24 grays for a gray; at 16 one of the 256 is sent -# as the nearest named one, bright where it is at full intensity -# (xterm's palette). At none, no style sends anything. +# full depth a 24-bit color is sent as the nearest of xterm's 256 by +# RGB distance — a color on the cube is that point — and below that as +# the nearest of xterm's 16, one of the 256 the same way. The table is +# xterm's own: its 16 defaults, the 6×6×6 cube at 0, 95, 135, 175, 215 +# and 255, and the 24 grays from 8 by tens. The cost is paid once per +# style interned, never per cell. At none, no style sends anything. depth = 3 -cube =! (r, g, b) -> - if r is g and g is b - return 16 if r < 8 - return 231 if r > 248 - return 232 + Math.round((r - 8) / 247 * 24) - 16 + 36 * Math.round(r / 255 * 5) + 6 * Math.round(g / 255 * 5) + Math.round(b / 255 * 5) - -named =! (n) -> - return n if n < 8 - return n + 52 if n < 16 - if n >= 232 - r = g = b = ((n - 232) * 10 + 8) / 255 - else - r = Math.floor((n - 16) / 36) / 5 - g = Math.floor((n - 16) % 36 / 6) / 5 - b = (n - 16) % 6 / 5 - bright = Math.max(r, g, b) * 2 - return 0 if bright is 0 - code = (Math.round(b) << 2) | (Math.round(g) << 1) | Math.round(r) - if bright is 2 then code + 60 else code +XTERM =! do -> + table = for hex in ['000000', 'cd0000', '00cd00', 'cdcd00', '0000ee', 'cd00cd', '00cdcd', 'e5e5e5', '7f7f7f', 'ff0000', '00ff00', 'ffff00', '5c5cff', 'ff00ff', '00ffff', 'ffffff'] + n = parseInt hex, 16 + [n >> 16, (n >> 8) & 255, n & 255] + levels = [0, 95, 135, 175, 215, 255] + for r in levels + for g in levels + table.push [r, g, b] for b in levels + table.push [8 + 10 * i, 8 + 10 * i, 8 + 10 * i] for i in [0...24] + table + +# The entry of the table from `from` to `to` nearest (r, g, b). +nearest =! (r, g, b, from, to) -> + best = from + least = Infinity + for i in [from...to] + [tr, tg, tb] = XTERM[i] + far = (r - tr) * (r - tr) + (g - tg) * (g - tg) + (b - tb) * (b - tb) + if far < least + least = far + best = i + best + +# The SGR offset of one of the 16: 0 to 7, or 60 up for the bright. +named =! (n) -> if n < 8 then n else n + 52 # A color's parameters at the depth in force. lower =! (sgr, base) -> return sgr if depth is 3 - sgr = "#{base + 8};5;#{cube +m[1], +m[2], +m[3]}" if m = /^\d8;2;(\d+);(\d+);(\d+)$/.exec sgr + if m = /^\d8;2;(\d+);(\d+);(\d+)$/.exec sgr + return "#{base + 8};5;#{nearest +m[1], +m[2], +m[3], 16, 256}" if depth is 2 + return String base + named nearest(+m[1], +m[2], +m[3], 0, 16) return sgr if depth is 2 - if m = /^\d8;5;(\d+)$/.exec sgr then String(base + named(+m[1])) else sgr + if m = /^\d8;5;(\d+)$/.exec sgr + [r, g, b] = XTERM[+m[1]] + return String base + named nearest(r, g, b, 0, 16) + sgr # A style's escape parameters: its switches, then its colors. dress =! (fg, bg, flags) -> diff --git a/packages/tui/terminal.rip b/packages/tui/terminal.rip index 48dfd222..a7cec864 100644 --- a/packages/tui/terminal.rip +++ b/packages/tui/terminal.rip @@ -36,14 +36,20 @@ export interactive =! (out) -> out?.isTTY is true and not (ci? and ci isnt '' and ci isnt '0' and ci isnt 'false') # The depth `out` is drawn at, 0 to 3 — no color, the 16 named, the -# 256, 24-bit — read once at `run`: NO_COLOR is none; FORCE_COLOR is -# its digit, or 16 colors for any other value; else COLORTERM's -# truecolor is 24-bit, TERM's 256color is 256, a dumb terminal or a -# stdout nobody is watching has none, and any other terminal has 16. +# 256, 24-bit — read once at `run`, in this order: NO_COLOR set to +# anything but '' is none (no-color.org); FORCE_COLOR '0' or 'false' +# is none, '' or 'true' the 16, a number that depth up to 3, any other +# word the 16 (supports-color's reading); a stdout nobody is watching, +# or a dumb terminal, has none; COLORTERM's truecolor is 24-bit; +# TERM's 256color is 256; any other terminal has 16. export depth =! (out) -> env = process.env - return 0 if env.NO_COLOR? - return (if /^[0-3]$/.test env.FORCE_COLOR then +env.FORCE_COLOR else 1) if env.FORCE_COLOR? + return 0 if env.NO_COLOR + if env.FORCE_COLOR? + forced = env.FORCE_COLOR + return 0 if forced is '0' or forced is 'false' + return Math.min(+forced, 3) if /^\d+$/.test forced + return 1 return 0 unless interactive(out) and env.TERM isnt 'dumb' return 3 if /truecolor|24bit/i.test env.COLORTERM ?? '' return 2 if /256color/i.test env.TERM ?? '' diff --git a/packages/tui/test/terminal.rip b/packages/tui/test/terminal.rip index 9ebf72a4..fc9a8885 100644 --- a/packages/tui/test/terminal.rip +++ b/packages/tui/test/terminal.rip @@ -1123,22 +1123,31 @@ depthUnder =! (env, out = Terminal.new()) -> for name, value of held if value? then process.env[name] = value else delete process.env[name] -test! "the depth is read once at run: NO_COLOR is none, FORCE_COLOR is its number, else TERM and COLORTERM decide; a dumb terminal, or no terminal, has none unless forced", -> +test! "the depth is read once at run: NO_COLOR set and not empty is none; FORCE_COLOR 0 or false none, empty or true the 16, a number that depth up to 3, any other word the 16; else no terminal or a dumb one has none, and TERM and COLORTERM decide", -> rows = [ [{ NO_COLOR: '1' }, 0] - [{ NO_COLOR: '', COLORTERM: 'truecolor' }, 0] + [{ NO_COLOR: '' }, 16] + [{ NO_COLOR: '', COLORTERM: 'truecolor' }, 16777216] [{ NO_COLOR: '1', FORCE_COLOR: '3' }, 0] [{ FORCE_COLOR: '0' }, 0] + [{ FORCE_COLOR: 'false' }, 0] + [{ FORCE_COLOR: '' }, 16] + [{ FORCE_COLOR: 'true' }, 16] + [{ FORCE_COLOR: 'yes' }, 16] [{ FORCE_COLOR: '1' }, 16] [{ FORCE_COLOR: '2' }, 256] [{ FORCE_COLOR: '3' }, 16777216] + [{ FORCE_COLOR: '4' }, 16777216] + [{ FORCE_COLOR: '256' }, 16777216] [{ FORCE_COLOR: 'true', TERM: 'dumb' }, 16] [{ TERM: 'xterm-256color' }, 256] + [{ TERM: 'screen-256color' }, 256] [{ TERM: 'xterm-256color', COLORTERM: 'truecolor' }, 16777216] [{ TERM: 'xterm', COLORTERM: '24bit' }, 16777216] [{ TERM: 'xterm' }, 16] [{}, 16] [{ TERM: 'dumb' }, 0] + [{ TERM: 'dumb', COLORTERM: 'truecolor' }, 0] ] for [env, want] in rows depth = depthUnder! env @@ -1182,11 +1191,74 @@ test! "a 24-bit color is sent as it is at full depth, as the nearest of the 256 cube = paintedUnder! { FORCE_COLOR: '2' } ok cube.includes("#{CSI}1;38;5;196;48;5;21mr") and cube.includes("#{CSI}0;32mg") and cube.includes("#{CSI}0;38;5;244mh"), JSON.stringify cube named = paintedUnder! { FORCE_COLOR: '1' } - ok named.includes("#{CSI}1;91;104mr") and named.includes("#{CSI}0;32mg") and named.includes("#{CSI}0;37mh"), JSON.stringify named + ok named.includes("#{CSI}1;91;44mr") and named.includes("#{CSI}0;32mg") and named.includes("#{CSI}0;90mh"), JSON.stringify named none = paintedUnder! { NO_COLOR: '1' } ok not /\x1b\[[0-9;]*m/.test(none), JSON.stringify none ok plain(none).includes('rgh'), JSON.stringify none +# xterm's sixteen, then points the cube, the grays and the palette +# disagree on: each as the nearest of the 256, and of the 16. +POINTS =! [ + ['#000000', 16, 30], ['#cd0000', 160, 31], ['#00cd00', 40, 32], ['#cdcd00', 184, 33] + ['#0000ee', 21, 34], ['#cd00cd', 164, 35], ['#00cdcd', 44, 36], ['#e5e5e5', 254, 37] + ['#7f7f7f', 244, 90], ['#ff0000', 196, 91], ['#00ff00', 46, 92], ['#ffff00', 226, 93] + ['#5c5cff', 63, 94], ['#ff00ff', 201, 95], ['#00ffff', 51, 96], ['#ffffff', 231, 97] + ['#808080', 244, 90], ['#5f5f5f', 59, 90], ['#bcbcbc', 250, 37], ['#eeeeee', 255, 37] + ['#f8f8f8', 231, 97], ['#ff5f00', 202, 91], ['#875faf', 97, 90], ['#870000', 88, 31] + ['#5f0000', 52, 30], ['#d7d7d7', 188, 37], ['#afafaf', 145, 90], ['#404040', 238, 90] + ['#585858', 240, 90], ['#767676', 243, 90], ['#4d4d4d', 239, 90], ['#8a8a8a', 245, 90] +] + +Swatches =! component + render + Box flexDirection: 'column' + for point in POINTS + Text key: point[0], color: point[0], "x" + +# The SGR parameters before each swatch, in order, under `env`. +swatchesUnder =! (env) -> + held = {} + for name in ['NO_COLOR', 'FORCE_COLOR', 'TERM', 'COLORTERM'] + held[name] = process.env[name] + delete process.env[name] + process.env[name] = value for name, value of env + try + term = Terminal.new 40, 40 + running = run Swatches, stdin: Stdin.new(), stdout: term + quit() + await running.done + Array.from term.since(0).matchAll(/\x1b\[0?;?([0-9;]+)mx/g), (m) -> m[1] + finally + for name, value of held + if value? then process.env[name] = value else delete process.env[name] + +test! "every color is the nearest of xterm's own table: its sixteen, a color on the cube as that point, a gray as the nearest gray or cube point, and below that the nearest of the sixteen — mid-grays bright black, never white", -> + at256 = swatchesUnder! { FORCE_COLOR: '2' } + eq at256, ("38;5;#{n}" for [hex, n] in POINTS) + at16 = swatchesUnder! { FORCE_COLOR: '1' } + eq at16, (String(n) for [hex, c, n] in POINTS) + ansi = swatchesUnder! { FORCE_COLOR: '1', COLORTERM: 'truecolor' } + eq ansi, at16, 'FORCE_COLOR is read before the terminal' + +test! "an ansi256 color below the 256 is the nearest of the sixteen by its own RGB", -> + Indexed = component + render + Box flexDirection: 'row' + Text color: 'ansi256(244)', "a" + Text color: 'ansi256(196)', "b" + Text color: 'ansi256(21)', "c" + Text color: 'ansi256(7)', "d" + held = process.env.FORCE_COLOR + process.env.FORCE_COLOR = '1' + try + term = Terminal.new() + running = run Indexed, stdin: Stdin.new(), stdout: term + quit() + await running.done + eq Array.from(term.since(0).matchAll(/\x1b\[0?;?([0-9;]+)m[abcd]/g), (m) -> m[1]), ['90', '91', '34', '37'] + finally + if held? then process.env.FORCE_COLOR = held else delete process.env.FORCE_COLOR + test "mount and renderToString stay at full depth whatever the environment says", -> held = process.env.NO_COLOR process.env.NO_COLOR = '1' From 863d91e73d9b636f4ffafa8bf6a0a4a6b45ee19d Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 21:58:44 -0700 Subject: [PATCH 16/18] tui: every console method that writes goes above the frame, through a Console of the runtime's own over two relaying sinks --- packages/tui/terminal.rip | 32 ++++++++++++++++++--------- packages/tui/test/terminal.rip | 40 +++++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/packages/tui/terminal.rip b/packages/tui/terminal.rip index a7cec864..e9f2a77c 100644 --- a/packages/tui/terminal.rip +++ b/packages/tui/terminal.rip @@ -8,6 +8,8 @@ # life: there is no share of raw mode to count (PLAN §8). import { format } from 'node:util' +import { Console } from 'node:console' +import { Writable } from 'node:stream' import { rowsToString } from './paint.rip' HIDE =! "\x1b[?25l" @@ -309,20 +311,30 @@ export stop! =! (held) -> # ── The console ─────────────────────────────────────────────────────────────── -LEVELS =! { log: 'out', info: 'out', debug: 'out', warn: 'err', error: 'err' } +# A stream whose every write is a line above the frame, to `stream`. +sink =! (held, stream) -> + Writable.new write: (chunk, encoding, next) -> + relay held, stream, String chunk + next() -# The console's methods bypass `process.stdout.write`, so they are -# replaced for the run and given back at teardown, when a suspend -# hands the terminal over as well. A line goes to the stream it always -# went to, by the road a `Static` item takes (`Screen.above`): inline, -# the frame is cleared first and drawn again below the line, so it -# scrolls into the scrollback above the app; on the alternate screen it -# is kept, and replayed once the screen is left. +# The console's methods bypass `process.stdout.write`, so every one +# that writes — `log`, `table`, `group`, `trace`, `assert`, `count`, +# `time` and the rest — is replaced for the run by a `Console` of the +# runtime's own over two sinks, and given back at teardown, when a +# suspend hands the terminal over as well. A line goes to the stream it +# always went to, by the road a `Static` item takes (`Screen.above`): +# inline, the frame is cleared first and drawn again below the line, +# so it scrolls into the scrollback above the app; on the alternate +# screen it is kept, and replayed once the screen is left. The +# runtime's `trace` drops its label, so it is written here as Node +# writes it: the label, then the stack from the caller down. capture! =! (held) -> + shadow = Console.new stdout: sink(held, held.out), stderr: sink(held, held.err), colorMode: false + shadow.trace = (...args) -> relay held, held.err, "Trace: #{format ...args}\n#{Error.new().stack.split('\n').slice(2).join '\n'}\n" held.was = {} - for name, stream of LEVELS + for name in Object.getOwnPropertyNames(shadow) when typeof shadow[name] is 'function' and typeof console[name] is 'function' held.was[name] = console[name] - console[name] = ((stream) -> (...args) -> relay held, held[stream], format(...args) + '\n')(stream) + console[name] = shadow[name] release! =! (held) -> return unless held.was diff --git a/packages/tui/test/terminal.rip b/packages/tui/test/terminal.rip index fc9a8885..3caa06d2 100644 --- a/packages/tui/test/terminal.rip +++ b/packages/tui/test/terminal.rip @@ -972,11 +972,12 @@ test! "a log under the alternate screen is kept, and replayed once the screen is console.log 'one' console.error 'two' console.warn 'three' + console.table [{ a: 1 }] eq [term.sent.length, err.sent, term.lines[0]?.join('')], [mark, [], 'x'] quit() await running.done - ok term.since(mark).endsWith("#{SHOW}#{LEAVE}one\n"), JSON.stringify term.since mark - eq [err.sent, term.text, printed], [['two\n', 'three\n'], 'one', []] + ok term.since(mark).endsWith("#{SHOW}#{LEAVE}one\n┌─────────┬───┐\n│ (index) │ a │\n├─────────┼───┤\n│ 0 │ 1 │\n└─────────┴───┘\n"), JSON.stringify term.since mark + eq [err.sent, term.text.split('\n')[0], printed], [['two\n', 'three\n'], 'one', []] Steps =! component @done := [] @@ -1347,6 +1348,32 @@ test! "an uncaught error closes the app with it — `done` rejects — and is le process.exit = held process.off name, user for name in ['uncaughtException', 'unhandledRejection'] +test! "every console method that writes takes the road: a table, a group's indent, an assert and a trace to stderr, a count, a time", -> + recorded! (printed) -> + term = Terminal.new() + err = Pipe.new() + running = run Echo, stdin: Stdin.new(), stdout: term, stderr: err + console.table [{ a: 1 }] + console.group 'G' + console.log 'in' + console.groupEnd() + console.count 'c' + console.time 't' + console.timeEnd 't' + console.assert false, 'nope' + console.trace 'here' + running.flush() + ok term.text.startsWith("┌─────────┬───┐\n│ (index) │ a │\n├─────────┼───┤\n│ 0 │ 1 │\n└─────────┴───┘\nG\n in\nc: 1\nt: "), JSON.stringify term.text + ok term.text.endsWith('\nx'), 'the frame stands below' + eq err.sent.length, 2 + eq err.sent[0], 'Assertion failed: nope\n' + ok err.sent[1].startsWith("Trace: here\n at "), JSON.stringify err.sent[1] + ok err.sent[1].includes('test/terminal.rip'), 'the stack begins at the caller' + eq printed, [] + quit() + await running.done + ok console.table isnt running.table and typeof console.table is 'function' + test! "the console is the process's own again on every way out, and a log with the app closed passes straight through", -> recorded! (printed) -> stdin = Stdin.new() @@ -1381,11 +1408,14 @@ test! "with the mouse on, the frame's row moves down by the lines a log writes, stdin.key "#{CSI}?4;1R" console.log "two\nlines" running.flush() - eq term.queries, ['cursor'] + console.log 'three' + running.flush() + eq term.queries, ['cursor'], 'one probe, at the start; a log moves the row by what it wrote' + stdin.key down(0, 3) + up(0, 3) stdin.key down(0, 5) + up(0, 5) + eq running.app.heard.value, [], 'the rows the frame was on are nothing now' + stdin.key down(0, 6) + up(0, 6) eq running.app.heard.value, ['click'] - stdin.key down(0, 3) + up(0, 3) - eq running.app.heard.value, ['click'], 'the row the frame was on is nothing now' quit() await running.done From c7a85e81618ffeaa1bd2daa36cbea33186fdb68e Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 22:00:25 -0700 Subject: [PATCH 17/18] tui: the docs say what a signal exit, a stop, the depth and the console do now, and SOURCE.md counts Ink's titles as it registers them --- packages/tui/PLAN.md | 78 ++++++++++++++++++---------- packages/tui/README.md | 50 ++++++++++++------ packages/tui/test/terminal/SOURCE.md | 56 ++++++++++++-------- 3 files changed, 118 insertions(+), 66 deletions(-) diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index 10d80ed4..c46aa7f4 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -970,24 +970,44 @@ probes are asked once the app stands, so a constructor that throws leaves no answer for the shell. **Signals.** An unhandled SIGTERM skips Bun's exit hooks, so SIGINT, -SIGTERM and SIGHUP have handlers that tear down and exit with 128 + n; -with raw mode on, Ctrl-C arrives as a key and its default action is -`quit`. An uncaught error or an unhandled rejection tears down, then -hands the error on to whoever else handles it — the runtime, which -prints it with its frames remapped (`src/cli/run.js`) and exits 1 — -or prints it and exits 1 itself. `beforeExit` closes a live app as -`quit` would, so a script whose loop drains ends with its last frame -in the scrollback; `exit` tears down an app still live when -`process.exit` is called with it on the terminal. The handlers stand -on the process only while the app runs. +SIGTERM and SIGHUP have handlers that close the app — its last frame +drawn, `done` resolved — and exit with 128 + n; a `done` an app +awaits never settles after a signal exit, since the process is gone +before any continuation runs. A signal that arrives while the process +is stopped stays pending until it is continued, and then the continue +and the signal, in either order, end the same way, the app's exit +hook run. With raw mode on, Ctrl-C arrives as a key and its default +action is `quit`. An uncaught error or an unhandled rejection closes +the app with the error — `done` rejects — and leaves the error to the +listeners after this package's, which the runtime already called +once: its own, which prints it with its frames remapped +(`src/cli/run.js`) and exits 1, or the app's; with none, it is printed +and the exit is 1. `beforeExit` closes a live app as `quit` would, so +a script whose loop drains ends with its last frame in the scrollback; +`exit` tears down an app still live when `process.exit` is called +with it on the terminal. The seven handlers belong to the app's life, +never to the terminal's state: put on the process as `open` takes the +app, ahead of the runtime's own and any the app adds, and taken off +as the first step of `teardown`, whatever the terminal's state is +then — never under `mount`. **Suspend.** Ctrl-Z is a default action of `keydown`, preventable like Ctrl-C: teardown with the cursor below the last frame, then -SIGTSTP to the process itself; SIGCONT sets up again, asks the -questions again — the frame's row with the mouse, the keyboard if it -was still undecided — forgets the press whose release was never seen -(`Pointer.reset`) and the bytes held mid-sequence (`parser.reset`), -and draws whole at whatever size the terminal has now. `suspend(fn)` +SIGTSTP to the whole process group — what the terminal's driver does +for a cooked Ctrl-Z, and under `rip app.rip` the group holds the +launcher too, so the shell sees one stopped job. The signal is sent +only when the app reads the process's own stdin: with any other +stream — a test's fake, a `stdout:` given with no stdin — Ctrl-Z +takes the same road and sends nothing, since a stream of one's own is +not the terminal's job, and whoever gave it continues the app with +SIGCONT to the process. While stopped, a timer is held so the loop +does not drain on the continue and close the app before it wakes. +SIGCONT sets up again, asks the questions again — the frame's row +with the mouse, the keyboard if it was still undecided — forgets the +press whose release was never seen (`Pointer.reset`) and the bytes +held mid-sequence (`parser.reset`), and draws whole at whatever size +the terminal has now; the keys left in the read with the Ctrl-Z are +nobody's. `suspend(fn)` is the same road without the signal: teardown, `await fn()`, setup, whole frame — for an editor or a shell that takes the terminal for a while. Meanwhile a booked frame goes nowhere, a key is nobody's, a @@ -1015,19 +1035,25 @@ its escapes when a depth is forced. `altScreen` and the console capture are nothing there. **Colors.** The depth is read once at `run` and exposed as -`screen.colors` (0, 16, 256 or 16777216): `NO_COLOR` set is none; -`FORCE_COLOR` 0–3 is that depth, and any other value 16; else -`COLORTERM` `truecolor` / `24bit` is 24-bit, `TERM` `256color` is -256, a dumb terminal or output nobody is watching none, and any other -terminal 16. The painter emits every style at that depth (`paint.rip`): -a 24-bit color becomes the nearest of the 256 — the 6×6×6 cube, the -24 grays for a gray — and one of the 256 the nearest of the 16, bright -at full intensity; at none, no style sends anything. `mount` and -`renderToString` stay at full depth. +`screen.colors` (0, 16, 256 or 16777216), in this order: `NO_COLOR` +set to anything but `''` is none (no-color.org); `FORCE_COLOR` `0` or +`false` is none, `''` or `true` the 16, a number that depth up to 3, +any other word the 16 (supports-color's reading); output nobody is +watching (no TTY, or CI) or `TERM=dumb` is none; `COLORTERM` +`truecolor` / `24bit` is 24-bit; `TERM` `*256color*` is 256; else 16. +The painter emits every style at that depth (`paint.rip`), from one +table of xterm's own 256 values: a 24-bit color becomes the nearest of +xterm's 256 by RGB distance (a color on the cube is sent as that +point), and below that the nearest of xterm's 16 — so a mid-gray is +bright black, never white; at none, no style sends anything. The cost +is paid per style interned, not per cell. `mount` and `renderToString` +stay at full depth. **Console.** `console.log` bypasses `process.stdout.write`, so while -an app runs on a terminal the five methods are replaced and given -back at teardown. A line takes the road a `Static` item takes +an app runs on a terminal its writing methods — `log`, `table`, +`group`, `trace`, `assert`, `count`, `time` and the rest — are +replaced by a `node:console` `Console` whose two sinks relay, and +given back at teardown. A line takes the road a `Static` item takes (`Screen.above`, §6): inline, the frame is cleared from its top-left, the line written to the stream it always went to, and the frame drawn again below it, all in the next frame's write, which the line books — diff --git a/packages/tui/README.md b/packages/tui/README.md index 5a2daf28..a75f3335 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -58,14 +58,24 @@ back on every way out, by one road: modes and the process's handlers are as they were. A failure leaves what reached the screen, and `done` rejects with it. - **Signals.** SIGINT, SIGTERM and SIGHUP give the terminal back and - exit with 128 plus the signal's number. An uncaught error or an - unhandled rejection gives it back, prints the error, and exits 1; a - `process.exit` with the app live gives it back on the way. -- **Suspend.** Ctrl-Z gives the terminal back and stops the process as - the shell would; `fg` draws the app again, whole, at the terminal's - size now. It is a default action of `keydown`, preventable like - Ctrl-C. `suspend fn` is the same road without the signal — the - terminal is `fn`'s until it settles: + exit with 128 plus the signal's number; a `done` an app awaits never + settles after a signal exit, since the process is gone before any + continuation runs. An uncaught error or an unhandled rejection gives + the terminal back, rejects `done` with the error, and leaves it to + the runtime — which prints it and exits 1 — or to the app's own + handler, once; a `process.exit` with the app live gives the terminal + back on the way. +- **Suspend.** Ctrl-Z gives the terminal back and stops the job as the + terminal would — the whole process group, so under `rip app.rip` the + shell sees one stopped job; `fg` draws the app again, whole, at the + terminal's size now, whether or not the app has a timer. It is a + default action of `keydown`, preventable like Ctrl-C. The stop + signal is sent only when the app reads the process's own stdin: with + any other stream — a test's, or a `stdout:` given with no stdin — + Ctrl-Z takes the same road and sends nothing, since a stream of one's + own is not the terminal's job, and whoever gave it continues the app + with `process.kill process.pid, 'SIGCONT'`. `suspend fn` is the same + road without the signal — the terminal is `fn`'s until it settles: ```coffee import { suspend } from 'rip/tui' @@ -81,15 +91,21 @@ back on every way out, by one road: - **CI and pipes.** On a stdout that is no terminal, or with `CI` set, nothing is asked of the terminal and the last frame alone is written, as text, at exit; `screen.interactive` reads false. -- **Colors.** The depth is read once at `run` — `NO_COLOR`, - `FORCE_COLOR` 0 to 3, else `COLORTERM` and `TERM` — and - `screen.colors` reads it: 0, 16, 256 or 16777216. A 24-bit color is - drawn as the nearest the terminal has. -- **Console.** While the app runs, `console.log` and its four siblings - clear the frame, write the line where it always went, and draw the - frame again below it, so logs scroll into the scrollback above the - app; on the alternate screen they are kept and replayed at exit. - `run App, console: false` leaves the console alone. +- **Colors.** The depth is read once at `run` and `screen.colors` + reads it: 0, 16, 256 or 16777216. `NO_COLOR` set to anything but the + empty string is none; `FORCE_COLOR` `0` or `false` is none, empty or + `true` the 16, a number that depth up to 3, any other word the 16; + otherwise a pipe, CI or a dumb terminal is none, `COLORTERM` + `truecolor` 24-bit, `TERM` `256color` 256, and any other terminal + 16. A 24-bit color is drawn as the nearest of xterm's 256 — a color + on the cube as that point — and below that as the nearest of xterm's + 16. +- **Console.** While the app runs, every console method that writes + (`log`, `table`, `group`, `trace`, `assert`, `count`, `time*`, …) + clears the frame, writes where it always went, and draws the frame + again below, so logs scroll into the scrollback above the app; on the + alternate screen they are kept and replayed at exit. `run App, + console: false` leaves the console alone. ## Widgets and styles diff --git a/packages/tui/test/terminal/SOURCE.md b/packages/tui/test/terminal/SOURCE.md index a2fe7fd3..177d3d8b 100644 --- a/packages/tui/test/terminal/SOURCE.md +++ b/packages/tui/test/terminal/SOURCE.md @@ -30,24 +30,26 @@ its throttled log-update are not carried over. ## What runs -Of the 46 titles in these files, 27 are ported and 19 are left out. -`test/terminal.rip` holds `harness.rip`'s tally to these counts. +Of the 70 titles in these files, 27 are held, 1 differs, and 42 are +left out. A title is counted once per case Ink registers, so a `test(` +site inside a loop counts once per turn of the loop; both columns are +given. `test/terminal.rip` holds `harness.rip`'s tally to these counts. -| Ink file | titles | held | differs | left out | -|---|---|---|---|---| -| `suspend-terminal` | 14 | 8 | 0 | 6 | -| `suspension-exit` | 2 | 1 | 0 | 1 | -| `suspension-output` | 3 | 1 | 0 | 2 | -| `suspension-resize` | 2 | 2 | 0 | 0 | -| `suspension-handle` | 2 | 0 | 0 | 2 | -| `suspension-input-disable` | 6 | 0 | 0 | 6 | -| `kitty-negotiation` (suspension rows) | 2 | 1 | 1 | 0 | -| `exit` | 15 | 9 | 0 | 6 | -| `errors` | 7 | 1 | 0 | 6 | -| `error-overview` | 6 | 0 | 0 | 6 | -| `alternate-screen-example` | 2 | 0 | 0 | 2 | -| `render` (console rows) | 2 | 2 | 0 | 0 | -| `components` (CI rows) | 2 | 2 | 0 | 0 | +| Ink file | sites | titles | held | differs | left out | +|---|---|---|---|---|---| +| `suspend-terminal` | 11 | 14 | 8 | 0 | 6 | +| `suspension-exit` | 1 | 2 | 1 | 0 | 1 | +| `suspension-output` | 2 | 3 | 1 | 0 | 2 | +| `suspension-resize` | 1 | 2 | 2 | 0 | 0 | +| `suspension-handle` | 1 | 2 | 0 | 0 | 2 | +| `suspension-input-disable` | 6 | 7 | 0 | 0 | 7 | +| `kitty-negotiation` (suspension rows) | 2 | 2 | 1 | 1 | 0 | +| `exit` | 15 | 15 | 9 | 0 | 6 | +| `errors` | 8 | 8 | 1 | 0 | 7 | +| `error-overview` | 7 | 7 | 0 | 0 | 7 | +| `alternate-screen-example` | 2 | 2 | 0 | 0 | 2 | +| `render` (console rows) | 1 | 2 | 2 | 0 | 0 | +| `components` (CI rows) | 4 | 4 | 2 | 0 | 2 | The suspension rows of `kitty-negotiation` were left out of `test/mouse/SOURCE.md` for this file; `waitUntilExit preserves the @@ -132,7 +134,7 @@ them and the exit code is the process's own. stderr writes — Ink's `useStdout().write`; a write to a stream is the stream's own here - `suspension-handle`: both — handles -- `suspension-input-disable`: all six — Ink's input hooks each own a +- `suspension-input-disable`: all seven — Ink's input hooks each own a share of raw mode and of bracketed paste, and a resume restores the shares that still have an owner; the host owns the terminal for the app's life here, with no share to disable (PLAN §8) @@ -152,9 +154,17 @@ them and the exit code is the process's own. when render exits with an error and waitUntilExit is unused — `done` is always handed back, and a rejection nobody awaits is the caller's; waitUntilExit preserves the original component error — - `test/events.rip` pins that `done` rejects with what a listener threw -- `error-overview`: all six — the deferred reporter; stacks are the - runtime's own (`src/cli/run.js`) + `test/events.rip` pins that `done` rejects with what a listener threw; + waitUntilExit preserves a component error from another realm — a + `vm` context; an error is whatever object was thrown here +- `error-overview`: all seven, "does not emit duplicate key warnings + for repeated stack lines" among them — the deferred reporter; stacks + are the runtime's own (`src/cli/run.js`) - `alternate-screen-example`: both — the snake game's reducer, and a - fixture that prints its state; the alternate screen's bytes are - pinned by this package's own rows + fixture that prints its state; the alternate screen's bytes and the + leave order are pinned by this package's own rows ("The alternate + screen" in `test/terminal.rip`) +- `components`: debug mode in CI does not replay final frame during + unmount teardown; debug mode in CI keeps final newline separation + after waitUntilExit — Ink's + `debug` option, which writes every frame whole, has no counterpart From 0f2ee999fcf40c050b63164d581291bdd34b2ea5 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Mon, 21 Sep 2026 22:01:51 -0700 Subject: [PATCH 18/18] tui: Static's cleanup reads its node directly, now that a cleanup runs untracked; a Static under a swapping branch is built once --- packages/tui/test.rip | 27 +++++++++++++++++++++++++++ packages/tui/tui.rip | 8 ++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/tui/test.rip b/packages/tui/test.rip index 055d29ef..452f01f4 100644 --- a/packages/tui/test.rip +++ b/packages/tui/test.rip @@ -2130,6 +2130,33 @@ test "rowsToString is the grid's serializer, plain and with escape sequences", - finally view.close() +test "a Static under an if that swaps is built once for the new branch, its items written once, and the branch let go leaves no container behind", -> + Swap = component + @which := 'a' + render + Box flexDirection: 'column' + if @which is 'a' + Static + for name in ['one', 'two'] + Text key: name, "a #{name}" + else + Static + for name in ['three'] + Text key: name, "b #{name}" + Text "live #{@which}" + view = mount Swap, cols: 20 + try + eq view.frame(), 'live a' + eq [view.scrollback, view.held.view.statics.length], ["a one\na two\n", 1] + view.app.which.value = 'b' + eq view.frame(), 'live b' + eq [view.scrollback, view.held.view.statics.length], ["a one\na two\nb three\n", 1], 'the new container stands alone' + view.app.which.value = 'a' + eq view.frame(), 'live a' + eq [view.scrollback, view.held.view.statics.length], ["a one\na two\nb three\na one\na two\n", 1], 'a branch built again is new: its items are written again, once' + finally + view.close() + test! "examples/log.rip runs headless: the steps scroll into the scrollback as the clock moves, the spinner turns, the warning is printed, and it quits when done or on q", -> view = mount BuildLog, cols: 60, rows: 10 try diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index 90e086fc..43b308c0 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -45,15 +45,11 @@ export Newline = component # alternate screen it writes nothing (screen.rip). export Static = component extends div el := null - # The node and the screen are taken as plain values: a cleanup that - # read the `el` cell would subscribe whatever disposes this component - # to it, and the detach that clears the cell would run that again. ~> return unless el and live - node = el view = live.view - view.statics.push node - -> view.statics = view.statics.filter (other) -> other isnt node + view.statics.push el + -> view.statics = view.statics.filter (other) -> other isnt el render div ref: el, display: 'none' slot