From 3542e51e301095f8c9f70c182a56d92261843a48 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 22 Sep 2026 13:46:17 -0600 Subject: [PATCH 1/6] tui: a space past the end of a row begins the next one at a whole width, as Ink draws it --- packages/tui/PLAN.md | 8 +++----- packages/tui/TODO.md | 4 ---- packages/tui/test/text.rip | 3 ++- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index 68fe62d0..db589232 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -1346,11 +1346,9 @@ What the table says, and where it does not flatter: - **Text wraps at a rounded width here and at Yoga's float width in Ink.** A cell `33%` of 120 columns is 39.6 to Ink's wrapper and 40 cells to this package's, so a line that fills the cell exactly wraps - differently; and a line that fills its cell exactly at a space leaves - that space at the head of the next line here. The reducer refuses - such a tree (first differing cell: update 0, row 20, column 35), so - the resize scenario's cells are a quarter of 120 and of 80 columns, - whole either way. The second point is a text-engine defect, open. + differently. The reducer refuses such a tree (first differing cell: + update 0, row 20, column 35), so the resize scenario's cells are a + quarter of 120 and of 80 columns, whole either way. **Where an Ink frame goes** (share of in-frame CPU time, sampled; `bun run profile`, in RESULTS.md): diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index 8ceaa2f2..7d2ab5cb 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -46,10 +46,6 @@ steps are in [PLAN.md](PLAN.md). (`rip bench/tui.rip static N`). - [ ] `Static`'s items are elements; a bare text under `Static` is never hidden and is painted again with every batch. -- [ ] A line that ends exactly at a space at the cell width keeps that - space at the head of the next line: `Box width: 5` holding - `Text "abcde fgh"` draws `"abcde\n fgh"` where Ink draws - `"abcde\nfgh"` (PLAN §11). ## 6. Input and focus diff --git a/packages/tui/test/text.rip b/packages/tui/test/text.rip index c45a520f..9d78c720 100644 --- a/packages/tui/test/text.rip +++ b/packages/tui/test/text.rip @@ -256,8 +256,9 @@ test "words wrap at the width, and the space stays on the row it follows", -> eq rows('the quick brown fox', 10), ['the quick ', 'brown fox'] eq rows('hello world', 7), ['hello ', 'world'] -test "a space that does not fit starts a row of its own", -> +test "a space past the end of a row starts the next one, as Ink draws it at a whole width", -> eq rows('hello world', 5), ['hello', ' ', 'world'] + eq rows('abcde fgh', 5), ['abcde', ' fgh'] test "leading, trailing, and repeated spaces are kept", -> eq rows(' ab cd ', 4), [' ab', ' cd', ' '] From a7ed1f7129446e03a7a82d51e3b8c0f111adb640 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 22 Sep 2026 13:48:46 -0600 Subject: [PATCH 2/6] tui: autofocus on a node that is not focusable is refused by name when its claim is settled --- packages/tui/PLAN.md | 7 +++-- packages/tui/README.md | 13 ++++----- packages/tui/TODO.md | 3 --- packages/tui/focus.rip | 16 ++++++++++- packages/tui/test/events.rip | 52 +++++++++++++++++++++++++++++++++--- 5 files changed, 75 insertions(+), 16 deletions(-) diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index db589232..bdad09bf 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -194,7 +194,7 @@ the terminal cannot honor throws a named error. | Text `data` setter (same-value short-circuit, marks dirty) | | `value`, `checked`, `innerHTML`, `textContent` | | `setAttribute`, `removeAttribute`, `toggleAttribute` | | `querySelector`, `document.head` (transitions) | | `addEventListener`, `removeEventListener`, `dispatchEvent` on every node and on the document: capture, target and bubble phases, `target`, `currentTarget`, `eventPhase`, `stopPropagation`, `preventDefault` (§7) | | Unknown style keys, with a suggestion | -| `focus()`, `blur()`, `focused`, `document.activeElement`; the attributes `focusable`, `autofocus`, `disabled`, `cursor` (§7) | | A `cursor` that is not `{ x, y }` in whole cells; a switch that is not true or false | +| `focus()`, `blur()`, `focused`, `document.activeElement`; the attributes `focusable`, `autofocus`, `disabled`, `cursor` (§7) | | A `cursor` that is not `{ x, y }` in whole cells; a switch that is not true or false; `autofocus` on a node that is not `focusable` | | Globals: `document`, `Node` (base class of every node), an `SVGElement` stub | | | `div` is a box and `span` is text. Comments are zero-size anchors. @@ -894,7 +894,10 @@ and a focusable node inside a focusable node is reached after it. `tend` gives focus to the first claimant in tree order that can hold it, if nothing has it, and drops every claim either way: a node that arrives never takes focus from a node that has it (a dialog calls - `focus()`), and a claim is not made again when focus is let go. + `focus()`), and a claim is not made again when focus is let go. A + claim on a node that is not `focusable` is refused by name as it is + settled, since the two switches arrive in either order; one on a + disabled node claims nothing. - **`focus` and `blur` pair up.** The node that had focus hears `blur` before the one that takes it hears `focus`; a listener of the blur that moves focus itself has the last word. The state is written diff --git a/packages/tui/README.md b/packages/tui/README.md index 15e8cffc..8477edc9 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -536,13 +536,14 @@ disabled loses focus to nothing — the next Tab starts from the top — and hears `blur`. `autofocus: true` is a claim made once, when the node arrives, as HTML's is: the first such node in tree order takes focus if nothing has it, and never takes it from a node that does. A node that -is disabled, or not `focusable`, when its claim is settled never claims -again — enable it and it waits for Tab or `focus()` — where Ink's +is disabled when its claim is settled never claims again — enable it +and it waits for Tab or `focus()` — where Ink's `useFocus({autoFocus, isActive})` takes focus whenever it becomes -active. Inside a `focus` or `blur` listener every read agrees with the -event: `document.activeElement`, `focus.active` and `el.focused` say -the node has focus as it hears `focus`, and that nothing has it as it -hears `blur`. +active; `autofocus` on a node that is not `focusable` is refused by +name when its claim is settled. Inside a `focus` or `blur` listener +every read agrees with the event: `document.activeElement`, +`focus.active` and `el.focused` say the node has focus as it hears +`focus`, and that nothing has it as it hears `blur`. ```coffee el.focus() # take focus, if the node can hold it diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index 7d2ab5cb..8b2a06f7 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -53,9 +53,6 @@ steps are in [PLAN.md](PLAN.md). closes leaves the keyboard with the root element until Tab; giving focus back to the node that had it before the dialog took it — and holding Tab inside a dialog while it is open — is undecided. -- [ ] `autofocus` on a node that is not `focusable` claims nothing, in - silence: the two switches arrive in either order, so neither write - can refuse the other's absence. - [ ] Tab walks the tree from the focused node, passing over shut subtrees whole: unmeasured on a tree of 10,000 nodes. - [ ] A select list that marks its choice with one binding an item diff --git a/packages/tui/focus.rip b/packages/tui/focus.rip index 4bd20592..34651075 100644 --- a/packages/tui/focus.rip +++ b/packages/tui/focus.rip @@ -11,6 +11,8 @@ # focused node that cannot hold focus loses it to nothing, and a node # that arrived with `autofocus` takes it if nothing has it. +import { blame } from './layout.rip' + # Whether styles `s` show the node and everything under it: neither # `hidden` nor `display: 'none'`. The one spelling of it (paint, mouse, # screen read it too). @@ -69,12 +71,24 @@ export take! =! (doc, node) -> node.glow?.write true node.dispatchEvent { type: 'focus', bubbles: false } -# Settle what the tree's changes left open. +# Whether `node` or anything above it is `disabled`. +barred =! (node) -> + while node + return true if node.disabled + node = node.parentNode + false + +# Settle what the tree's changes left open. A claim on a node that is +# not `focusable` is refused by name here, where both switches have +# arrived in whichever order they were written; one on a disabled node +# claims nothing. export tend! =! (doc) -> take doc, null if doc.active and not open doc.active, doc return unless doc.claims.length claims = doc.claims doc.claims = [] + for node in claims when node.autofocus and not node.focusable and not barred node + throw blame Error.new("rip/tui: autofocus on <#{node.tagName}> that is not focusable — write focusable: true"), node node = if doc.active then null else doc.body while node if node.focusable and not shut(node) and claims.includes node diff --git a/packages/tui/test/events.rip b/packages/tui/test/events.rip index 14bb215d..89a079ef 100644 --- a/packages/tui/test/events.rip +++ b/packages/tui/test/events.rip @@ -570,17 +570,61 @@ test "claims are settled in tree order, whatever order they were made in", -> find('b').set 'autofocus', true eq idOf(view.focused), 'b' -test "`autofocus` on a node that cannot take focus claims nothing", -> +test "`autofocus` on a disabled node claims nothing", -> Cannot = component render Box - Box id: 'a', autofocus: true - Text "a" + Box id: 'a', disabled: true + Box id: 'inner', autofocus: true + Text "a" Box id: 'b', focusable: true, autofocus: true, disabled: true Text "b" + Box id: 'c', autofocus: true, disabled: true + Text "c" mounted Cannot, {}, (view) -> eq view.focused, null +test "`autofocus` on a node that is not `focusable` is refused by name where the claim is settled, whichever switch came first", -> + Unfit = component + @late := false + render + Box flexDirection: 'column' + Box id: 'plain', autofocus: true + Text "plain" + if @late + Box id: 'late', autofocus: true + Text "late" + refused = "rip/tui: autofocus on
that is not focusable — write focusable: true" + view = mount Unfit, cols: 20 + try + throws (-> view.frame()), "#{refused} (on div > div[data-part=Box] > div#plain[data-part=Box])" + eq view.focused, null, 'the claim was settled when it was refused' + view.app.late.value = true + throws (-> view.focused), "#{refused} (on div > div[data-part=Box] > div#late[data-part=Box])" + finally + view.close() + Fit = component + render + Box flexDirection: 'column' + Box id: 'first', autofocus: true, focusable: true + Text "first" + Box id: 'second', focusable: true, autofocus: true + Text "second" + mounted Fit, {}, (view) -> + eq idOf(view.focused), 'first' + for order in [['autofocus', 'focusable'], ['focusable', 'autofocus']] + focus.to null + node = document.createElement 'div' + node.set 'id', order[0] + node.set key, true for key in order + document.body.firstChild.appendChild node + eq idOf(view.focused), order[0], order.join ' then ' + node = document.createElement 'div' + node.set 'id', 'unfit' + document.body.firstChild.appendChild node + node.set 'autofocus', true + throws (-> view.frame()), "#{refused} (on div > div[data-part=Box] > div#unfit)" + test "`screen.focused` follows the terminal's focus reports", -> Shown = component render @@ -1190,7 +1234,7 @@ test "random trees under random change: focus is always a node that can hold it, node.set 'focusable', true if rand() < 0.7 node.set 'disabled', true if rand() < 0.08 node.set 'hidden', true if rand() < 0.06 - node.set 'autofocus', true if rand() < 0.15 + node.set 'autofocus', true if rand() < 0.15 and node.focusable hear = (event) -> trail.push "#{idOf node} #{event.eventPhase}" if meddle and event.eventPhase > 1 From e418791215e3a6cdd4b9adf16c4729a668067116 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 22 Sep 2026 13:51:37 -0600 Subject: [PATCH 3/6] tui: a bare text under Static is refused by name as it is inserted, since only an element can be written once --- packages/tui/PLAN.md | 4 +++- packages/tui/README.md | 4 +++- packages/tui/TODO.md | 2 -- packages/tui/document.rip | 4 ++++ packages/tui/test.rip | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index bdad09bf..97c640c2 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -562,7 +562,9 @@ 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 +what happens to them later is nobody's. An item is an element, since +only an element can be put away: a bare text under `Static` is refused +by name as it is inserted. `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. diff --git a/packages/tui/README.md b/packages/tui/README.md index 8477edc9..8d83bd04 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -785,7 +785,9 @@ 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 +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 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 diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index 8b2a06f7..fa3f6ce5 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -44,8 +44,6 @@ steps are in [PLAN.md](PLAN.md). the reconciler's keyed `for` walks the list again. An append costs 151 µs over 1,000, 173 over 4,000, 274 over 8,000 (`rip bench/tui.rip static N`). -- [ ] `Static`'s items are elements; a bare text under `Static` is - never hidden and is painted again with every batch. ## 6. Input and focus diff --git a/packages/tui/document.rip b/packages/tui/document.rip index f770dc87..a5e12f51 100644 --- a/packages/tui/document.rip +++ b/packages/tui/document.rip @@ -251,6 +251,10 @@ export class Node extends Target if before then before.nextSibling = node else @firstChild = node if ref then ref.previousSibling = node else @lastChild = node node.parentNode = this + # `Static` writes elements once and puts them away; a text is no + # element to put away, so it would be painted again with every batch. + if node.nodeType is 3 and @attributes?.get('data-part') is 'Static' + throw blame Error.new("rip/tui: a bare text under Static is no item it can write once — wrap it in Text"), node if @tagName is 'span' and node.tagName is 'span' for own key, value of node.styles when value isnt undefined if KIND.get(key) is 'layout' diff --git a/packages/tui/test.rip b/packages/tui/test.rip index 5d78cdc6..0c6c2c58 100644 --- a/packages/tui/test.rip +++ b/packages/tui/test.rip @@ -2155,6 +2155,39 @@ test "renderToString is the rows written above and then the frame, plain or with eq renderToString(Only, cols: 20), "a\nb" eq renderToString(Only, cols: 20, ansi: true), "\x1b[31ma\x1b[0m\n\x1b[31mb\x1b[0m" +test "a bare text under Static is refused by name as it is put there, where a Text item is written once", -> + refused = "rip/tui: a bare text under Static is no item it can write once — wrap it in Text" + Loose = component + render + Box flexDirection: 'column' + Static + "loose" + Text "live" + throws (-> mount(Loose, cols: 20).close()), "#{refused} (on div[data-part=Static] > text)" + Late = component + @steps := [] + render + Box flexDirection: 'column' + Static + for step in @steps + "#{step}" + Text "live" + view = mount Late, cols: 20 + try + eq view.frame(), 'live' + throws (-> view.app.steps.value = ['a']), "#{refused} (on div > div[data-part=Box] > div[data-part=Static] > text)" + finally + view.close() + view = mount Journal, cols: 20, props: { done: ['a'] } + try + view.frame() + view.app.done.value = ['a', 'b'] + view.frame() + view.frame() + eq view.scrollback, "a\nb\n" + finally + view.close() + 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 From a4da1a7c2624d1c8e990ffd81fb5f469e2fc8c60 Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 22 Sep 2026 13:54:05 -0600 Subject: [PATCH 4/6] tui: a stream's columns or rows that are not a whole number above zero read as 80 by 24 --- packages/tui/README.md | 4 +++- packages/tui/TODO.md | 4 ---- packages/tui/screen.rip | 11 +++++++++-- packages/tui/test/terminal.rip | 17 +++++++++++++++++ 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/tui/README.md b/packages/tui/README.md index 8d83bd04..f92c5c37 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -735,7 +735,9 @@ already in that mode works without the option. Three rules for an app: reads stdin through it, and `mount` sends a test's bytes through it. A `key` becomes a `keydown`, a `paste` a `paste`, and the terminal's `focus` / `blur` reports `screen.focused`. What a text input can rely -on: `screen.cols` and `screen.rows` are reactive reads of the terminal's size, 80 × 24 before `run`. +on: `screen.cols` and `screen.rows` are reactive reads of the terminal's +size, 80 × 24 before `run` and where the stream reports none — a 0 +included, as a pty whose size was never set reports. - **Typed text is one `key` event per code point**, never per grapheme cluster: a cluster can be cut between two reads, and only code points diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index fa3f6ce5..9f6975e6 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -73,10 +73,6 @@ steps are in [PLAN.md](PLAN.md). caller's own: a test whose app is still live when it fails hands its own report to that stream. `test/terminal.rip` quits after every test for that; the other suites do not. -- [ ] A stdout whose `columns` is 0 — a pty whose size was never set, - as `script` makes with no terminal behind it — draws frames of no - cells, where an undefined `columns` is read as 80 (`screen.rip`) - and Ink reads 0 as 80 too. ## 8. Compiler-side, filed separately diff --git a/packages/tui/screen.rip b/packages/tui/screen.rip index 0c3161f1..c59268a0 100644 --- a/packages/tui/screen.rip +++ b/packages/tui/screen.rip @@ -27,6 +27,13 @@ showing =! (node, body) -> return true if node is body return false unless visible node.styles +# A size a stream reports, or `none` where it reports none: anything but +# a whole number of cells above zero is none — undefined, and the 0 of a +# pty whose size was never set, as `script` makes with no terminal +# behind it. Ink falls back the same way, to 80 by 24 where it finds no +# other size. A mount's endless rows are a size. +sized =! (value, none) -> if value > 0 and (Number.isInteger(value) or value is Infinity) then value else 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. @@ -71,8 +78,8 @@ export class Screen @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 + get cols: -> sized @out.columns, 80 + get rows: -> sized @out.rows, 24 # Write `text` above the live frame — a `Static` batch, a `print` line, # a console line (terminal.rip) — in the frame's own write: the diff --git a/packages/tui/test/terminal.rip b/packages/tui/test/terminal.rip index 3caa06d2..fb27c4f9 100644 --- a/packages/tui/test/terminal.rip +++ b/packages/tui/test/terminal.rip @@ -1098,6 +1098,23 @@ test! "`screen.interactive` reads true on a terminal, false on a stdout that is eq screen.interactive, true view.close() +test! "a stdout whose columns or rows are 0, as a pty whose size was never set reports, is read as 80 by 24, as one that reports nothing is", -> + for [columns, rows] in [[0, 0], [0, 10], [40, 0], [undefined, undefined]] + for Out in [Terminal, Pipe] + out = Out.new() + out.columns = columns + out.rows = rows + running = run Echo, stdin: Stdin.new(), stdout: out + eq [Out.name, columns, rows, screen.cols, screen.rows], [Out.name, columns, rows, columns or 80, rows or 24] + quit() + await running.done + eq out.text, 'x\n', "#{Out.name} #{columns}x#{rows}" if Out is Pipe + view = mount Echo, cols: 0, rows: 0 + try + eq [screen.cols, screen.rows, view.frame()], [80, 24, 'x'] + finally + view.close() + test! "the last frame is written once through a real pipe", -> child = Child.new 'pipe' { code, out, err } = child.finish! From 34171fddbe76d0c0e27108ef2eb9f32f2059d38b Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 22 Sep 2026 14:04:45 -0600 Subject: [PATCH 5/6] tui: a removed focused node gives focus back to the holder it took it from, and a modal box holds Tab --- packages/tui/PLAN.md | 49 +++++---- packages/tui/README.md | 31 ++++-- packages/tui/TODO.md | 4 - packages/tui/document.rip | 13 +-- packages/tui/focus.rip | 72 +++++++++++--- packages/tui/test/events.rip | 188 +++++++++++++++++++++++++++++++++-- 6 files changed, 295 insertions(+), 62 deletions(-) diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index 97c640c2..cfc11716 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -59,9 +59,9 @@ together, counted by `bench/lines.rip` and quoted from | Row | Ink + Yoga | Rip TUI | |---|---|---| -| Framework only | Ink `src/` 6,760 | 4,269 | -| Framework + layout algorithm | + Yoga 3.2.1 `yoga/algorithm/` 3,042 = 9,802 | 4,269 (`layout.rip` is 1,466 of it) | -| Full runtime closure | + React, react-reconciler, scheduler and 33 more packages | + Rip runtime 1,598 (`reactive.js`, `components.js`) = 5,867 | +| Framework only | Ink `src/` 6,760 | 4,308 | +| Framework + layout algorithm | + Yoga 3.2.1 `yoga/algorithm/` 3,042 = 9,802 | 4,308 (`layout.rip` is 1,466 of it) | +| Full runtime closure | + React, react-reconciler, scheduler and 33 more packages | + Rip runtime 1,598 (`reactive.js`, `components.js`) = 5,906 | Yoga is counted at 3.2.1, the version Ink 7.1.1 ships, at `misc/yoga` or the checkout `YOGA_SRC` names (`lines.rip` refuses any other). The honest headline is **2.3× smaller** with the layout algorithm on both sides, and 1.6× framework against framework: @@ -138,20 +138,20 @@ 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 | 307 | -| `document.rip` | Terminal document: nodes, tree links, the event and its dispatch, style road, keyboard traits, damage marks | 390 | -| `focus.rip` | Who can hold focus, tree order, taking it, settling it | 64 | +| `document.rip` | Terminal document: nodes, tree links, the event and its dispatch, style road, keyboard traits, damage marks | 393 | +| `focus.rip` | Who can hold focus, tree order, taking it, settling it, giving it back, `modal` | 99 | | `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 | 713 | -| `screen.rip` | Frames, pacing, the cursor, where the frame sits, the write above the frame (`Static`, `print`, the console), progress, the alternate screen | 177 | +| `screen.rip` | Frames, pacing, the cursor, where the frame sits, the write above the frame (`Static`, `print`, the console), progress, the alternate screen | 178 | | `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 | 205 | -| | **Total** | **4,269** | +| | **Total** | **4,308** | 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 -`document.rip`, 62 in `focus.rip`, 39 in `screen.rip`); what Ink spends +focus, the cursor and stdin are 321 of them (85 in `tui.rip`, 100 in +`document.rip`, 97 in `focus.rip`, 39 in `screen.rip`); what Ink spends on the same — `use-input`, `use-paste`, `use-focus`, `use-focus-manager`, `use-cursor`, their three contexts, `cursor-helpers`, and `App.tsx`, which holds its focus list, raw mode @@ -194,7 +194,7 @@ the terminal cannot honor throws a named error. | Text `data` setter (same-value short-circuit, marks dirty) | | `value`, `checked`, `innerHTML`, `textContent` | | `setAttribute`, `removeAttribute`, `toggleAttribute` | | `querySelector`, `document.head` (transitions) | | `addEventListener`, `removeEventListener`, `dispatchEvent` on every node and on the document: capture, target and bubble phases, `target`, `currentTarget`, `eventPhase`, `stopPropagation`, `preventDefault` (§7) | | Unknown style keys, with a suggestion | -| `focus()`, `blur()`, `focused`, `document.activeElement`; the attributes `focusable`, `autofocus`, `disabled`, `cursor` (§7) | | A `cursor` that is not `{ x, y }` in whole cells; a switch that is not true or false; `autofocus` on a node that is not `focusable` | +| `focus()`, `blur()`, `focused`, `document.activeElement`; the attributes `focusable`, `autofocus`, `disabled`, `modal`, `cursor` (§7) | | A `cursor` that is not `{ x, y }` in whole cells; a switch that is not true or false; `autofocus` on a node that is not `focusable` | | Globals: `document`, `Node` (base class of every node), an `SVGElement` stub | | | `div` is a box and `span` is text. Comments are zero-size anchors. @@ -876,8 +876,8 @@ that adjusts the offset is the whole scrolled-list pattern. **Focus** belongs to a node and follows tree order, found by a walk when Tab is pressed (Ink keeps the order its hooks registered in, and focuses by id). Any element takes -`focusable`, `autofocus` and `disabled`, which are switches kept on the -node, not styles. A node can hold focus while it is focusable, in the +`focusable`, `autofocus`, `disabled` and `modal`, which are switches +kept on the node, not styles. A node can hold focus while it is focusable, in the tree, and nothing from it up to the body is `disabled`, `hidden`, or `display: 'none'` — so `disabled` on a box is its descendants', which is Ink's `disableFocus()`. The walk passes over a shut subtree whole, @@ -887,16 +887,29 @@ and a focusable node inside a focusable node is reached after it. takes a node out and puts it back in one turn, and a focused node must keep its focus through that. So `tend` settles focus before every key, every frame, and every `view.focused`: a node that can no - longer hold focus loses it **to nothing** (Ink's tests ask the same: - the next Tab starts from the top), hearing `blur` once; and - `document.activeElement` / `focus.active` answer null from the moment - the node cannot hold it, without waiting for `tend`. + longer hold focus hears `blur` once, and `document.activeElement` / + `focus.active` answer null from the moment the node cannot hold it, + without waiting for `tend`. +- **Focus goes back where it was.** A focused node that has left the + tree gives focus to the holder it took focus from — kept in a + WeakMap on the document, and inside one `modal` box the holder the + box was entered from — if that one can still hold it, and to nothing + if not; focus given back is remembered by no one. A focused node + that is hidden or disabled, still in the tree, loses focus **to + nothing** (Ink's tests ask the same: the next Tab starts from the + top). +- **A `modal` box holds Tab.** From a node inside one, Tab and + Shift-Tab go round the nearest modal box and never leave it; from + outside every one they walk the whole tree. A claim inside a modal + box takes focus even from a holder outside it, so a dialog with an + `autofocus` field takes the keyboard as it opens and gives it back + as it is removed. - **`autofocus` is a claim made once,** when the node arrives in the document or the switch is written on a node already there. The next `tend` gives focus to the first claimant in tree order that can hold it, if nothing has it, and drops every claim either way: a node that - arrives never takes focus from a node that has it (a dialog calls - `focus()`), and a claim is not made again when focus is let go. A + arrives outside a modal box never takes focus from a node that has + it, and a claim is not made again when focus is let go. A claim on a node that is not `focusable` is refused by name as it is settled, since the two switches arrive in either order; one on a disabled node claims nothing. diff --git a/packages/tui/README.md b/packages/tui/README.md index f92c5c37..af15594c 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -147,9 +147,9 @@ Lines of code, by the rule above (`bun run lines`): | | Ink + Yoga | Rip TUI | |---|--:|--:| -| Framework only | Ink `src/` 6,760 | 4,269 | -| Framework + layout algorithm | + Yoga 3.2.1 `yoga/algorithm/` 3,042 = 9,802 | 4,269 (layout.rip is 1,466 of it) | -| Full runtime closure | + React, react-reconciler, scheduler and 33 more packages | + Rip runtime 1,598 = 5,867 | +| Framework only | Ink `src/` 6,760 | 4,308 | +| Framework + layout algorithm | + Yoga 3.2.1 `yoga/algorithm/` 3,042 = 9,802 | 4,308 (layout.rip is 1,466 of it) | +| Full runtime closure | + React, react-reconciler, scheduler and 33 more packages | + Rip runtime 1,598 = 5,906 | Ink + Yoga 3.2.1, the version Ink 7.1.1 ships, is 2.3× the lines of this package with the layout algorithm on both sides, 1.6× framework @@ -531,13 +531,24 @@ Once the app is closing, what is left of the same read is dropped. `display: 'none'` take a node and everything under it out of reach. Tab follows the tree's order, found by a walk when Tab is pressed, so a list that is reordered is walked as it stands, and a focused node that a -reorder moves keeps its focus. A focused node that is removed, hidden or -disabled loses focus to nothing — the next Tab starts from the top — -and hears `blur`. `autofocus: true` is a claim made once, when the node -arrives, as HTML's is: the first such node in tree order takes focus if -nothing has it, and never takes it from a node that does. A node that -is disabled when its claim is settled never claims again — enable it -and it waits for Tab or `focus()` — where Ink's +reorder moves keeps its focus. A focused node that is removed hears +`blur` and gives focus back to the node it took focus from, if that one +can still hold it, and to nothing if not; inside one `modal` box the +node remembered is the one the box was entered from, however Tab went +round it. A focused node that is hidden or disabled hears `blur` and +loses focus to nothing — the next Tab starts from the top. + +`modal: true` on a box holds Tab: from a node inside it, Tab and +Shift-Tab go round the nearest modal box and never leave it, and from +outside every one they walk the whole tree. An `autofocus` claim inside +a modal box takes focus even from a holder outside it, so a dialog +takes the keyboard as it opens and gives it back as it is removed. + +`autofocus: true` is a claim made once, when the node arrives, as +HTML's is: the first such node in tree order takes focus if nothing has +it, and outside a modal box never takes it from a node that does. A +node that is disabled when its claim is settled never claims again — +enable it and it waits for Tab or `focus()` — where Ink's `useFocus({autoFocus, isActive})` takes focus whenever it becomes active; `autofocus` on a node that is not `focusable` is refused by name when its claim is settled. Inside a `focus` or `blur` listener diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index 9f6975e6..7b97ebe3 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -47,10 +47,6 @@ steps are in [PLAN.md](PLAN.md). ## 6. Input and focus -- [ ] Focus that a removed node held goes to nothing. A dialog that - closes leaves the keyboard with the root element until Tab; giving - focus back to the node that had it before the dialog took it — and - holding Tab inside a dialog while it is open — is undecided. - [ ] Tab walks the tree from the focused node, passing over shut subtrees whole: unmeasured on a tree of 10,000 nodes. - [ ] A select list that marks its choice with one binding an item diff --git a/packages/tui/document.rip b/packages/tui/document.rip index a5e12f51..2d1c3c3d 100644 --- a/packages/tui/document.rip +++ b/packages/tui/document.rip @@ -4,8 +4,8 @@ # mutation arrives through a method here, so the document always knows # what changed. A member the terminal cannot honor throws by name. # Events are dispatched here as DOM dispatches them, and what a node is -# to the keyboard — `focusable`, `autofocus`, `disabled`, `cursor` — is -# kept here; who has focus is focus.rip's. +# to the keyboard — `focusable`, `autofocus`, `disabled`, `modal`, +# `cursor` — is kept here; who has focus is focus.rip's. import { __setChildFailureReporter } from '../../src/runtime/components.js' import { admit, blame } from './layout.rip' @@ -42,7 +42,7 @@ STANDING =! Set.new ['borderTop', 'borderBottom', 'borderLeft', 'borderRight', # What a node is to the keyboard, which moves no box and recolors no # cell: kept on the node as fields of their own. -TRAITS =! Set.new ['focusable', 'autofocus', 'disabled', 'cursor'] +TRAITS =! Set.new ['focusable', 'autofocus', 'disabled', 'modal', 'cursor'] KIND =! Map.new() KIND.set key, 'layout' for key in LAYOUT @@ -353,7 +353,7 @@ export class Element extends Node @road = null @styled = null @still = false # written above the frame by `Static` (screen.rip): its display stays none - @focusable = @autofocus = @disabled = false + @focusable = @autofocus = @disabled = @modal = false @cursor = null @glow = null @@ -414,8 +414,8 @@ export class Element extends Node @mark 3 if key is 'display' @changed kind is 'layout' - # `focusable`, `autofocus` and `disabled` are switches, and `cursor` a - # place. A frame is owed when the change can move focus or the cursor: + # `focusable`, `autofocus`, `disabled` and `modal` are switches, and + # `cursor` a place. A frame is owed when the change can move focus or the cursor: # a claim, a node that may not hold the focus it has, the focused # node's own cursor. trait!: (key, value) -> @@ -511,6 +511,7 @@ class Document extends Target @damage = Damage.new() @active = null # the node that has focus, until the next `tend` finds it cannot @claims = [] # nodes that arrived with `autofocus`, unsettled + @prior = WeakMap.new() # the holder each node took focus from, to give it back to (focus.rip) @failed = null # the error of a child that failed to construct, until it is thrown @lit = watch null @body = Element.new 'div' diff --git a/packages/tui/focus.rip b/packages/tui/focus.rip index 34651075..696436f1 100644 --- a/packages/tui/focus.rip +++ b/packages/tui/focus.rip @@ -8,8 +8,13 @@ # disabled — and so does where it sits: a keyed list reordered takes a # node out and puts it back in one turn. So nothing is decided when the # tree changes. `tend` settles it before every key and every frame: a -# focused node that cannot hold focus loses it to nothing, and a node -# that arrived with `autofocus` takes it if nothing has it. +# focused node that has left the tree gives focus back to the holder it +# took it from, one that is hidden or disabled loses it to nothing, and +# a node that arrived with `autofocus` takes it if nothing has it — or +# from a holder outside the `modal` box it arrived in. +# +# A `modal` box holds Tab: from a node inside one, Tab and Shift-Tab go +# round the nearest one and never leave it. import { blame } from './layout.rip' @@ -32,6 +37,27 @@ export open =! (node, doc) -> node = node.parentNode false +# Whether `node` is in `doc`'s tree. +placed =! (node, doc) -> + while node + return true if node is doc.body + node = node.parentNode + false + +# Whether `node` is `above` or under it. +within =! (node, above) -> + while node + return true if node is above + node = node.parentNode + false + +# The nearest `modal` box that holds `node`, itself included, or null. +modalOf =! (node) -> + while node + return node if node.modal + node = node.parentNode + null + # The node after `node` in tree order and the one before it, under # `root`, passing over everything under a node that is shut. after =! (node, root) -> @@ -56,7 +82,12 @@ deepest =! (node) -> # DOM's do: inside the blur nothing has focus, and inside the focus the # node has it — so a listener that throws leaves the state whole. A # listener of the blur that moves focus itself has the last word. -export take! =! (doc, node) -> +# +# The node that takes focus remembers the holder it took it from, or — +# where both are inside one `modal` box — the one that holder +# remembered, so a box keeps the holder it was entered from however +# Tab goes round it. Focus given back (`home`) is remembered by no one. +export take! =! (doc, node, home = false) -> was = doc.active return if was is node doc.owe false if was?.cursor or node?.cursor @@ -66,6 +97,10 @@ export take! =! (doc, node) -> was.glow?.write false was.dispatchEvent { type: 'blur', bubbles: false } if node and not doc.active + unless home + bound = modalOf node + from = if was and bound and bound is modalOf(was) then doc.prior.get(was) else was + if from then doc.prior.set(node, from) else doc.prior.delete(node) doc.active = node doc.lit.write node node.glow?.write true @@ -78,37 +113,48 @@ barred =! (node) -> node = node.parentNode false -# Settle what the tree's changes left open. A claim on a node that is -# not `focusable` is refused by name here, where both switches have +# Settle what the tree's changes left open. A focused node that has +# left the tree gives focus back to the holder it remembers, if that one +# can hold it; one still in it, hidden or disabled, gives it to nothing. +# A claim on a node that is not `focusable` is refused by name here, where both switches have # arrived in whichever order they were written; one on a disabled node # claims nothing. export tend! =! (doc) -> - take doc, null if doc.active and not open doc.active, doc + was = doc.active + if was and not open was, doc + back = if placed(was, doc) then null else doc.prior.get(was) + take doc, (if back and open(back, doc) then back else null), true return unless doc.claims.length claims = doc.claims doc.claims = [] for node in claims when node.autofocus and not node.focusable and not barred node throw blame Error.new("rip/tui: autofocus on <#{node.tagName}> that is not focusable — write focusable: true"), node - node = if doc.active then null else doc.body + holder = doc.active + return if holder and not claims.some (claim) -> modalOf(claim) + node = doc.body while node if node.focusable and not shut(node) and claims.includes node - take doc, node - return + bound = modalOf node + if not holder or (bound and not within(holder, bound)) + take doc, node + return node = after node, doc.body # Tab and Shift-Tab: the next node that can hold focus, in tree order -# and round again. With none, focus stays where it is. +# and round again — round the nearest `modal` box that holds the focused +# node, or the whole tree. With none, focus stays where it is. export advance! =! (doc, back) -> tend doc - start = doc.active ?? doc.body + root = modalOf(doc.active) ?? doc.body + start = doc.active ?? root node = start round = false loop - node = if back then before(node, doc.body) else after(node, doc.body) + node = if back then before(node, root) else after(node, root) unless node return if round round = true - node = if back then deepest(doc.body) else doc.body + node = if back then deepest(root) else root return if node is start if node.focusable and not shut node take doc, node diff --git a/packages/tui/test/events.rip b/packages/tui/test/events.rip index 89a079ef..7d1b91c4 100644 --- a/packages/tui/test/events.rip +++ b/packages/tui/test/events.rip @@ -625,6 +625,119 @@ test "`autofocus` on a node that is not `focusable` is refused by name where the node.set 'autofocus', true throws (-> view.frame()), "#{refused} (on div > div[data-part=Box] > div#unfit)" +# A list, and a dialog over it that holds Tab while it is open. +Dialog =! component + @open := false + @hide := false + @inner := false + @off := null + @gone := null + @extra := false + left ~= ['one', 'two', 'three'].filter (name) => name isnt @gone + render + Box flexDirection: 'column' + Box id: 'list', flexDirection: 'column' + for name in left + Box key: name, id: name, focusable: true, disabled: name is @off + Text name + if @extra + Box id: 'extra', focusable: true, autofocus: true + Text "extra" + if @open + Box id: 'dialog', modal: true, hidden: @hide, flexDirection: 'column' + Box id: 'field', focusable: true, autofocus: true + Text "field" + Box id: 'ok', focusable: true + Text "ok" + if @inner + Box id: 'confirm', modal: true + Box id: 'yes', focusable: true, autofocus: true + Text "yes" + Box id: 'no', focusable: true + Text "no" + Box id: 'cancel', focusable: true + Text "cancel" + +# Who heard focus and blur, in order, among the nodes named. +listen =! (events, ids) -> + for id in ids + node = find id + node.addEventListener 'focus', (event) -> events.push "focus #{idOf event.target}" + node.addEventListener 'blur', (event) -> events.push "blur #{idOf event.target}" + +test "a `modal` dialog with `autofocus` takes focus as it opens, holds Tab, and gives focus back to the node that had it as it is removed", -> + mounted Dialog, {}, (view) -> + view.press 'Tab' + view.press 'Tab' + eq idOf(view.focused), 'two' + events = [] + listen events, ['one', 'two', 'three'] + view.app.extra.value = true + view.app.open.value = true + listen events, ['field', 'ok', 'cancel'] + eq idOf(view.focused), 'field', 'the claim of a node inside a modal box takes focus from a holder outside it, and one outside any takes none' + eq cycle(view), ['ok', 'cancel', 'field'] + eq cycle(view, 'Tab', shift: true), ['field', 'cancel', 'ok'] + eq idOf(view.focused), 'field' + events.length = 0 + view.app.open.value = false + eq idOf(view.focused), 'two', 'focus is back where it was before the dialog took it, however Tab went round it' + eq events, ['blur field', 'focus two'] + eq [idOf(focus.active), idOf(document.activeElement), find('two').focused], ['two', 'two', true] + view.app.gone.value = 'two' + eq idOf(view.focused), 'one', 'focus given back is remembered by no one: the node keeps the holder it took focus from' + eq cycle(view), ['three', 'extra', 'one'], 'with the dialog gone Tab walks the whole tree' + throws (-> find('one').set 'modal', 'yes'), 'rip/tui: modal: "yes" is not true or false' + +test "Tab from outside any modal box walks the whole tree, into a modal box, and is held there", -> + mounted Dialog, { props: { open: true } }, (view) -> + eq idOf(view.focused), 'field' + focus.to find 'one' + view.press 'Tab' + view.press 'Tab' + eq idOf(view.focused), 'three' + view.press 'Tab' + eq idOf(view.focused), 'field' + eq cycle(view), ['ok', 'cancel', 'field'] + focus.to null + eq cycle(view, 'Tab', shift: true), ['cancel', 'ok', 'field'], 'Shift-Tab from nothing reaches the last node in tree order, inside the box' + +test "nested modal boxes: the innermost holds Tab and gives focus back as it goes; boxes that go together give back nothing", -> + mounted Dialog, {}, (view) -> + find('one').focus() + view.app.open.value = true + eq idOf(view.focused), 'field' + view.press 'Tab' + eq idOf(view.focused), 'ok' + view.app.inner.value = true + eq idOf(view.focused), 'yes', 'a claim inside the inner box takes focus from a holder outside it' + eq cycle(view), ['no', 'yes'] + view.app.inner.value = false + eq idOf(view.focused), 'ok' + eq cycle(view), ['cancel', 'field', 'ok'] + view.app.inner.value = true + eq idOf(view.focused), 'yes' + view.app.open.value = false + eq view.focused, null, 'the holder the inner box took focus from left the tree with it' + +test "focus given back falls to nothing when its holder was disabled or removed meanwhile; a dialog hidden, not removed, loses focus to nothing", -> + for how in ['off', 'gone', 'hide'] + mounted Dialog, {}, (view) -> + find('two').focus() + view.app.open.value = true + eq [how, idOf view.focused], [how, 'field'] + events = [] + listen events, ['one', 'three', 'field', 'ok', 'cancel'] + if how is 'hide' then view.app.hide.value = true else view.app[how].value = 'two' + eq [how, idOf view.focused], [how, 'field'] unless how is 'hide' + view.app.open.value = false unless how is 'hide' + eq [how, idOf(view.focused), events], [how, null, ['blur field']] + mounted Dialog, {}, (view) -> + view.app.open.value = true + eq idOf(view.focused), 'field' + view.app.open.value = false + eq view.focused, null, 'a dialog opened while nothing had focus gives back nothing' + test "`screen.focused` follows the terminal's focus reports", -> Shown = component render @@ -1189,6 +1302,13 @@ ordered =! (node, out = []) -> kid = kid.nextSibling out +# The nearest `modal` box that holds `node`, itself included, or null. +trap =! (node) -> + while node + return node if node.modal + node = node.parentNode + null + everything =! (node, out = []) -> kid = node.firstChild while kid @@ -1202,13 +1322,13 @@ Bare =! component render Box id: 'root' -test "random trees under random change: focus is always a node that can hold it, Tab walks the tree in order, focus and blur pair up, and a path is the tree as its dispatch began", -> +test "random trees under random change: focus is always a node that can hold it, Tab walks the tree in order and never leaves a modal box, a dialog's claim takes focus and its removal gives it back, focus and blur pair up, and a path is the tree as its dispatch began", -> seed = 20260921 rand = -> seed = (seed * 1664525 + 1013904223) % 4294967296 seed / 4294967296 pick = (list) -> list[Math.floor rand() * list.length] - counts = { steps: 0, agreed: 0, inserted: 0, removed: 0, moved: 0, switched: 0, tabs: 0, calls: 0, focuses: 0, blurs: 0, cleared: 0, cycles: 0, visited: 0, mutated: 0, heard: 0, claimed: 0 } + counts = { steps: 0, agreed: 0, inserted: 0, removed: 0, moved: 0, switched: 0, tabs: 0, calls: 0, focuses: 0, blurs: 0, cleared: 0, cycles: 0, visited: 0, mutated: 0, heard: 0, claimed: 0, modals: 0, opened: 0, closed: 0, restored: 0, fell: 0, trapped: 0 } for round in [0...120] view = mount Bare, cols: 20 try @@ -1217,6 +1337,8 @@ test "random trees under random change: focus is always a node that can hold it, holder = null # the node the focus and blur events say has focus trail = [] # who heard the key under way meddle = null # a change one listener makes mid-dispatch + dialogs = [] # the modal boxes opened with a claim inside + opening = null # the one opened this step, whose claim the step settles say = (problem) -> raise "seed 20260921 round #{round} step #{counts.steps}: #{problem}" # A node out of the tree hears its own blur and nothing above it # does, so each node listens for itself. @@ -1235,6 +1357,9 @@ test "random trees under random change: focus is always a node that can hold it, node.set 'disabled', true if rand() < 0.08 node.set 'hidden', true if rand() < 0.06 node.set 'autofocus', true if rand() < 0.15 and node.focusable + if rand() < 0.1 + node.set 'modal', true + counts.modals += 1 hear = (event) -> trail.push "#{idOf node} #{event.eventPhase}" if meddle and event.eventPhase > 1 @@ -1258,7 +1383,7 @@ test "random trees under random change: focus is always a node that can hold it, false change = -> all = everything root - kind = Math.floor rand() * 6 + kind = Math.floor rand() * 8 if kind is 0 or all.length < 6 place make() counts.inserted += 1 @@ -1270,6 +1395,25 @@ test "random trees under random change: focus is always a node that can hold it, node.remove() place node counts.moved += 1 + else if kind is 6 + # A dialog: a modal box with a focusable field that claims focus. + box = make() + box.set 'modal', true + box.appendChild make() for n in [0...Math.floor(rand() * 3)] + field = make() + field.set 'focusable', true + field.set 'autofocus', true + kids = box.childNodes + box.insertBefore field, (if kids.length then pick(kids) else null) + place box + dialogs.push box + opening = box + counts.opened += 1 + else if kind is 7 + up = dialogs.filter (box) -> inside box, document.body + if up.length + pick(up).remove() + counts.closed += 1 else node = pick all key = pick ['focusable', 'disabled', 'hidden', 'display'] @@ -1283,12 +1427,17 @@ test "random trees under random change: focus is always a node that can hold it, for step in [0...80] counts.steps += 1 had = view.focused # which settles any claim an arrival made + opening = null act = rand() if act < 0.45 change() else if act < 0.6 + bound = trap had view.press 'Tab', shift: rand() < 0.5 counts.tabs += 1 + if bound + say "Tab from #{idOf had} left the modal box #{idOf bound} for #{idOf view.focused}" unless inside view.focused, bound + counts.trapped += 1 else if act < 0.75 target = pick everything root continue unless target @@ -1331,21 +1480,38 @@ test "random trees under random change: focus is always a node that can hold it, counts.agreed += 1 counts.cleared += 1 if had and not now counts.claimed += 1 if now and not had and act < 0.45 + # A dialog that opened takes focus by the first claim in it, in + # tree order, that can hold focus — from whatever had it, since + # that is outside the box. + if opening + want = [opening, ...everything(opening)].find((node) -> node.autofocus and holds node) ?? had + say "the dialog #{idOf opening} opened and left focus with #{idOf now}, not #{idOf want}" unless now is want + # A holder that left the tree gives focus back to the holder it + # remembers, if that one can hold it, and to nothing if not. + if had and not inside had, document.body + back = document.prior.get had + want = if back and holds(back) then back else null + say "#{idOf had} left the tree and focus went to #{idOf now}, not #{idOf want}" unless now is want + if want then counts.restored += 1 else counts.fell += 1 # A cycle: Tab from here reaches every node that can hold focus - # once, in tree order, and comes back. + # once, in tree order, and comes back — round the modal box that + # holds the focused node, and inside one once it is reached. continue unless step % 10 is 9 - order = ordered document.body - continue unless order.length - from = if now then order.indexOf(now) else -1 + at = now + presses = ordered(trap(at) ?? document.body).length + continue unless presses back = rand() < 0.5 - for n in [1..order.length] + for n in [1..presses] + order = ordered(trap(at) ?? document.body) + from = if at then order.indexOf(at) else -1 + due = if back then order[((if from < 0 then order.length else from) - 1 + order.length) % order.length] else order[(from + 1) % order.length] view.press 'Tab', shift: back - due = if back then order[((if from < 0 then order.length else from) - n + order.length * 2) % order.length] else order[(from + n) % order.length] - say "#{if back then 'Shift-' else ''}Tab #{n} of #{order.length} reached #{idOf view.focused}, not #{idOf due}" unless view.focused is due + say "#{if back then 'Shift-' else ''}Tab #{n} of #{presses} from #{idOf at} reached #{idOf view.focused}, not #{idOf due}" unless view.focused is due + at = due counts.visited += 1 counts.cycles += 1 finally view.close() # A fuzz that does nothing passes everything: hold it to its volume. - floors = { steps: 9600, agreed: 9500, inserted: 1100, removed: 550, moved: 550, switched: 1600, tabs: 1100, calls: 1100, focuses: 1500, blurs: 1500, cleared: 300, cycles: 440, visited: 950, mutated: 600, heard: 2600, claimed: 60 } + floors = { steps: 9600, agreed: 9500, inserted: 1100, removed: 550, moved: 550, switched: 1600, tabs: 1100, calls: 1100, focuses: 1500, blurs: 1500, cleared: 300, cycles: 440, visited: 950, mutated: 600, heard: 2600, claimed: 60, modals: 330, opened: 540, closed: 270, restored: 70, fell: 70, trapped: 320 } ok counts[name] >= floor, "#{name}: #{counts[name]} is under the floor of #{floor} — #{JSON.stringify counts}" for own name, floor of floors From af7fd008a01a7410024e53b4ce261d201864086a Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Tue, 22 Sep 2026 14:06:37 -0600 Subject: [PATCH 6/6] tui: the line counts are the counting rule's after the runtime's error fix --- packages/tui/PLAN.md | 2 +- packages/tui/README.md | 2 +- packages/tui/bench/RESULTS.md | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index cfc11716..0b750fee 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -61,7 +61,7 @@ together, counted by `bench/lines.rip` and quoted from |---|---|---| | Framework only | Ink `src/` 6,760 | 4,308 | | Framework + layout algorithm | + Yoga 3.2.1 `yoga/algorithm/` 3,042 = 9,802 | 4,308 (`layout.rip` is 1,466 of it) | -| Full runtime closure | + React, react-reconciler, scheduler and 33 more packages | + Rip runtime 1,598 (`reactive.js`, `components.js`) = 5,906 | +| Full runtime closure | + React, react-reconciler, scheduler and 33 more packages | + Rip runtime 1,601 (`reactive.js`, `components.js`) = 5,909 | Yoga is counted at 3.2.1, the version Ink 7.1.1 ships, at `misc/yoga` or the checkout `YOGA_SRC` names (`lines.rip` refuses any other). The honest headline is **2.3× smaller** with the layout algorithm on both sides, and 1.6× framework against framework: diff --git a/packages/tui/README.md b/packages/tui/README.md index af15594c..db25b924 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -149,7 +149,7 @@ Lines of code, by the rule above (`bun run lines`): |---|--:|--:| | Framework only | Ink `src/` 6,760 | 4,308 | | Framework + layout algorithm | + Yoga 3.2.1 `yoga/algorithm/` 3,042 = 9,802 | 4,308 (layout.rip is 1,466 of it) | -| Full runtime closure | + React, react-reconciler, scheduler and 33 more packages | + Rip runtime 1,598 = 5,906 | +| Full runtime closure | + React, react-reconciler, scheduler and 33 more packages | + Rip runtime 1,601 = 5,909 | Ink + Yoga 3.2.1, the version Ink 7.1.1 ships, is 2.3× the lines of this package with the layout algorithm on both sides, 1.6× framework diff --git a/packages/tui/bench/RESULTS.md b/packages/tui/bench/RESULTS.md index ad25e658..ae21daef 100644 --- a/packages/tui/bench/RESULTS.md +++ b/packages/tui/bench/RESULTS.md @@ -34,11 +34,11 @@ Median of 7 fresh processes each; ✓ both first frames read the same. | | Ink + Yoga | Rip TUI | |---|--:|--:| -| Framework only | Ink `src/` 6,760 | 4,269 | -| Framework + layout algorithm | + Yoga 3.2.1 `yoga/algorithm/` 3,042 = 9,802 | 4,269 (layout.rip is 1,466 of it) | -| Full runtime closure | + React, react-reconciler, scheduler and 33 more packages (36 in all) | + Rip runtime 1,598 (reactive.js, components.js) = 5,867 | +| Framework only | Ink `src/` 6,760 | 4,308 | +| Framework + layout algorithm | + Yoga 3.2.1 `yoga/algorithm/` 3,042 = 9,802 | 4,308 (layout.rip is 1,466 of it) | +| Full runtime closure | + React, react-reconciler, scheduler and 33 more packages (36 in all) | + Rip runtime 1,601 (reactive.js, components.js) = 5,909 | -Framework against framework, Ink is 1.6× the lines of Rip TUI; with the layout algorithm on both sides, Ink + Yoga 3.2.1 (the version Ink 7.1.1 ships) is 2.3×. Rip TUI's files: tui.rip 307, document.rip 390, focus.rip 64, layout.rip 1,466, text.rip 421, paint.rip 713, screen.rip 177, terminal.rip 205, input.rip 315, mouse.rip 211. +Framework against framework, Ink is 1.6× the lines of Rip TUI; with the layout algorithm on both sides, Ink + Yoga 3.2.1 (the version Ink 7.1.1 ships) is 2.3×. Rip TUI's files: tui.rip 307, document.rip 393, focus.rip 99, layout.rip 1,466, text.rip 421, paint.rip 713, screen.rip 178, terminal.rip 205, input.rip 315, mouse.rip 211. ## One frame, whole and damaged