Skip to content
64 changes: 40 additions & 24 deletions packages/tui/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`, `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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -874,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,
Expand All @@ -885,16 +887,32 @@ 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.
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.
- **`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
Expand Down Expand Up @@ -1346,11 +1364,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):
Expand Down
48 changes: 32 additions & 16 deletions packages/tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,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
Expand Down Expand Up @@ -531,18 +531,30 @@ 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, or not `focusable`, 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. 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
Expand Down Expand Up @@ -734,7 +746,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
Expand Down Expand Up @@ -784,7 +798,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
Expand Down
17 changes: 0 additions & 17 deletions packages/tui/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,9 @@ 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.
- [ ] 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

- [ ] 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.
- [ ] `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
Expand All @@ -82,10 +69,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

Expand Down
8 changes: 4 additions & 4 deletions packages/tui/bench/RESULTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading