From c4acc96c7fe1b35f8b752bccad7bc44d9e37e8d1 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 22 Sep 2026 17:13:11 -0600 Subject: [PATCH 1/7] tui: the TODO lists what the live test runner works around --- packages/tui/TODO.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index 349fde45..7cf4bb7b 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -77,3 +77,30 @@ steps are in [PLAN.md](PLAN.md). - A spelling for a capture listener in a render block. The package reads a type that ends in `Capture` (`@keydownCapture:`), since `@name:` is always `addEventListener(name, handler)` with no third argument. + +## 9. Found by the live test runner + +- [ ] Frames are paced by a fixed 8 ms. A lane that writes a thousand + chunks a second would draw a frame each 8 ms, so + `scripts/test-live.rip` keeps its run in a plain model that a + computed re-reads on each `tick.frame`, painting once a tick. An + app should set the least interval between frames (`run App, + pace: ms`, and `mount` honoring it through `view.tick`) and bind + its state directly. +- [ ] `print` writes above only the `Static` items already in the tree: + an effect that prints in the turn that appends an item runs + before the render block adds that item, so the item's row lands + below the printed text. The runner holds its ending back a tick + so its last lanes' rows are written before the failures it + prints. +- [ ] A mount closed by `quit` draws no last frame, where `run` does: + what the quitting turn added to `Static`, or changed in the + frame, reaches neither `view.scrollback` nor the frame unless the + test calls `view.frame()` before the quit lands. +- [ ] `mount` always draws at full color depth, so a test cannot see an + app at 16 colors or at none: the runner's named colors for a + terminal of 16, and its rows with no color at all, are unpinned. +- [ ] A row `Box` of several `Text`s wraps at a narrow width; the row + that holds is one `Text` of nested styled runs with `wrap: + 'truncate'`, which the runner found by trial. The README's text + section does not say so. From 21ea3cdbeffa5d74b223386ad73193e117bd0a39 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 22 Sep 2026 17:22:33 -0600 Subject: [PATCH 2/7] tui: an app sets the least interval between its frames with pace, and every change inside it goes into the frame that ends it --- packages/tui/PLAN.md | 11 +- packages/tui/README.md | 14 ++- packages/tui/TODO.md | 7 -- packages/tui/screen.rip | 29 +++-- packages/tui/test/events.rip | 50 ++++++++ packages/tui/test/live.rip | 32 ++--- packages/tui/tui.rip | 17 ++- scripts/test-live.rip | 238 +++++++++++++++++++---------------- 8 files changed, 245 insertions(+), 153 deletions(-) diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index 0b750fee..8cea0621 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -123,8 +123,9 @@ component effects ──► node setters ──► dirty marks ──► frame f diff + emit (one write) ◄───────┘ ``` -One flush per reactive batch: a microtask, a minimum interval of about -8 ms. Each key of a +One flush per reactive batch: a microtask, and a minimum interval +between frames, 8 ms unless `run App, pace: ms` names another — every +change inside the interval goes into the frame that ends it. Each key of a stdin read is a reactive turn of its own — the tree a key meets is the tree the key before it left — and a read is still one frame, because a change books a frame and does not draw one. Rip flushes effects @@ -1109,13 +1110,13 @@ run App ``` - `run(App, {stdin, stdout, stderr, damage, mouse, keyboard, selection, - altScreen, console})` → `{app, done, quit, flush}`, and `suspend(fn)` + altScreen, console, pace})` → `{app, done, quit, flush}`, and `suspend(fn)` hands the terminal to `fn` and takes it back (§8). - `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, scrollback, stderr, resize, +- `mount(App, {cols, rows, props, damage, mouse, keyboard, selection, + pace})` → `{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 diff --git a/packages/tui/README.md b/packages/tui/README.md index 747dfe25..ab2e0303 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -215,6 +215,11 @@ back on every way out, by one road: A key that arrives meanwhile is nobody's; a `quit` meanwhile closes the app without taking the terminal back. +- **Pace.** A change books a frame and every change until it is drawn + goes into it, and two frames are at least 8 ms apart. `run App, + pace: 100` sets that interval: an app fed by a stream that changes + its state a thousand times a second binds the state directly and + draws ten frames a second. - **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 @@ -430,7 +435,14 @@ default actions — and draws nothing: ask for the frame. Nothing is drawn until `frame` asks, so a frame that fails — a layout that never settles — throws from `frame`, to the test that asked for -it. `bytes` is the difference from the frame +it. `mount App, pace: 100` draws as `run` does instead: the first frame +and every change after it are booked, no closer than the pace, on the +mount's clock, and drawn as `view.tick` moves the clock to them, so ten +changes within 100 ms of `tick` are one frame; a booked frame that +fails throws from `tick`, and `bytes` holds every write since the test +last asked for a `frame`. + +`bytes` is the difference from the frame before, exactly as `run` writes it; a frame that changes no cell sends nothing. With `rows`, a frame taller than the terminal shows its bottom, as it does on a terminal; without, the terminal is as tall as diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index 7cf4bb7b..aa44dc1c 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -80,13 +80,6 @@ steps are in [PLAN.md](PLAN.md). ## 9. Found by the live test runner -- [ ] Frames are paced by a fixed 8 ms. A lane that writes a thousand - chunks a second would draw a frame each 8 ms, so - `scripts/test-live.rip` keeps its run in a plain model that a - computed re-reads on each `tick.frame`, painting once a tick. An - app should set the least interval between frames (`run App, - pace: ms`, and `mount` honoring it through `view.tick`) and bind - its state directly. - [ ] `print` writes above only the `Static` items already in the tree: an effect that prints in the turn that appends an item runs before the render block adds that item, so the item's row lands diff --git a/packages/tui/screen.rip b/packages/tui/screen.rip index c59268a0..99a7c694 100644 --- a/packages/tui/screen.rip +++ b/packages/tui/screen.rip @@ -16,7 +16,7 @@ import { failed } from './document.rip' import { layout } from './layout.rip' import { Grid, paint, diff, overlay, still, rowsToString, clipped, clipOf } from './paint.rip' -PACE =! 8 # the least milliseconds between two frames +export PACE =! 8 # the least milliseconds between two frames, unless the app sets its own LAYOUTS =! 32 # the most layouts one frame may take to settle # Whether every node above `node`, up to `body`, is shown. @@ -52,14 +52,18 @@ export class Screen # watching (terminal.rip) — off one, frames are kept and only text is # written; `alt` is the alternate screen, whose top-left the frame's # is, where nothing is written above and `leave` moves nowhere; - # `origin` is the terminal row the frame's top-left is on. `after` - # and `failed` are hooks a caller installs: run after every frame, - # and told of a frame that fails with no caller to throw to. - constructor: (@doc, @out, { whole = false, interactive = true, alt = false, origin = 0 } = {}) -> + # `origin` is the terminal row the frame's top-left is on; `pace` is + # the least milliseconds between two frames, and `clock` a `{ now, + # after }` of a test's own that booked frames wait on. `after` and + # `failed` are hooks a caller installs: run after every frame, and + # told of a frame that fails with no caller to throw to. + constructor: (@doc, @out, { whole = false, interactive = true, alt = false, origin = 0, pace = PACE, clock = null } = {}) -> @front = null @spare = null @booked = false - @last = 0 + @last = -Infinity + @pace = pace + @clock = clock @failed = null @whole = whole @owed = 0 # the cells the last frame owed @@ -170,7 +174,7 @@ export class Screen frame!: -> @still() @booked = false - @last = performance.now() + @last = @now() failed @doc tend @doc converge @doc, @cols @@ -248,13 +252,18 @@ export class Screen up = if spot.y then "\x1b[#{spot.y}A" else '' "\x1b[?25l#{up}\r" + now: -> if @clock then @clock.now() else performance.now() + # Draw what is owed, once this turn's writes are in and no sooner than - # the pace allows. + # the pace allows: every change meanwhile goes into that one frame. On + # a test's clock the frame waits for the clock to reach it. book!: -> return if @booked @booked = true - wait = PACE - (performance.now() - @last) - if wait > 0 then setTimeout (=> @drawBooked()), wait else queueMicrotask (=> @drawBooked()) + wait = @pace - (@now() - @last) + if @clock then @clock.after Math.max(0, wait), (=> @drawBooked()) + else 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. One drawn by hand meanwhile, or a close, has diff --git a/packages/tui/test/events.rip b/packages/tui/test/events.rip index 7d1b91c4..055f589e 100644 --- a/packages/tui/test/events.rip +++ b/packages/tui/test/events.rip @@ -1244,6 +1244,56 @@ test! "under `run` a key that changes nothing books no frame and writes nothing" quit() await running.done +frames =! (bytes) -> bytes.split('\x1b[?2026h').length - 1 + +test "under `pace: 100` ten changes within 100 ms of the mount's clock are one frame, drawn when the clock reaches it", -> + view = mount Cells, pace: 100 + try + eq view.bytes, '', 'the first frame is booked, as a change is' + view.tick 0 + eq [frames(view.bytes), view.held.view.front.toString()], [1, "a line that stays\ncount 0"] + mark = view.bytes.length + for n in [1..10] + view.tick 9 + view.press '+' + eq view.bytes.slice(mark), '', 'nothing is drawn before the pace allows' + view.tick 10 + eq frames(view.bytes.slice(mark)), 1 + ok view.bytes.slice(mark).includes('10'), JSON.stringify view.bytes + view.tick 500 + eq frames(view.bytes.slice(mark)), 1, 'and nothing after it, with nothing owed' + view.press '+' + view.tick 0 + eq frames(view.bytes.slice(mark)), 2, 'a change after a quiet interval is drawn at once' + finally + view.close() + view = mount Cells + try + view.frame() + mark = view.bytes.length + view.press '+' + view.tick 500 + eq view.bytes.length, mark, 'with no pace, a mount draws when the test asks' + finally + view.close() + throws (-> mount Cells, pace: -1), 'pace: -1 is not a number of milliseconds' + throws (-> mount Cells, pace: '8'), 'pace: "8" is not a number of milliseconds' + +test! "`run App, pace: ms` draws the changes of the interval as one frame, no sooner than the interval after the last", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Cells, stdin: stdin, stdout: term, pace: 300 + mark = term.sent.length + stdin.key '+' + sleep! 50 + stdin.key '+' + sleep! 50 + eq term.sent.length, mark, 'held for the pace' + sleep! 400 + eq [term.sent.length, term.text], [mark + 1, "a line that stays\ncount 2"], 'the two changes, one frame' + quit() + await running.done + test! "under `run` a cursor that moves alone — by focus, or by what the focused node declares — owes a frame of moves and no cell", -> Fields = component at := 0 diff --git a/packages/tui/test/live.rip b/packages/tui/test/live.rip index 6396eba7..65c198f5 100644 --- a/packages/tui/test/live.rip +++ b/packages/tui/test/live.rip @@ -1,8 +1,9 @@ # The repository's live test runner, scripts/test-live.rip, driven # headless through `mount`: the lanes' events are fed by hand as the # scheduler (scripts/lanes.mjs) would send them, the lanes' clock is a -# number the test moves, and the board's clock moves with `view.tick`. -# Every frame is the one a terminal would show at that moment. +# number the test moves, and the board is mounted at the runner's pace, +# so its frames are drawn as `view.tick` moves its clock. Every frame is +# the one a terminal would show at that moment. # # rip test/live.rip @@ -12,7 +13,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { mount } from 'rip/tui' import * as cells from './ink/cells.rip' -import { Board, Tally, blocks, firstFailure, recall, remember, PAINT } from '../../../scripts/test-live.rip' +import { Board, Tally, PACE, blocks, firstFailure, recall, remember, PAINT } from '../../../scripts/test-live.rip' import { failureBlock } from '../../../scripts/lanes.mjs' # Four lanes as the scheduler plans them, the last one skipped, and what @@ -50,7 +51,7 @@ board =! (memory, body) -> planned.skipped = planned.lanes.filter (it) -> it.skip clockAt = { now: 0 } tally = Tally.new planned, { root: '/repo', cores: 10, jobs: 2, ci: false }, memory, -> clockAt.now - view = mount Board, cols: 100, rows: 30, props: { tally } + view = mount Board, cols: 100, rows: 30, pace: PACE, props: { tally } hear = (at, ...events) -> clockAt.now = at tally.hear event for event in events @@ -148,18 +149,14 @@ test "the strip holds one cell per lane in the order they start, and the footer test "the terminal's progress indicator follows the run's share, weighed by the last run, and says error once a lane fails", -> board MEMORY(), ({ view, hear, lanes }) -> hear 0, { type: 'plan' }, { type: 'start', lane: lanes[0] }, { type: 'start', lane: lanes[2] } - view.frame() hear 20000, { type: 'finish', result: { lane: lanes[2], status: 'pass', ms: 700, ran: 548, output: '548 tests: ok' } } - view.frame() - ok view.bytes.includes('\x1b]9;4;1;26\x1b\\'), "20 s of the root lane's 50 and time's 0.8 whole, of 80.8" + ok view.bytes.endsWith('\x1b]9;4;1;26\x1b\\\x1b[?2026l'), "20 s of the root lane's 50 and time's 0.8 whole, of 80.8" hear 21000, { type: 'start', lane: lanes[1] }, { type: 'finish', result: { lane: lanes[1], status: 'fail', ms: 100, why: 'exit 1', output: BUN_FAILURE } } - view.frame() - ok view.bytes.includes('\x1b]9;4;2\x1b\\'), 'error once a lane fails' + ok view.bytes.endsWith('\x1b]9;4;2\x1b\\\x1b[?2026l'), 'error once a lane fails' board null, ({ view, hear, lanes }) -> hear 0, { type: 'plan' }, { type: 'start', lane: lanes[2] } hear 700, { type: 'finish', result: { lane: lanes[2], status: 'pass', ms: 700, ran: 548, output: '548 tests: ok' } } - view.frame() - ok view.bytes.includes('\x1b]9;4;1;33\x1b\\'), 'on a first run, the lanes finished of the lanes to run' + ok view.bytes.endsWith('\x1b]9;4;1;33\x1b\\\x1b[?2026l'), 'on a first run, the lanes finished of the lanes to run' test "a failing lane's card: the test, what it said, and where, as a link; ↑ and ↓ go between the cards; f shows the failing lanes in place of the lanes in flight", -> board MEMORY(), ({ view, hear, lanes }) -> @@ -224,15 +221,12 @@ test! "the end: every failing lane's output printed above the frame as the plain passing = [{ lane: lanes[2], status: 'pass', ms: 700, ran: 548, output: '548 tests: ok' }, { lane: lanes[0], status: 'pass', ms: 45000, ran: 7938, output: 'Ran 7938 tests' }] hear 4700, { type: 'finish', result: passing[0] } hear 45000, { type: 'finish', result: passing[1] }, { type: 'end', summary: { results: [failing, ...passing], skipped: [lanes[3]], excluded: [], failed: [failing], wall: 45000, ranTotal: 8486, code: 1 } } - view.frame() - before = view.scrollback - ok cells.plain(before).some((row) -> row.startsWith '✓ root (extended tier) 7,938 tests'), 'the last lane is in the scrollback' - ok not before.includes('failures, then last 60 lines'), 'the run ends a tick after its last lane, once that lane is in the scrollback' - view.tick 100 frame = rows view.frame() - after = view.scrollback.slice before.length - ok after.startsWith(failureBlock(failing, PAINT) + '\n'), "the failing lane's block, byte for byte the plain runner's" - eq cells.plain(after.slice(failureBlock(failing, PAINT).length + 1)), [ + block = failureBlock failing, PAINT + at = view.scrollback.indexOf block + ok cells.plain(view.scrollback.slice 0, at).some((row) -> row.startsWith '✓ root (extended tier) 7,938 tests'), 'the last lane is written above the end' + ok view.scrollback.slice(at).startsWith(block + '\n'), "the failing lane's block, byte for byte the plain runner's" + eq cells.plain(view.scrollback.slice(at + block.length + 1)), [ '╭──────────────────────────────────────────────────────────────────────────────────────────────────╮' '│ lanes 2 passed · 1 failed · 1 skipped │' '│ tests 8,486 │' diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index 898a7a24..cacd8d7b 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -7,7 +7,7 @@ import { __effect } from '../../src/runtime/reactive.js' import { install, failed, watch, Event } from './document.rip' import { tend, take, advance } from './focus.rip' import { Parser, TIMERS } from './input.rip' -import { Screen } from './screen.rip' +import { Screen, PACE } from './screen.rip' import { Pointer } from './mouse.rip' import { palette } from './paint.rip' import { guard, setup, probe, reply, teardown, handover, stepping, stop as halt, ask, interactive as attended, depth as depthOf } from './terminal.rip' @@ -269,13 +269,15 @@ open =! (App, options, out, terminal, stdin = null, clock = undefined) -> throw Error.new "rip/tui: mouse: #{JSON.stringify mouse} is not true, false or 'all'" unless mouse is true or mouse is false or mouse is 'all' 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' + pace = options.pace ?? PACE + throw Error.new "rip/tui: pace: #{JSON.stringify pace} is not a number of milliseconds, 0 or more" unless typeof pace is 'number' and pace >= 0 and pace < Infinity { doc, restore } = install() attending = not terminal or attended out alternate = terminal and attending 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 = Screen.new doc, out, { whole: options.damage is false, interactive: attending, alt: alternate, origin: (if terminal and not alternate then Infinity else 0) } + view = Screen.new doc, out, { whole: options.damage is false, interactive: attending, alt: alternate, origin: (if terminal and not alternate then Infinity else 0), pace, clock: (if terminal then null else clock) } cols = view.cols rows = view.rows interactive = view.interactive @@ -343,7 +345,10 @@ export run =! (App, options = {}) -> # An app mounted off any terminal, for a test to drive: set its state # through `app`, then draw. Nothing is drawn until `frame` asks, so a -# frame that fails throws to the test that asked for it. +# frame that fails throws to the test that asked for it — unless the +# mount has a `pace`: then a change books a frame as under `run`, drawn +# once `tick` moves the mount's clock to it, and a frame that fails +# throws from that `tick`. class Mount constructor: (@held) -> @@ -411,7 +416,11 @@ class Mount export mount =! (App, options = {}) -> out = { columns: options.cols ?? 80, rows: options.rows ?? Infinity, sent: '', write: (text) -> @sent += text } out.err = { sent: '', write: (text) -> @sent += text } - Mount.new open(App, options, out, false, null, Clock.new()) + held = open App, options, out, false, null, Clock.new() + if options.pace? + held.doc.onOwe = -> held.view.book() + held.view.book() + Mount.new held # Give the terminal and the process back: the terminal as terminal.rip # gives it back, the listeners, the `document` slot. `drawn` leaves the diff --git a/scripts/test-live.rip b/scripts/test-live.rip index e6436a57..13688d76 100644 --- a/scripts/test-live.rip +++ b/scripts/test-live.rip @@ -36,7 +36,11 @@ import { configure, guardrails, planLanes, launch, failureBlock, stripAnsi, FAIL # so neighbors never pulse in lockstep. SPINS =! ['⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏', '⣾⣽⣻⢿⡿⣟⣯⣷', '⠁⠂⠄⡀⢀⠠⠐⠈', '⢄⢂⢁⡁⡈⡐⡠', '⠈⠐⠠⢀⡀⠄⠂⠁'] EIGHTHS =! ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'] -TICK =! 100 # milliseconds between frames: the run paints at most once a tick +TICK =! 100 # milliseconds a spinner holds each glyph + +# The least milliseconds between two frames: every event meanwhile goes +# into the next one. +export PACE =! 100 # Colors as true color, and as the sixteen where the terminal has no more. HUES =! { cyan: '#22d3ee', green: '#4ade80', amber: '#fbbf24', red: '#f87171', slate: '#64748b', yellow: '#facc15' } @@ -129,21 +133,29 @@ export firstFailure =! (output) -> # ── The run as it stands ────────────────────────────────────────────────────── +# A reactive cell, for a model outside any component: `read` and +# `write`, and a binding that read it follows the write. +cell =! (first) -> + held := first + { read: (-> held), write: ((next) -> held = next) } + # What the lanes' events have said so far, and what the last complete -# run said: a model a frame reads, never a reactive one. Events land here -# as they come and the board reads it once a tick, so a lane that writes -# a thousand chunks a second costs no frame. `now` is the clock the -# lanes' starts are read on. +# run said. Each event writes the cells the board binds; the board draws +# no more than once a pace however fast they come, so a lane that writes +# a thousand chunks a second costs no more frames than one that writes +# none. A lane's entry is replaced, never changed in place. `now` is the +# clock the lanes' starts are read on. export class Tally constructor: (planned, config, memory = null, now = Date.now) -> @config = config @memory = memory ?? { wall: null, lanes: {} } @now = now - @entries = planned.lanes.map (lane, index) -> { lane, index, status: 'waiting', at: null, result: null, seen: 0, arrivals: 0, carry: '', decoder: TextDecoder.new(), card: null } - @byLabel = Map.new @entries.map((entry) -> [entry.lane.label, entry]) - @finished = [] # the scrollback's rows, in the order the lanes finished - @began = null - @summary = null + @lanes = cell planned.lanes.map((lane, index) -> { lane, index, status: 'waiting', at: null, result: null, seen: 0, arrivals: 0, card: null }) + @order = Map.new planned.lanes.map((lane, index) -> [lane.label, index]) + @lines = planned.lanes.map -> { carry: '', decoder: TextDecoder.new() } # each lane's line so far, which nothing draws + @rows = cell [] # the scrollback's rows, in the order the lanes finished + @start = cell null + @end = cell null @shown = false # whether a frame has shown the summary yet @closed = false known = (record.ms for own label, record of @memory.lanes when record?.ms > 0) @@ -151,36 +163,45 @@ export class Tally @median = if known.length then known[Math.floor known.length / 2] else null @slowest = if known.length then known[known.length - 1] else 0 + get entries: -> @lanes.read() + get finished: -> @rows.read() + get began: -> @start.read() + get summary: -> @end.read() + hear!: (event) -> switch event.type - when 'plan' then @began = @now() - when 'start' - entry = @byLabel.get event.lane.label - entry.status = 'running' - entry.at = @now() - when 'chunk' then @count @byLabel.get(event.lane.label), event.data + when 'plan' then @start.write @now() + when 'start' then @change event.lane.label, { status: 'running', at: @now() } + when 'chunk' then @count event.lane.label, event.data when 'finish' then @settle event.result - when 'end' then @summary = event.summary + when 'end' then @end.write event.summary + + # A lane's entry, with `fields` over it. + change!: (label, fields) -> + list = [...@entries] + at = @order.get label + list[at] = { ...list[at], ...fields } + @lanes.write list # The lines a lane prints one per test — the rip harness's `✓` and `✗`, # bun's `(pass)` and `(fail)` — counted as they stream, a line at a # time, with when each arrived. - count!: (entry, data) -> - text = entry.carry + entry.decoder.decode(data, { stream: true }) + count!: (label, data) -> + line = @lines[@order.get label] + text = line.carry + line.decoder.decode(data, { stream: true }) parts = text.split /\r\n|\r|\n/ - entry.carry = parts.pop() - for part in parts when /^\s*(?:✓|✗|\(pass\)|\(fail\)) /.test stripAnsi(part) - entry.seen += 1 - entry.arrivals += @now() - entry.at - return + line.carry = parts.pop() + tests = 0 + tests += 1 for part in parts when /^\s*(?:✓|✗|\(pass\)|\(fail\)) /.test stripAnsi(part) + return unless tests + entry = @entries[@order.get label] + @change label, { seen: entry.seen + tests, arrivals: entry.arrivals + tests * (@now() - entry.at) } settle!: (result) -> - entry = @byLabel.get result.lane.label - entry.status = result.status - entry.result = result - entry.card = firstFailure result.output if result.status is 'fail' or result.status is 'timeout' + failing = result.status is 'fail' or result.status is 'timeout' + @change result.lane.label, { status: result.status, result, card: (if failing then firstFailure(result.output) else null) } @slowest = Math.max @slowest, result.ms - @finished.push { result, slowest: @slowest, last: @memory.lanes[result.lane.label] ?? null } + @rows.write [...@rows.read(), { result, slowest: @slowest, last: @memory.lanes[result.lane.label] ?? null }] # A lane's last duration, or the middle of the known ones for a lane # with none; null on a first run. @@ -211,53 +232,6 @@ export class Tally done += Math.min(now - entry.at, weight) if entry.status is 'running' if total then done / total else null - # Everything a frame shows, as of now; `frame` turns the spinners. The - # summary shows a tick after it arrives, so the rows of the lanes that - # finished with it are in the tree before the run's end prints above - # them. - snapshot: (frame) -> - now = @now() - over = @summary? and @shown - @shown = @summary? - elapsed = if @began? then now - @began else 0 - tally = { passed: 0, failed: 0, skipped: 0, tests: 0, finished: 0 } - for entry in @entries - switch entry.status - when 'pass' - tally.passed += 1 - tally.tests += entry.result.ran ?? 0 - when 'fail', 'timeout' then tally.failed += 1 - when 'skip' then tally.skipped += 1 - tally.finished = tally.passed + tally.failed - tally.total = @entries.filter((entry) -> not entry.lane.skip).length - share = @share now - running = for entry in @entries when entry.status is 'running' - spent = now - entry.at - weight = @estimate entry.lane.label - counts = @counted entry.lane.label - record = @memory.lanes[entry.lane.label] - { - label: entry.lane.label - spin: spinOf entry.index, frame - spent - over: if weight then spent / weight else 0 - fill: if counts then entry.seen / record.ran else if weight then spent / weight else null - info: if counts then "#{commas entry.seen} / #{commas record.ran} tests" else if weight then "#{secs spent} / ~#{about weight}" else secs(spent) - } - { - frame - elapsed - share - eta: if share? and share >= 0.02 and share < 1 and elapsed > 2000 then elapsed * (1 - share) / share else null - cells: ({ status: entry.status, spin: spinOf(entry.index, frame) } for entry in @entries) - running - tally - failures: @failures - finished: @finished - summary: @summary - over - } - # ── The memory of the last run ──────────────────────────────────────────────── memoryPath =! (root) -> join root, '.rip', 'test-last.json' @@ -372,59 +346,109 @@ export Board = component top := 0 # the first line of that output shown finale := false # the end card is written above the frame - snap ~= @tally.snapshot tick.frame + # The time, as the clock moves it, is what the spinners, the bars and + # the clocks are drawn at; everything else is the tally's, as its + # events write it. + frame ~= tick.frame + now ~= + frame + @tally.now() + summary ~= @tally.summary + failures ~= @tally.failures + elapsed ~= if @tally.began? then now - @tally.began else 0 + share ~= @tally.share now + eta ~= if share? and share >= 0.02 and share < 1 and elapsed > 2000 then elapsed * (1 - share) / share else null + + # The lanes passed, failed, skipped and finished, of those to run, and + # the tests of the lanes that passed. + score ~= + t = { passed: 0, failed: 0, skipped: 0, tests: 0, total: 0 } + for entry in @tally.entries + t.total += 1 unless entry.lane.skip + switch entry.status + when 'pass' + t.passed += 1 + t.tests += entry.result.ran ?? 0 + when 'fail', 'timeout' then t.failed += 1 + when 'skip' then t.skipped += 1 + t.finished = t.passed + t.failed + t + + running ~= + for entry in @tally.entries when entry.status is 'running' + spent = now - entry.at + weight = @tally.estimate entry.lane.label + counts = @tally.counted entry.lane.label + record = @tally.memory.lanes[entry.lane.label] + { + label: entry.lane.label + spin: spinOf entry.index, frame + spent + over: if weight then spent / weight else 0 + fill: if counts then entry.seen / record.ran else if weight then spent / weight else null + info: if counts then "#{commas entry.seen} / #{commas record.ran} tests" else if weight then "#{secs spent} / ~#{about weight}" else secs(spent) + } + + # The run ends a tick after its summary arrives, so the rows of the + # lanes that finished with it are in the tree before the run's end + # prints above them. + over ~= + frame + ended = summary? and @tally.shown + @tally.shown = summary? + ended + wide ~= screen.cols >= 70 name ~= Math.max 10, ...@tally.entries.map((entry) -> entry.lane.label.length) - chosen ~= snap.failures[Math.min(pick, snap.failures.length - 1)] ?? null + chosen ~= failures[Math.min(pick, failures.length - 1)] ?? null heading ~= config = @tally.config - blurb = if wide then "#{snap.tally.total} lanes · #{config.jobs} at a time · #{config.cores} cores" else "#{snap.tally.total} lanes" + blurb = if wide then "#{score.total} lanes · #{config.jobs} at a time · #{config.cores} cores" else "#{score.total} lanes" left = [seg(' rip ', { bold: true, color: hue('cyan') }), seg('test:all ', { bold: true }), seg(blurb, { dimColor: true })] used = left.reduce ((sum, piece) -> sum + piece.text.length), 0 - stamp = clockOf snap.elapsed + stamp = clockOf elapsed [...left, seg(' '.repeat(Math.max 1, screen.cols - used - stamp.length - 1)), seg(stamp, { bold: true })] - strip ~= [seg(' '), ...snap.cells.map(cellSeg)] + strip ~= [seg(' '), ...@tally.entries.map((entry) -> cellSeg { status: entry.status, spin: spinOf(entry.index, frame) })] overall ~= - share = snap.share - eta = if snap.eta? then " ETA #{clockOf snap.eta}" else '' - info = if share? then "#{String(Math.floor share * 100).padStart 3}%#{eta}" else "#{snap.tally.finished}/#{snap.tally.total}" + ahead = if eta? then " ETA #{clockOf eta}" else '' + info = if share? then "#{String(Math.floor share * 100).padStart 3}%#{ahead}" else "#{score.finished}/#{score.total}" width = Math.max 10, screen.cols - info.length - 4 - tone = if snap.tally.failed then hue('red') else if share? then fillHue(share, 0) else hue('cyan') + tone = if score.failed then hue('red') else if share? then fillHue(share, 0) else hue('cyan') if share? fill = blocks share, width [seg(' '), seg(fill.done, { color: tone }), seg(fill.rest, { dimColor: true }), seg(" #{info}")] else - [seg(' '), seg(sweep(snap.frame, width), { color: tone }), seg(" #{info}")] + [seg(' '), seg(sweep(frame, width), { color: tone }), seg(" #{info}")] flight ~= - for lane in snap.running + for lane in running label = lane.label.padEnd name unless wide [seg(" #{lane.spin} ", { color: hue('cyan') }), seg(label), seg(" #{secs lane.spent}", { dimColor: true })] else width = Math.max 10, Math.min(40, screen.cols - name - 28) tone = fillHue lane.fill ?? 0, lane.over - bar = if lane.fill? then blocks(lane.fill, width) else { done: sweep(snap.frame, width), rest: '' } + bar = if lane.fill? then blocks(lane.fill, width) else { done: sweep(frame, width), rest: '' } [seg(" #{lane.spin} ", { color: hue('cyan') }), seg(label), seg(' '), seg(bar.done, { color: tone }), seg(bar.rest, { dimColor: true }), seg(" #{lane.info}", { dimColor: true })] failing ~= - return [] if snap.summary - return [[seg(' no lane has failed', { dimColor: true })]] unless snap.failures.length - for entry, n in snap.failures + return [] if summary + return [[seg(' no lane has failed', { dimColor: true })]] unless failures.length + for entry, n in failures mark = if n is pick then '›' else ' ' [seg(" #{mark} ", { color: hue('red'), bold: true }), seg("✗ #{entry.lane.label.padEnd name}", { color: hue('red'), bold: n is pick }), seg(" #{entry.result.why}", { dimColor: true })] - cardLines ~= if chosen and not snap.summary then cardRows(chosen, @tally.config.root, Math.min(pick, snap.failures.length - 1), snap.failures.length) else [] + cardLines ~= if chosen and not summary then cardRows(chosen, @tally.config.root, Math.min(pick, failures.length - 1), failures.length) else [] totals ~= - t = snap.tally + t = score runs = [seg(' '), seg("#{t.passed} passed", { color: hue('green') }), seg(' · ', { dimColor: true }), seg("#{t.failed} failed", (if t.failed then { color: hue('red'), bold: true } else { dimColor: true })), seg(' · ', { dimColor: true }), seg("#{t.skipped} skipped", (if t.skipped then { color: hue('yellow') } else { dimColor: true })), seg(' · ', { dimColor: true }), seg(plural(t.tests, 'test'))] - keys = if open then '↑↓ PgUp PgDn scroll · ⏎ close' else if snap.failures.length then 'q quit · f failures · ↑↓ choose · ⏎ output' else 'q quit · f failures' + keys = if open then '↑↓ PgUp PgDn scroll · ⏎ close' else if failures.length then 'q quit · f failures · ↑↓ choose · ⏎ output' else 'q quit · f failures' used = runs.reduce ((sum, piece) -> sum + piece.text.length), 0 - runs.push seg(' '.repeat(Math.max 2, screen.cols - used - keys.length - 1)), seg(keys, { dimColor: true }) if wide and not snap.summary + runs.push seg(' '.repeat(Math.max 2, screen.cols - used - keys.length - 1)), seg(keys, { dimColor: true }) if wide and not summary runs # The output pane takes the rows the rest of the frame leaves — the @@ -439,29 +463,29 @@ export Board = component paneTitle ~= " #{chosen?.lane.label ?? ''} · lines #{top + 1}–#{Math.min(captured.length, top + tall)} of #{captured.length} " ~> - return unless snap.over and not @tally.closed + return unless over and not @tally.closed @tally.closed = true - for lost in snap.summary.failed + for lost in summary.failed print failureBlock(lost, PAINT) open = false finale = true - quit snap.summary.code + quit summary.code # The terminal's own indicator: the run's share, or its lanes on a first # run, and 'error' once a lane fails; in whole percents, so it is sent # again only when it moves. indicator ~= - t = snap.tally - if snap.summary then null + t = score + if summary then null else if t.failed then 'error' - else Math.round((snap.share ?? t.finished / Math.max(1, t.total)) * 100) / 100 + else Math.round((share ?? t.finished / Math.max(1, t.total)) * 100) / 100 ~> screen.progress indicator scroll: (to) -> top = Math.max 0, Math.min(to, captured.length - tall) pressed: (event) -> return quit() if event.key is 'q' - count = snap.failures.length + count = failures.length if open switch event.key when 'ArrowUp' then @scroll top - 1 @@ -484,25 +508,25 @@ export Board = component render Box flexDirection: 'column', focusable: true, autofocus: true, @keydown: @pressed Static - for row in snap.finished + for row in @tally.finished Line key: row.result.lane.label, segs: laneRow(row, name, wide) Static if finale Box flexDirection: 'column', borderStyle: 'round', borderColor: hue('slate'), paddingX: 1 - for runs, n in endRows(@tally, snap.summary) + for runs, n in endRows(@tally, summary) Line key: n, segs: runs Line segs: heading if screen.cols >= 40 Line segs: strip Line segs: overall - unless snap.summary + unless summary Box height: 1 if only for runs, n in failing Line key: n, segs: runs else for runs, n in flight - Line key: snap.running[n].label, segs: runs + Line key: running[n].label, segs: runs if cardLines.length Box flexDirection: 'column', borderStyle: 'round', borderColor: hue('red'), paddingX: 1 for runs, n in cardLines @@ -525,7 +549,7 @@ main! =! -> guardrails config planned = planLanes config tally = Tally.new planned, config, recall(config.root) - board = run Board, props: { tally } + board = run Board, pace: PACE, props: { tally } usePty = Bun.enableANSIColors and process.platform isnt 'win32' lanes = launch config, planned, { usePty, cols: process.stdout.columns or 120, rows: process.stdout.rows or 40 }, (event) -> tally.hear event # However the process leaves — a signal, a crash — no lane outlives it. From db8c431b0c44a3193deae4d4e7ad2ca76b5f16e9 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 22 Sep 2026 17:24:42 -0600 Subject: [PATCH 3/7] tui: print writes the Static items the same turn added before its text, so the scrollback is in the order of the program --- packages/tui/PLAN.md | 3 ++- packages/tui/README.md | 6 +++++- packages/tui/TODO.md | 6 ------ packages/tui/screen.rip | 10 ++++++++-- packages/tui/test.rip | 26 +++++++++++++++++++++++--- packages/tui/test/live.rip | 1 - scripts/test-live.rip | 12 +----------- 7 files changed, 39 insertions(+), 25 deletions(-) diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index 8cea0621..2c0899c2 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -1112,7 +1112,8 @@ run App - `run(App, {stdin, stdout, stderr, damage, mouse, keyboard, selection, altScreen, console, pace})` → `{app, done, quit, flush}`, and `suspend(fn)` hands the terminal to `fn` and takes it back (§8). -- `print(text)` writes text above the live frame, its line ended, and +- `print(text)` writes text above the live frame, its line ended, after + every `Static` item not yet written, the turn's own included; 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, diff --git a/packages/tui/README.md b/packages/tui/README.md index ab2e0303..4855d5d8 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -814,7 +814,11 @@ an item is done: a change to its state or its removal from the list changes nothing on the terminal. An item is an element: a bare text under `Static` is refused by name as it is put there — wrap it in `Text`. An item under a hidden ancestor waits -until it is shown. Off a terminal the rows go out as plain text as they +until it is shown. A `print`, or a console line, writes the items not +yet written first — those the same turn added included, even from an +effect that runs before the render block that adds them — so the +scrollback is in the order of the program. 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. diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index aa44dc1c..5cc3610c 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -80,12 +80,6 @@ steps are in [PLAN.md](PLAN.md). ## 9. Found by the live test runner -- [ ] `print` writes above only the `Static` items already in the tree: - an effect that prints in the turn that appends an item runs - before the render block adds that item, so the item's row lands - below the printed text. The runner holds its ending back a tick - so its last lanes' rows are written before the failures it - prints. - [ ] A mount closed by `quit` draws no last frame, where `run` does: what the quitting turn added to `Static`, or changed in the frame, reaches neither `view.scrollback` nor the frame unless the diff --git a/packages/tui/screen.rip b/packages/tui/screen.rip index 99a7c694..aa5b3e90 100644 --- a/packages/tui/screen.rip +++ b/packages/tui/screen.rip @@ -11,6 +11,7 @@ # record of it: a frame first takes the cursor back to the top-left, # then draws, then parks it again, all in the one write. +import { __batch } from '../../src/runtime/reactive.js' import { tend, visible } from './focus.rip' import { failed } from './document.rip' import { layout } from './layout.rip' @@ -93,10 +94,15 @@ export class Screen # 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. What `Static` holds goes first, the items this turn added + # included: an effect that writes here runs before the render blocks + # its own write queued (they are deeper), so those run first, and the + # scrollback is in the order of the program. above!: (text, err = null) -> return unless text - @still() unless @batching # what `Static` holds goes first + unless @batching + __batch -> + @still() @scrollback += text unless @interactive (err ?? @out).write text diff --git a/packages/tui/test.rip b/packages/tui/test.rip index 61d558eb..fe3b3dae 100644 --- a/packages/tui/test.rip +++ b/packages/tui/test.rip @@ -2227,6 +2227,26 @@ test "print writes above the frame, ending its line; print.err the same on stder finally view.close() +test "a print from the turn that appends a Static item writes the item's row first: the scrollback is in the order of the program", -> + Steps = component + @done := [] + ~> print "#{@done.length} done" if @done.length + render + Box flexDirection: 'column' + Static + for name in @done + Text key: name, "✓ #{name}" + Text "live" + view = mount Steps, cols: 20 + try + view.frame() + view.app.done.value = ['one'] + view.app.done.value = ['one', 'two'] + view.frame() + eq view.scrollback, "✓ one\n1 done\n✓ two\n2 done\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 @@ -2274,9 +2294,9 @@ test! "examples/log.rip runs headless: the steps scroll into the scrollback as t 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" + # The warning is printed in the turn that finishes the step it is + # about, and stands below that step. + eq bare(view.scrollback), "✓ resolve\n✓ fetch\n✓ compile\nwarning: compile took the slow road\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 diff --git a/packages/tui/test/live.rip b/packages/tui/test/live.rip index 65c198f5..9edd77ce 100644 --- a/packages/tui/test/live.rip +++ b/packages/tui/test/live.rip @@ -254,7 +254,6 @@ test! "a green run's end card says so in its verdict, and exits 0", -> hear 0, { type: 'plan' }, { type: 'start', lane: lanes[2] } passing = { lane: lanes[2], status: 'pass', ms: 700, ran: 548, output: '548 tests: ok' } hear 700, { type: 'finish', result: passing }, { type: 'end', summary: { results: [passing], skipped: [], excluded: [], failed: [], wall: 700, ranTotal: 548, code: 0 } } - view.tick 100 view.frame() card = cells.styled view.scrollback ok card.some((row) -> row.includes '«#4ade80 bold»✓«» «#4ade80 bold»All«» «#4ade80 bold»1«» «#4ade80 bold»lanes«» «#4ade80 bold»green«»'), 'the verdict, bold green' diff --git a/scripts/test-live.rip b/scripts/test-live.rip index 13688d76..fe46b1b3 100644 --- a/scripts/test-live.rip +++ b/scripts/test-live.rip @@ -156,7 +156,6 @@ export class Tally @rows = cell [] # the scrollback's rows, in the order the lanes finished @start = cell null @end = cell null - @shown = false # whether a frame has shown the summary yet @closed = false known = (record.ms for own label, record of @memory.lanes when record?.ms > 0) known.sort (x, y) -> x - y @@ -389,15 +388,6 @@ export Board = component info: if counts then "#{commas entry.seen} / #{commas record.ran} tests" else if weight then "#{secs spent} / ~#{about weight}" else secs(spent) } - # The run ends a tick after its summary arrives, so the rows of the - # lanes that finished with it are in the tree before the run's end - # prints above them. - over ~= - frame - ended = summary? and @tally.shown - @tally.shown = summary? - ended - wide ~= screen.cols >= 70 name ~= Math.max 10, ...@tally.entries.map((entry) -> entry.lane.label.length) chosen ~= failures[Math.min(pick, failures.length - 1)] ?? null @@ -463,7 +453,7 @@ export Board = component paneTitle ~= " #{chosen?.lane.label ?? ''} · lines #{top + 1}–#{Math.min(captured.length, top + tall)} of #{captured.length} " ~> - return unless over and not @tally.closed + return unless summary and not @tally.closed @tally.closed = true for lost in summary.failed print failureBlock(lost, PAINT) From 457b786a325039949ef71183c204ea700057f1eb Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 22 Sep 2026 17:28:22 -0600 Subject: [PATCH 4/7] tui: a mount closed by quit draws its last frame, as run does, so the quitting turn reaches the scrollback and the frame --- packages/tui/README.md | 5 ++++- packages/tui/TODO.md | 4 ---- packages/tui/test.rip | 23 +++++++++++++++++++++++ packages/tui/test/live.rip | 18 +++++++++--------- packages/tui/tui.rip | 6 ++++-- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/packages/tui/README.md b/packages/tui/README.md index 4855d5d8..6cf8930e 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -450,7 +450,10 @@ the frame. `damage` counts the cells the frame owed, which is how a test holds an update to a small repaint; `mount App, damage: false` (and `run`) owes every cell of every frame, for a frame to compare against. A `quit` from the app closes the mount and resolves -`view.done` with its value. +`view.done` with its value, and first draws the last frame, as `run` +does: what the quitting turn added to `Static` is in `view.scrollback`, +and what it changed is in `view.ansi`, with no `frame()` asked for. A +`close()` by hand draws nothing. `bytes`, `damage` and `cursor` describe the last `frame()`: a frame that draws nothing leaves `bytes` empty and `damage` 0, and the OSC 52 write diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index 5cc3610c..4b87bdd6 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -80,10 +80,6 @@ steps are in [PLAN.md](PLAN.md). ## 9. Found by the live test runner -- [ ] A mount closed by `quit` draws no last frame, where `run` does: - what the quitting turn added to `Static`, or changed in the - frame, reaches neither `view.scrollback` nor the frame unless the - test calls `view.frame()` before the quit lands. - [ ] `mount` always draws at full color depth, so a test cannot see an app at 16 colors or at none: the runner's named colors for a terminal of 16, and its rows with no color at all, are unpinned. diff --git a/packages/tui/test.rip b/packages/tui/test.rip index fe3b3dae..cccf91cd 100644 --- a/packages/tui/test.rip +++ b/packages/tui/test.rip @@ -1502,6 +1502,29 @@ test! "a quit from a mounted app closes the mount and resolves done", -> eq typeof document, 'undefined' throws (-> view.frame()), 'this mount is closed' +test! "a mount closed by quit draws its last frame, as run does: the quitting turn's Static items reach the scrollback and its change the frame; a close by hand draws nothing", -> + Finisher = component + @done := [] + @over := false + ~> quit() if @over + render + Box flexDirection: 'column' + Static + for name in @done + Text key: name, "✓ #{name}" + Text "#{if @over then 'all done' else 'working'}" + view = mount Finisher, cols: 20 + eq view.frame(), 'working' + view.app.done.value = ['one'] + view.app.over.value = true + await view.done + eq [view.scrollback, bare(view.ansi)], ["✓ one\n", 'all done'] + view = mount Finisher, cols: 20 + eq view.frame(), 'working' + view.app.done.value = ['two'] + view.close() + eq [view.scrollback, bare(view.ansi)], ['', 'working'], 'a close by hand leaves the frame as the test last drew it' + # ==[ Damage: a frame paints and compares the cells a change owes ]== console.log "\nDamage" diff --git a/packages/tui/test/live.rip b/packages/tui/test/live.rip index 9edd77ce..5e8efbff 100644 --- a/packages/tui/test/live.rip +++ b/packages/tui/test/live.rip @@ -220,8 +220,10 @@ test! "the end: every failing lane's output printed above the frame as the plain hear 4000, { type: 'finish', result: failing }, { type: 'start', lane: lanes[2] } passing = [{ lane: lanes[2], status: 'pass', ms: 700, ran: 548, output: '548 tests: ok' }, { lane: lanes[0], status: 'pass', ms: 45000, ran: 7938, output: 'Ran 7938 tests' }] hear 4700, { type: 'finish', result: passing[0] } - hear 45000, { type: 'finish', result: passing[1] }, { type: 'end', summary: { results: [failing, ...passing], skipped: [lanes[3]], excluded: [], failed: [failing], wall: 45000, ranTotal: 8486, code: 1 } } - frame = rows view.frame() + hear 45000, { type: 'finish', result: passing[1] } + tally.hear { type: 'end', summary: { results: [failing, ...passing], skipped: [lanes[3]], excluded: [], failed: [failing], wall: 45000, ranTotal: 8486, code: 1 } } + eq (await view.done), 1 + ok tally.closed block = failureBlock failing, PAINT at = view.scrollback.indexOf block ok cells.plain(view.scrollback.slice 0, at).some((row) -> row.startsWith '✓ root (extended tier) 7,938 tests'), 'the last lane is written above the end' @@ -240,25 +242,23 @@ test! "the end: every failing lane's output printed above the frame as the plain '╰──────────────────────────────────────────────────────────────────────────────────────────────────╯' '' ] - eq frame, [ + eq cells.plain(view.ansi), [ ' rip test:all 3 lanes · 2 at a time · 10 cores 0:45' ' ●✗●⊘' ' ████████████████████████████████████████████████████████████████████████████████████████████ 100%' ' 2 passed · 1 failed · 1 skipped · 8,486 tests' ] - eq (await view.done), 1 - ok tally.closed test! "a green run's end card says so in its verdict, and exits 0", -> - board! MEMORY(), ({ view, hear, lanes }) -> + board! MEMORY(), ({ view, hear, lanes, tally }) -> hear 0, { type: 'plan' }, { type: 'start', lane: lanes[2] } passing = { lane: lanes[2], status: 'pass', ms: 700, ran: 548, output: '548 tests: ok' } - hear 700, { type: 'finish', result: passing }, { type: 'end', summary: { results: [passing], skipped: [], excluded: [], failed: [], wall: 700, ranTotal: 548, code: 0 } } - view.frame() + tally.hear { type: 'finish', result: passing } + tally.hear { type: 'end', summary: { results: [passing], skipped: [], excluded: [], failed: [], wall: 700, ranTotal: 548, code: 0 } } + eq (await view.done), 0 card = cells.styled view.scrollback ok card.some((row) -> row.includes '«#4ade80 bold»✓«» «#4ade80 bold»All«» «#4ade80 bold»1«» «#4ade80 bold»lanes«» «#4ade80 bold»green«»'), 'the verdict, bold green' ok cells.plain(view.scrollback).some((row) -> row.includes '✓ All 1 lanes green · 548 tests · 0.7s') - eq (await view.done), 0 test "a complete run is remembered for the next: each passing lane's time, its count, and how its lines arrived", -> root = mkdtempSync join(tmpdir(), 'rip-live-') diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index cacd8d7b..5711d6a1 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -424,8 +424,8 @@ export mount =! (App, options = {}) -> # 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 +# last frame in the scrollback — a mount's too, drawn with whatever the +# closing turn owed; 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) -> @@ -445,6 +445,8 @@ close! =! (drawn, error = null, result = undefined) -> step -> missed = teardown held, drawn caught.error ?= missed + else if drawn + step -> view.frame() if doc.stale step -> app?.unmount() # The parser's wait is disarmed on every road, a mount's included. step -> held.parser.flush() From de935d384d646020666defdaa53f22a547927013 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 22 Sep 2026 17:32:26 -0600 Subject: [PATCH 5/7] tui: mount App, colors: n draws at that depth, as run draws on a terminal of it, and screen.colors reads it --- packages/tui/PLAN.md | 7 ++-- packages/tui/README.md | 8 +++-- packages/tui/TODO.md | 3 -- packages/tui/test/live.rip | 58 +++++++++++++++++++++++++++++++--- packages/tui/test/terminal.rip | 21 ++++++++++++ packages/tui/tui.rip | 5 ++- 6 files changed, 88 insertions(+), 14 deletions(-) diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index 2c0899c2..92912b73 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -1117,10 +1117,11 @@ run App `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, - pace})` → `{app, frame, ansi, bytes, damage, scrollback, stderr, resize, + pace, colors})` → `{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 + focused, cursor}`, is the test driver (§10), with `colors` the depth + it draws at (0, 16, 256, or 16777216 by default), and + `renderToString(App, {cols, rows, props, colors, 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 diff --git a/packages/tui/README.md b/packages/tui/README.md index 6cf8930e..43fd4055 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -235,7 +235,9 @@ back on every way out, by one road: `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. + 16. A mount is at full depth whatever the environment says, and + `mount App, colors: 16` (or `256`, `0`) draws it as `run` draws on a + terminal of that depth, so a test sees the app there. - **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 @@ -408,7 +410,7 @@ back, and draws a frame when the test asks for one. ```coffee import { mount } from 'rip/tui' -view = mount Counter, cols: 40, rows: 10, props: { count: 3 } # mouse:, keyboard:, selection: as `run` takes them +view = mount Counter, cols: 40, rows: 10, props: { count: 3 } # mouse:, keyboard:, selection:, pace: as `run` takes them; colors: 0, 16 or 256 view.frame() # "count 3" — lay out, paint, the frame as plain text view.app.count.value = 7 # public state is set from outside view.frame() # "count 7" @@ -467,7 +469,7 @@ 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. The rows +takes `props` and `colors`, 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 diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index 4b87bdd6..c890bb12 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -80,9 +80,6 @@ steps are in [PLAN.md](PLAN.md). ## 9. Found by the live test runner -- [ ] `mount` always draws at full color depth, so a test cannot see an - app at 16 colors or at none: the runner's named colors for a - terminal of 16, and its rows with no color at all, are unpinned. - [ ] A row `Box` of several `Text`s wraps at a narrow width; the row that holds is one `Text` of nested styled runs with `wrap: 'truncate'`, which the runner found by trial. The README's text diff --git a/packages/tui/test/live.rip b/packages/tui/test/live.rip index 5e8efbff..5357adea 100644 --- a/packages/tui/test/live.rip +++ b/packages/tui/test/live.rip @@ -44,14 +44,15 @@ BUN_FAILURE =! [ # A board over the four lanes, `memory` its last run (null for a first # run), and `hear`, which feeds it an event with the lanes' clock at -# `at` milliseconds and moves the board's clock a tick. The mount is -# closed when `body` is done, or when the promise it returns settles. -board =! (memory, body) -> +# `at` milliseconds and moves the board's clock a tick, on a terminal of +# `colors`. The mount is closed when `body` is done, or when the promise +# it returns settles. +board =! (memory, body, colors = 16777216) -> planned = { lanes: LANES(), excluded: [], skipped: [] } planned.skipped = planned.lanes.filter (it) -> it.skip clockAt = { now: 0 } tally = Tally.new planned, { root: '/repo', cores: 10, jobs: 2, ci: false }, memory, -> clockAt.now - view = mount Board, cols: 100, rows: 30, pace: PACE, props: { tally } + view = mount Board, cols: 100, rows: 30, pace: PACE, colors: colors, props: { tally } hear = (at, ...events) -> clockAt.now = at tally.hear event for event in events @@ -146,6 +147,55 @@ test "the strip holds one cell per lane in the order they start, and the footer eq cells.styled(view.ansi)[1], ' «#22d3ee»⠸«#f87171 bold»✗«#4ade80»●«#facc15»⊘' eq frame[frame.length - 1], ' 1 passed · 1 failed · 1 skipped · 548 tests q quit · f failures · ↑↓ choose · ⏎ output' +# Every SGR parameter `text` sends. +params =! (text) -> Array.from(text.matchAll(/\x1b\[([0-9;]*)m/g), (m) -> m[1].split(';')).flat() + +# The lanes of a run with a skip, a pass, a failure, and a lane in flight +# past its last duration: every color the board draws with but green's +# blend, which a terminal of 16 has none of. +colored =! (depth, body) -> + board MEMORY(), (({ view, hear, lanes }) -> + hear 0, { type: 'plan' }, { type: 'finish', result: { lane: lanes[3], status: 'skip', ms: 0, why: lanes[3].skip, output: '' } }, { type: 'start', lane: lanes[0] }, { type: 'start', lane: lanes[2] } + hear 700, { type: 'finish', result: { lane: lanes[2], status: 'pass', ms: 700, ran: 548, output: '548 tests: 548 passed, 0 failed' } }, { type: 'start', lane: lanes[1] } + hear 5000, { type: 'finish', result: { lane: lanes[1], status: 'fail', ms: 4300, why: 'exit 1', output: BUN_FAILURE } } + hear 60000 + body view), depth + +test "on a terminal of 16 colors every color is one of the named: the strip, the bars, the card and the scrollback rows", -> + colored 16, (view) -> + frame = cells.styled view.ansi + eq frame[1], ' «cyan»⠼«red bold»✗«green»●«yellow»⊘' + ok frame[2].startsWith(' «red»████'), 'the whole run, red once a lane fails' + ok frame[4].startsWith(' «cyan»⠼«» root (extended tier) «yellow»████'), "a lane past its last duration, yellow for amber: #{frame[4]}" + ok frame[5].startsWith('«red»╭'), 'the card' + eq cells.styled(view.scrollback), [ + '«yellow»⊘«» «yellow»packages/tray«» «yellow»skipped:«» «yellow»`swift`«» «yellow»is«» «yellow»not«» «yellow»on«» «yellow»PATH' + '«green»✓«» packages/time 548 tests «dim»0.7s«» «gray»▏«» «green»−0.1s' + '«red bold»✗«» «red bold»packages/sites«» «dim»4.3s«» «red»exit«» «red»1' + '' + ] + sent = params view.ansi + view.scrollback + ok sent.length and not sent.some((p) -> p is '38' or p is '48'), 'no color of the 256 or of 24 bits' + +test "on a terminal of no color nothing is sent but the text and the card's link, and it reads as it does in color", -> + colored 0, (view) -> + eq params(view.ansi + view.scrollback), [] + eq view.ansi.replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, ''), view.frame(), "the frame, the link's escape aside" + ok not view.scrollback.includes('\x1b'), 'the scrollback, plain' + eq cells.plain(view.scrollback), [ + '⊘ packages/tray skipped: `swift` is not on PATH' + '✓ packages/time 548 tests 0.7s ▏ −0.1s' + '✗ packages/sites 4.3s exit 1' + '' + ] + eq rows(view.frame()).slice(0, 5), [ + ' rip test:all 3 lanes · 2 at a time · 10 cores 1:00' + ' ⠼✗●⊘' + ' ████████████████████████████████████████████████████████████████████████████████████████████ 100%' + '' + ' ⠼ root (extended tier) ████████████████████████████████████████ 60.0s / ~50s' + ] + test "the terminal's progress indicator follows the run's share, weighed by the last run, and says error once a lane fails", -> board MEMORY(), ({ view, hear, lanes }) -> hear 0, { type: 'plan' }, { type: 'start', lane: lanes[0] }, { type: 'start', lane: lanes[2] } diff --git a/packages/tui/test/terminal.rip b/packages/tui/test/terminal.rip index fb27c4f9..6d57f526 100644 --- a/packages/tui/test/terminal.rip +++ b/packages/tui/test/terminal.rip @@ -1286,6 +1286,27 @@ test "mount and renderToString stay at full depth whatever the environment says" finally if held? then process.env.NO_COLOR = held else delete process.env.NO_COLOR +test "`mount App, colors: n` draws at that depth, as `run` draws on a terminal of it, and `screen.colors` reads it: one hex color as itself, the nearest of the 256, the nearest of the 16, and no escape at none", -> + Shade = component + render + Box flexDirection: 'row' + Text color: '#22d3ee', bold: true, "#{screen.colors}" + Text color: 'green', " g" + for [n, sgr] in [[16777216, '1;38;2;34;211;238'], [256, '1;38;5;45'], [16, '1;36'], [0, null]] + view = mount Shade, colors: n + try + eq view.frame(), "#{n} g" + if sgr + ok view.ansi.includes("#{CSI}#{sgr}m#{n}") and view.ansi.includes("#{CSI}0;32m g"), "#{n}: #{JSON.stringify view.ansi}" + else + ok not /\x1b\[[0-9;]*m/.test(view.ansi), JSON.stringify view.ansi + finally + view.close() + ok renderToString(Shade, colors: 16, ansi: true).includes("#{CSI}1;36m16"), 'renderToString takes it too' + ok renderToString(Tinted, cols: 10, ansi: true).includes('38;2;255;0;0'), 'and the next mount is at full depth again' + throws (-> mount Shade, colors: 8), 'colors: 8 is not 0, 16, 256 or 16777216' + eq typeof document, 'undefined', 'a refused depth installs nothing' + # ==[ The console ]== console.log "\nThe console" diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index 5711d6a1..4d9220d4 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -271,6 +271,9 @@ open =! (App, options, out, terminal, stdin = null, clock = undefined) -> throw Error.new "rip/tui: keyboard: #{JSON.stringify keys} is not 'basic' or 'enhanced'" unless keys is 'basic' or keys is 'enhanced' pace = options.pace ?? PACE throw Error.new "rip/tui: pace: #{JSON.stringify pace} is not a number of milliseconds, 0 or more" unless typeof pace is 'number' and pace >= 0 and pace < Infinity + # A terminal's depth is its environment's; a mount's is the test's. + shades = if terminal then -1 else DEPTHS.indexOf options.colors ?? 16777216 + throw Error.new "rip/tui: colors: #{JSON.stringify options.colors} is not 0, 16, 256 or 16777216" if shades < 0 and not terminal { doc, restore } = install() attending = not terminal or attended out alternate = terminal and attending and options.altScreen is true @@ -281,7 +284,7 @@ open =! (App, options, out, terminal, stdin = null, clock = undefined) -> cols = view.cols rows = view.rows interactive = view.interactive - depth = if terminal then depthOf out else 3 + depth = if terminal then depthOf out else shades colors = DEPTHS[depth] palette depth held = null From 8add869b1d3b2e5ba6a71b2be1238452c93880fc Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 22 Sep 2026 17:35:30 -0600 Subject: [PATCH 6/7] =?UTF-8?q?tui:=20a=20row=20of=20styled=20runs=20is=20?= =?UTF-8?q?one=20Text=20of=20nested=20runs=20cut=20with=20=E2=80=A6,=20as?= =?UTF-8?q?=20the=20README=20writes=20it,=20and=20no=20Line=20widget=20sta?= =?UTF-8?q?nds=20in=20for=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tui/README.md | 22 ++++++++++++++++++++++ packages/tui/TODO.md | 7 ------- packages/tui/test.rip | 38 ++++++++++++++++++++++++++++++++++++++ packages/tui/test/live.rip | 4 +++- scripts/test-live.rip | 5 +++-- 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/packages/tui/README.md b/packages/tui/README.md index 43fd4055..92b60645 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -268,6 +268,28 @@ scrollback (below). | `overflow`, `overflowX`, `overflowY`: `'visible'` or `'hidden'` (clips to the padding box; a box that scrolls its children keeps to its parent's size with `flexShrink: 1`, since the default is 0) | | | On `Text`: `wrap` — `'wrap'` (the default: words wrap, and a word longer than the line breaks), `'hard'`, `'truncate'` / `'truncate-end'`, `'truncate-start'`, `'truncate-middle'` (with `…`) There is no no-wrap mode: to keep a line on one row and scroll it, give the text a `width` wider than any line and shift it with `contentOffsetX` (`truncate` cuts at the box's width before the offset shifts it). | On `Text`: `link` — a URL; the words are a hyperlink (OSC 8) | +A row of runs in several styles — a mark, a name, a count — is one +`Text` with a `Text` nested for each run, and `wrap: 'truncate'` keeps +it to one row, cut with `…` where its box is too narrow for it: + +```coffee +Row = component + render + Text wrap: 'truncate' + Text color: 'green', "✓ " + Text bold: true, "packages/time" + Text dimColor: true, " 548 tests 0.7s" +``` + +At 18 columns that is `✓ packages/time …`. A row `Box` of the same +three `Text`s is a flex row of three items instead: too narrow for them +it spills past its box, and with `flexShrink: 1` on each it wraps each +text in a column of its own. There is no `Line` widget for this: it +would be `Text wrap: 'truncate'` by another name, and the runs are +already the text's own children. `scripts/test-live.rip` draws every +row of its board this way, from runs it computes: a `for` of nested +`Text`s, each with the run's style as `style:`. + Text is measured by grapheme cluster — a flag, a family emoji, a letter with its combining marks each take the cells a terminal gives them — and control characters are stripped when the text is set: styling diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index c890bb12..349fde45 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -77,10 +77,3 @@ steps are in [PLAN.md](PLAN.md). - A spelling for a capture listener in a render block. The package reads a type that ends in `Capture` (`@keydownCapture:`), since `@name:` is always `addEventListener(name, handler)` with no third argument. - -## 9. Found by the live test runner - -- [ ] A row `Box` of several `Text`s wraps at a narrow width; the row - that holds is one `Text` of nested styled runs with `wrap: - 'truncate'`, which the runner found by trial. The README's text - section does not say so. diff --git a/packages/tui/test.rip b/packages/tui/test.rip index cccf91cd..f9666111 100644 --- a/packages/tui/test.rip +++ b/packages/tui/test.rip @@ -799,6 +799,44 @@ test "a link wraps its words in OSC 8, nested text inherits it, and a plain fram "go" eq renderToString(Panel, cols: 4, ansi: true), "\x1b[44m#{OPEN 'https://example.com'}go\x1b[0;44m#{SHUT} \x1b[0m" +# The README's row of styled runs, as it is written there. +ROW =! ''' + Row = component + render + Text wrap: 'truncate' + Text color: 'green', "✓ " + Text bold: true, "packages/time" + Text dimColor: true, " 548 tests 0.7s" + ''' + +test "a row of styled runs is one Text of nested runs, cut with … where it is too narrow, as the README writes it; a row Box of the same Texts spills past its box, or wraps each in a column", -> + ok readFileSync(join(import.meta.dir, 'README.md'), 'utf8').includes("```coffee\n#{ROW}\n```"), 'the README holds the pattern' + Row = component + render + Text wrap: 'truncate' + Text color: 'green', "✓ " + Text bold: true, "packages/time" + Text dimColor: true, " 548 tests 0.7s" + eq renderToString(Row, cols: 40), '✓ packages/time 548 tests 0.7s' + eq renderToString(Row, cols: 18), '✓ packages/time …' + eq renderToString(Row, cols: 18, ansi: true), '\x1b[32m✓ \x1b[0;1mpackages/time\x1b[0;2m …\x1b[0m', 'each run in its own style' + Spills = component + render + Box width: 18, borderStyle: 'round' + Box flexDirection: 'row' + Text color: 'green', "✓ " + Text bold: true, "packages/time" + Text dimColor: true, " 548 tests 0.7s" + eq renderToString(Spills, cols: 20).split('\n')[1], '│✓ packages/time 54', 'over its border, to the edge of the terminal' + Shrinks = component + render + Box width: 18, borderStyle: 'round' + Box flexDirection: 'row' + Text flexShrink: 1, color: 'green', "✓ " + Text flexShrink: 1, bold: true, "packages/time" + Text flexShrink: 1, dimColor: true, " 548 tests 0.7s" + eq renderToString(Shrinks, cols: 20).split('\n').length, 5, 'three rows inside the border' + test "a link that wraps is closed on every row and opened again on the next, its '…' inside it", -> Long = component render diff --git a/packages/tui/test/live.rip b/packages/tui/test/live.rip index 5357adea..4d290297 100644 --- a/packages/tui/test/live.rip +++ b/packages/tui/test/live.rip @@ -328,7 +328,7 @@ test "a complete run is remembered for the next: each passing lane's time, its c finally rmSync root, { recursive: true, force: true } -test "a terminal under 70 columns keeps each lane in flight to its spinner, name and time, and one under 40 drops the strip", -> +test "a terminal under 70 columns keeps each lane in flight to its spinner, name and time, one under 40 drops the strip, and a row too wide is cut short, never wrapped", -> board MEMORY(), ({ view, hear, lanes }) -> hear 0, { type: 'plan' }, { type: 'start', lane: lanes[0] } hear 12000 @@ -340,6 +340,8 @@ test "a terminal under 70 columns keeps each lane in flight to its spinner, name frame = rows view.frame() ok frame[1].includes('%'), 'the whole run is the second row' ok not frame.some((row) -> row.includes '··⊘'), 'and the strip is gone' + eq frame.length, 5, 'every row one row: the header, the bar, a blank row, the lane, the footer' + eq frame[4], ' 0 passed · 0 failed · 0 skipped · …', 'the footer, too wide, is cut short with …' test! "q quits the board mid-run, with no exit code of its own", -> board! MEMORY(), ({ view, hear, lanes }) -> diff --git a/scripts/test-live.rip b/scripts/test-live.rip index fe46b1b3..dabf448f 100644 --- a/scripts/test-live.rip +++ b/scripts/test-live.rip @@ -258,8 +258,9 @@ export PAINT =! { dim: ((text) -> tint 2, text), red: ((text) -> tint 31, text) # ── The board ───────────────────────────────────────────────────────────────── -# One row of styled runs of text: runs nested in one text, which a -# terminal too narrow for the row cuts short with `…` instead of wrapping. +# One row of styled runs of text, as packages/tui's README writes a row: +# the runs nested in one text, which a terminal too narrow for the row +# cuts short with `…` instead of wrapping. The runs are `seg`s. Line = component @segs := [] render From 65a442d26af71baa88e78e94eb9d56745eff16be Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 22 Sep 2026 17:40:13 -0600 Subject: [PATCH 7/7] tui: the comments on close and on above's empty batch say what each does, wrapped --- packages/tui/screen.rip | 2 +- packages/tui/tui.rip | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/tui/screen.rip b/packages/tui/screen.rip index aa5b3e90..095f18ba 100644 --- a/packages/tui/screen.rip +++ b/packages/tui/screen.rip @@ -101,7 +101,7 @@ export class Screen above!: (text, err = null) -> return unless text unless @batching - __batch -> + __batch -> # an empty batch: its end runs the effects the turn queued @still() @scrollback += text unless @interactive diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index 4d9220d4..e8042040 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -428,9 +428,10 @@ export mount =! (App, options = {}) -> # 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 mount's too, drawn with whatever the -# closing turn owed; 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`. +# closing turn owed; 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) -> held = live { app, view, out, doc, restore, resized, pointer, settled } = held