diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index d036ca18..68fe62d0 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -59,12 +59,11 @@ together, counted by `bench/lines.rip` and quoted from | Row | Ink + Yoga | Rip TUI | |---|---|---| -| Framework only | Ink `src/` 6,760 | 4,252 | -| Framework + layout algorithm | + Yoga 3.2.1 `yoga/algorithm/` 3,042 = 9,802 | 4,252 (`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,850 | +| 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 | -Yoga is counted at 3.2.1, the version Ink 7.1.1 ships (`lines.rip` -refuses any other). The honest headline is **2.3× smaller** with the +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: not the 3× the budget aimed at, since the package ships the mouse, the enhanced @@ -111,8 +110,8 @@ README carries this matrix. Ink's string `Transform` has no counterpart because it has no job here: a text transform is an ordinary expression in the binding -(`"#{name.toUpperCase()}"`), and per-cell restyling is a style -callback (§6). +(`"#{name.toUpperCase()}"`), and a run that needs a style of its +own is its own `span` (§6). ## 3. Architecture @@ -125,7 +124,7 @@ component effects ──► node setters ──► dirty marks ──► frame f ``` One flush per reactive batch: a microtask, a minimum interval of about -8 ms, deferred while `write()` reports backpressure. Each key of a +8 ms. 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 @@ -138,17 +137,17 @@ write by hand; hot loops use the indexed `for x, i in` form). | Module | Job | Code lines | |---|---|---| -| `tui.rip` | Entry: `run`, `mount` and its input, `renderToString`, `suspend`, `screen`, `focus`, widgets, `print`, `clock`; the delivery of events, the default actions | 310 | -| `document.rip` | Terminal document: nodes, tree links, the event and its dispatch, style road, keyboard traits, damage marks | 388 | -| `focus.rip` | Who can hold focus, tree order, taking it, settling it | 62 | +| `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 | | `layout.rip` | Flexbox, containing blocks, baseline, cache, edge rounding | 1,466 | | `text.rip` | Sanitize, grapheme clusters, width, wrap, truncate | 421 | -| `paint.rip` | Cell grids, styles at the terminal's depth, clip, borders, backgrounds, the selection overlay, damage, diff, a subtree painted once | 694 | -| `screen.rip` | Frames, pacing, the cursor, where the frame sits, the write above the frame (`Static`, `print`, the console), progress, the alternate screen | 173 | +| `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 | | `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 | 181 | -| | **Total** | **4,222 built; about 4,250 complete** | +| `terminal.rip` | Setup / teardown: raw mode, the modes, the probes, the cursor, the alternate screen, the signals, suspend and resume, the console, the depth read | 205 | +| | **Total** | **4,269** | 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 @@ -350,10 +349,7 @@ byte as upstream wrote them, against a test-only shim shaped like the of its hand-written cases for measure functions, the measure cache, measure modes, rounding, dirtying, and computed edges (`test/yoga-hand.rip`; `test/yoga/SOURCE.md` says which are left out - and why). Real - `yoga-layout` 3.2.1, the release Ink ships, passes 537 of the 543 - through the same runner: it predates the intrinsic keywords and one - alignment fix. + and why). - **Two cases answer differently on purpose, pinned, not skipped** (divergences 1 and 6 below). The runner holds each differing expectation to this engine's exact answer, with the reason beside @@ -527,12 +523,11 @@ are benchmarked, since a cell diff can emit more than a line diff. **Decided — styling is structural only.** Raw ANSI inside text is what forces Ink's 509-line tokenizer and repeated re-tokenizing. Text is sanitized once in the `data` setter (escape and control characters -stripped, tabs expanded). For text that arrives pre-colored, an opt-in -`ansi(str)` helper parses SGR into styled spans once, outside the -paint path. Ink's string `Transform` is replaced by a per-cell style -callback. +stripped, tabs expanded). Text that arrives pre-colored is sanitized like any +other — its escapes are stripped — and a run that needs a style of its +own is its own `span`. -**Held to Ink's own tests.** `test/ink/` ports 478 of Ink's paint +**Held to Ink's own tests.** `test/ink/` ports 521 of Ink's paint cases — borders, backgrounds, overflow, text, wrapping, truncation, widths, hyperlinks, content offset, position, display, the flex files — with every expected frame taken from published Ink 7.1.1 as an @@ -584,7 +579,7 @@ blanks off, as the plain form does. ## 7. Input, focus, cursor (`input.rip`, `document.rip`) -**Events replace hooks** (built: `document.rip`, `tui.rip`). A key is +**Events replace hooks**. A key is a `keydown` sent to `focus.active`, or to `document.body` while nothing has focus, as DOM sends it — never to the app's first element, which would change target as a sibling came before it and leave a second root @@ -718,7 +713,7 @@ in `test/input/`, 205 held to Ink's answer under one mapping to DOM names and 39 stated differences; `test/input/SOURCE.md` lists them and the 12 left out. -**Enhanced keyboard** (built: `terminal.rip`) is opt-in (`run App, keyboard: +**Enhanced keyboard** is opt-in (`run App, keyboard: 'enhanced'`; the default is `'basic'`, and any other value is refused by name). Setup asks the terminal for the kitty protocol's disambiguation flag and teardown withdraws it; the decoder is always @@ -750,7 +745,7 @@ forwards xterm's modify-other-keys form (`CSI 27 ; mod ; code ~`), which the decoder reads; and the 500 ms `escape-time` that delays a lone Escape for every terminal program, which is the user's to lower. -**Mouse** (built: `mouse.rip`, the modes and the probe in `terminal.rip`) is +**Mouse** is opt-in (`run App, mouse: true`), because capture takes over the terminal's own text selection. `true` asks for button-event tracking (`CSI ? 1002 h`) and SGR reports (`CSI ? 1006 h`): presses, releases, @@ -843,7 +838,7 @@ two rows' cells when the pointer crosses, and nothing while it rests. A report that arrives while the mouse is off is dropped, never typed. Ink has no mouse support. -**Selection** (built: `mouse.rip`, the overlay in `paint.rip`). With the +**Selection**. With the mouse on, a drag with the left button selects the cells from the press to the pointer in reading order — linear, as a terminal selects: the first row from the press to its end, the rows between whole, the last @@ -876,7 +871,7 @@ margin, which is what the bench drives). It is a paint-only change, so a scroll runs no layout. A wheel handler that adjusts the offset is the whole scrolled-list pattern. -**Focus** (built: `focus.rip`) belongs to a node and follows tree +**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 @@ -919,7 +914,7 @@ and a focusable node inside a focusable node is reached after it. DOM's does; `node.focused` is a reactive read, minted on first use, which is how a node styles itself by its focus through `ref:`. -**Cursor** (built: `screen.rip`). A focused node declares `cursor: +**Cursor**. A focused node declares `cursor: {x, y}`, whole cells from its own rounded corner, border and padding included, `x` in cells and not in characters. After each frame the renderer parks the hardware cursor there and shows it, and hides it @@ -938,7 +933,7 @@ hardware cursor. A cursor belongs to a node, so in a frame taller than the terminal it stays with its row of the tree, where Ink counts `y` from the top of what is shown. -**stdin** (built: `terminal.rip`; every way out is §8's). `run +**stdin** (every way out is §8's). `run App, stdin:` reads a stdin that is a terminal and can be set raw: raw mode, `ref`, `resume`, one `data` listener feeding the `Parser`, and bracketed paste (`CSI ? 2004 h`) and focus reports (`CSI ? 1004 h`) @@ -1141,15 +1136,13 @@ run App ## 10. Testing - `test.rip` on `rip/testing`, importing `rip/tui`, opening with a - "Package surface" section. Streams and the clock are injectable; no - pty dependency. One end-to-end smoke test runs under - `script -q /dev/null`. + "Package surface" section. Streams and the clock are injectable. Job + control — Ctrl-Z, `fg`, a signal while stopped — runs under + `test/terminal/ptyrun.py`, a pty shell the suite spawns (Python 3). - **Layout:** the Yoga suite and the ported aspect ratio cases (§5). -- **Paint:** about 400 Ink cases ported as literal expected strings - plus a plain cell dump — borders 52, backgrounds 35, overflow 44, - text 57 (minus ANSI), wrap and width 32, dimensions 29, content - offset 23, position 13, render-to-string 37, log-update 34, resize - 10, synchronized write 5, static 5, wide-character regressions 10. +- **Paint:** 521 of Ink's 632 titles ported (`test/ink/SOURCE.md` says + what is left out and why), every frame from published Ink 7.1.1 as the + oracle. - **Damage:** `test/damage.rip` changes random trees a step at a time — texts, styles, clips, offsets, nodes inserted, removed, moved, hidden, a keyed list driven through its component, resizes — and @@ -1446,7 +1439,7 @@ start. Each row is a rule the v0.1 code follows. |---|---|---| | Screen-reader output | `role` and every `aria-*` attribute are accepted and stored on the node, never rejected as unknown keys, so components are written accessibly from day one. The hardware cursor follows focus (§7). | One tree walk that serializes roles, states, and labels as linear text — the counterpart of Ink's `renderNodeToScreenReaderOutput`. | | Windows | Rip itself claims only macOS and Linux (CI is Linux). All platform code lives in `terminal.rip`. Resize comes from the stream's `resize` event, never the SIGWINCH signal. Suspend is guarded by platform. The painter never writes the last cell of the last row. Nothing rejects `win32`. | A CI lane and whatever it finds. | -| Error overview | Uncaught errors and the runtime's component error hook (`__setErrorHandler`) route through one reporter that restores the terminal first. | A prettier reporter: source excerpt and mapped stack. | +| Error overview | An uncaught error, and a child that fails to construct (the runtime's `__setChildFailureReporter`), give the terminal back first and reach the listeners after the package's, once (§8). | A prettier reporter: source excerpt and mapped stack. | | Right-to-left | The reverse directions already flip an axis (§5). | A flip per direction, the 543 RTL halves, and bidirectional text. | ## 14. Decisions @@ -1455,12 +1448,12 @@ start. Each row is a rule the v0.1 code follows. on raw tags and unknown rest props are errors in the TypeScript face only (`rip check`, the editor); they compile and run. The skeleton ships with that limit; a typed rest / attribute vocabulary for - non-HTML hosts lands in the compiler, as its own PR, before PR 6. + non-HTML hosts is filed separately (TODO §8). Declaring every prop on `Box` is rejected at a measured 9× mount cost. 2. **Yoga's suite — decided.** Vendored as-is under `test/yoga/`. 3. **RTL — decided.** Deferred behind the direction seam (§5). -4. **Static position and baseline — decided.** In scope for PR 2 (§5). +4. **Static position and baseline — decided.** In scope (§5). 5. **Name.** `@rip/tui`, imported as `rip/tui`. ## 15. Risks @@ -1470,5 +1463,5 @@ start. Each row is a rule the v0.1 code follows. sequences; mitigated by forced column moves, not solved. - Inline reflow after a resize can only be estimated. - tmux strips kitty sequences and delays ESC by 500 ms by default. -- A cell diff can write more bytes than a line diff; measured in PR 0. +- A cell diff can write more bytes than a line diff; the bench counts bytes per update. - A global `document` is visible to every module in the process. diff --git a/packages/tui/README.md b/packages/tui/README.md index 1574d47a..15e8cffc 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -50,7 +50,7 @@ bun install # once, at the repository root rip examples/counter.rip # from packages/tui: ↑ and ↓ count, Ctrl-C quits ``` -`run` returns `{ app, done, quit, flush }`; `done` resolves with the +`run` returns `{ app, done, quit, flush }`, and `run App, props: {…}` hands the component its initial public state, as `mount` does; `done` resolves with the value given to `quit`. Ctrl-C quits. The last frame stays in the scrollback and the cursor lands on the line below it. @@ -105,7 +105,10 @@ Run one with `rip examples/ink/counter.rip`. the same tree, node for node, against the same fake 200×60 terminal — each in a fresh process, five times, and writes [bench/RESULTS.md](bench/RESULTS.md): -every number reproduces with `bun run bench`. +every number reproduces with `bun run bench`. The line count needs Ink's +source at `misc/ink` and a Yoga checkout at 3.2.1, the version Ink 7.1.1 +ships — `misc/yoga`, or the checkout `YOGA_SRC` names — and `bun run +lines` refuses any other version, so the count is of the Yoga Ink runs on. Ink is measured as a careful React app is written: React's production build, memoized rows, `interactive: true`, incremental rendering on, its frame throttle lifted, every update @@ -144,17 +147,17 @@ Lines of code, by the rule above (`bun run lines`): | | Ink + Yoga | Rip TUI | |---|--:|--:| -| Framework only | Ink `src/` 6,760 | 4,252 | -| Framework + layout algorithm | + Yoga 3.2.1 `yoga/algorithm/` 3,042 = 9,802 | 4,252 (layout.rip is 1,466 of it) | -| Full runtime closure | + React, react-reconciler, scheduler and 33 more packages | + Rip runtime 1,598 = 5,850 | +| 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 | 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 against framework. Two rows where the table is not one-sided: after a resize the frame is drawn from nothing, which is more bytes than Ink's incremental log writes; and a -`Static` append grows with the items already written — about 150 µs -averaged over 1,000 appends, 350 over 8,000 — where Ink's stays flat, +`Static` append grows with the items already written — about 131 µs +averaged over 1,000 appends, 270 over 8,000 (`rip bench/tui.rip static 8`) — where Ink's stays flat, so past a few thousand appends Ink is the faster side. ## Examples @@ -254,8 +257,8 @@ scrollback (below). | `position` (`'relative'`, `'absolute'`, `'static'`) with `top`, `right`, `bottom`, `left` | | | `aspectRatio`, `boxSizing`, `display` (`'flex'`, `'none'`, `'contents'`), `hidden` | | | `borderStyle`: `single`, `double`, `round`, `bold`, `singleDouble`, `doubleSingle`, `classic`, `arrow`, or an object of eight glyphs; `borderTop` / `borderRight` / `borderBottom` / `borderLeft: false` drops an edge | `contentOffsetX`, `contentOffsetY` — shift a box's children; a scroll is a repaint and runs no layout | -| `overflow`, `overflowX`, `overflowY`: `'visible'` or `'hidden'` (clips to the padding box) | | -| 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 `…`) | On `Text`: `link` — a URL; the words are a hyperlink (OSC 8) | +| `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) | 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 — @@ -339,10 +342,9 @@ bottom), a sweep as a paint starts, and damage past half the screen are painted whole. One changed cell of a full 200×60 table — a text that keeps its size, which owes its words and no survey — is about 1 µs of paint and diff where the whole frame is about -125 µs; a change that is surveyed costs a few more — a row recolored in -a 2,000-row clipped log is about 7 µs where the whole frame is about -85 µs — and the bytes are the same (Apple M5, Bun 1.4.2; `bun run -frame` in `bench/` prints the first). +143 µs; a last row that comes and goes, which owes a survey and its +rows, about 23 µs where the whole frame is about 56 µs — and the bytes +are the same (`bench/RESULTS.md`; `bun run frame` in `bench/`). Layout is flexbox as Yoga lays it out — the defaults are Yoga's (`flexDirection: 'column'`, `flexShrink: 0`, `alignItems: 'stretch'`, @@ -418,7 +420,8 @@ view.focused # the node that has focus, or null view.cursor # where the last frame parked the cursor, { x, y }, or null while hidden view.scrollback # what `Static` and `print` wrote above the frame so far, as it was written view.stderr # what `print.err` wrote -view.close() # unmount, and give the process its `document` slot back +view.held # the mount's record, for a test that reaches past the driver: `doc`, `out`, `parser`, `clock`, and `view`, the Screen (`alt`, `interactive`, `origin`) +view.close() # unmount, and give the process its `document` slot back; a closed mount refuses to draw or be driven, by name ``` Input takes the road `run` reads stdin by — the same dispatch, the same @@ -436,6 +439,13 @@ test holds an update to a small repaint; `mount App, damage: false` against. A `quit` from the app closes the mount and resolves `view.done` with its value. +`bytes`, `damage` and `cursor` describe the last `frame()`: a frame that +draws nothing leaves `bytes` empty and `damage` 0, and the OSC 52 write +of a release sits in `bytes` until the next frame. `send` coordinates +are the terminal's: once `Static` or `print` has written rows above the +frame, the frame's row `r` is terminal row `min(rows written, rows − +frame height) + r`. + The terminal document is a global of the process, so one app is mounted at a time: a second `mount`, `run`, or `renderToString` is refused by name until the first is closed — close in a `finally`. @@ -510,7 +520,7 @@ prevented: | Key | Does | Keep the key with | |---|---|---| -| Tab, Shift-Tab (no Ctrl, Alt or Meta) | focus to the next or the previous node | `event.preventDefault()` — a text input that takes Tab | +| Tab, Shift-Tab — a Shift-Tab arrives as `Tab` with `shiftKey` (no Ctrl, Alt or Meta) | focus to the next or the previous node | `event.preventDefault()` — a text input that takes Tab | | Ctrl-C | `quit()` | `event.preventDefault()` — an app that asks before it leaves | | Escape | nothing: closing a dialog or clearing an input is the app's | | @@ -553,8 +563,13 @@ wide glyphs is measured, not counted. After every frame the hardware cursor is parked there and shown, where an input method and a screen reader look for it. Content offsets above the node move it, and a clip that leaves its cell out — or a frame taller than the terminal, whose -top rows are not shown — hides it. On `quit` it returns to the line -below the frame. +top rows are not shown — hides it, as does a place outside what the +node inks (its box, and words of its own that spill past it; the cell +after its last column counts, as an insertion point), so a cursor +computed from state that is stale for a frame disappears for that frame +and the app goes on. Only a value that is no +place at all — not `{ x, y }`, or not whole cells — is refused at the +write. On `quit` it returns to the line below the frame. A text input inserts `event.key` when it is one code point, and cuts its buffer into clusters for Backspace and for the cursor's column (the @@ -591,10 +606,12 @@ read is one frame, however many keys it holds, and a key that changes nothing owes no frame and draws nothing. On the select list above with ten items, an arrow key — its bytes through the parser, the dispatch, the listener, the state change, -and the frame of 12 cells and 55 bytes it causes — is about 8 µs, and a -key no listener acts on about 0.3 µs; with a hundred items the arrow is -about 55 µs, since each item's `inverse` is a binding that reads `at` -(Apple M5, Bun 1.4.2; `bun run keys` in `bench/`). +and the frame of 12 cells and 55 bytes it causes — is about 5.5 µs, and +a key no listener acts on about 0.2 µs; with a hundred items the arrow +is about 42 µs, since each item's `inverse` is a binding that reads +`at` (`bench/RESULTS.md`; `bun run keys` in `bench/`). Those keys move +texts that keep their size; a text that changes size runs a layout of +the whole tree, about 0.5 ms at 4,000 nodes. Ctrl-Z is [PLAN.md](PLAN.md)'s lifecycle step. @@ -662,8 +679,8 @@ left. Motion is cheap. A report that keeps its target dispatches nothing — no event is made unless a listener would hear it, for `mousemove`, `mouseenter` and `mouseleave` alike — and draws nothing; one that crosses from one row to the next costs the two rows' cells. On -a tree of 1,576 elements a motion report is about 0.5 µs and a click -about 1 µs, parser included (`bun run hit` in `bench/`). In inline mode +a tree of 2,403 nodes a motion report is about 0.6 µs and a click about +1.1 µs, parser included (`bench/RESULTS.md`; `bun run hit` in `bench/`). In inline mode the frame is not at the terminal's first row, so with the mouse the package asks the terminal where its cursor is (`CSI ? 6 n`) once the app stands and after every resize, and lowers the answer when a frame @@ -717,7 +734,7 @@ 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: +on: `screen.cols` and `screen.rows` are reactive reads of the terminal's size, 80 × 24 before `run`. - **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 @@ -801,7 +818,7 @@ Terminal, Ghostty, kitty and iTerm2 honor: a number from 0 to 1, `'error'`, `'indeterminate'`, or `null` to clear. It goes out with the next frame's write, and is cleared on every way out. -## What is here, and what is planned +## What is here [PLAN.md](PLAN.md) is the design and the order of work. `bench/` holds the harness with its terminal reducer, both contenders (`bun run ink`, diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index fbea44b3..8ceaa2f2 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -40,13 +40,16 @@ steps are in [PLAN.md](PLAN.md). compiled. Warm the bench longer, or make the path smaller. - [ ] A `Static` batch lays its container out as a root, and the body's next layout puts the container away again by visiting - every item under it: a walk as long as the list, per batch. The - 1,000-append row of the bench (PLAN §11) is unmeasured. + every item under it: a walk as long as the list, per batch, and + 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. -- [ ] `print` and a `Static` item write nothing on the alternate - screen, where Ink keeps them for the way out; console capture - (PLAN §8) decides what a run there keeps. +- [ ] 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 @@ -61,11 +64,11 @@ steps are in [PLAN.md](PLAN.md). subtrees whole: unmeasured on a tree of 10,000 nodes. - [ ] A select list that marks its choice with one binding an item (`inverse: n is at`) pays for every item on every arrow key: about - 55 µs at a hundred items where ten cost 8 (`bun run keys`). + 42 µs at a hundred items where ten cost 5.5 (`bun run keys`). - [ ] After a resize the frame's row is asked of the terminal again from what was the top-left; a terminal whose reflow moves the cursor off that row places clicks wrongly until the next resize. - The alternate screen (step 5) has no such row. + The alternate screen has no such row. - [ ] A selection is held to the rows the grid shows: in a frame taller than the terminal a drag past the top row selects nothing above it, and the tree rows scrolled out are not on the clipboard. @@ -91,3 +94,7 @@ 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. +- A name that is not defined, read as a prop of an element that holds a + keyed `for`, surfaces as a reconciler `TypeError` (`anchor.parentNode`, + `src/runtime/components.js:590`) with no node named, where the same + read on an element without the `for` surfaces as the `ReferenceError`. diff --git a/packages/tui/bench/RESULTS.md b/packages/tui/bench/RESULTS.md index 2324068f..ad25e658 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,252 | -| Framework + layout algorithm | + Yoga 3.2.1 `yoga/algorithm/` 3,042 = 9,802 | 4,252 (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,850 | +| 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 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 311, document.rip 388, focus.rip 63, layout.rip 1,466, text.rip 421, paint.rip 701, screen.rip 173, terminal.rip 203, 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 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. ## One frame, whole and damaged diff --git a/packages/tui/document.rip b/packages/tui/document.rip index 1e7c44e5..f770dc87 100644 --- a/packages/tui/document.rip +++ b/packages/tui/document.rip @@ -87,9 +87,12 @@ export watch =! (first) -> { read: (-> held), write: ((next) -> held = next) } # A cursor as a node keeps it: `{ x, y }` in whole cells from its corner. +# One outside what the node inks is kept and not shown (screen.rip), as +# one a clip hides is not: a state that is stale for a frame moves the +# cursor away, never the app. spot =! (value) -> return null unless value - unless typeof value is 'object' and Number.isInteger(value.x) and Number.isInteger(value.y) and value.x >= 0 and value.y >= 0 + unless typeof value is 'object' and Number.isInteger(value.x) and Number.isInteger(value.y) throw Error.new "rip/tui: cursor: #{JSON.stringify value} is not a place — write { x, y } in whole cells from the node's own corner" Object.freeze { x: value.x, y: value.y } @@ -195,7 +198,7 @@ export class Target event.currentTarget = null not event.defaultPrevented -export NOWHERE =! Object.freeze { x: 0, y: 0, w: 0, h: 0 } +NOWHERE =! Object.freeze { x: 0, y: 0, w: 0, h: 0 } # The leaf that draws `node`'s words: the outermost text it is a run of. # A `contents` text is no leaf — it lends its words to its parent, each @@ -249,7 +252,7 @@ export class Node extends Target if ref then ref.previousSibling = node else @lastChild = node node.parentNode = this if @tagName is 'span' and node.tagName is 'span' - for own key of node.styles + for own key, value of node.styles when value isnt undefined if KIND.get(key) is 'layout' throw blame Error.new("rip/tui: '#{key}' has no meaning on text nested in text — it is a run of words, styled by color and weight only"), node node.adopt @ownerDocument @@ -345,6 +348,7 @@ export class Element extends Node @attributes = null @road = null @styled = null + @still = false # written above the frame by `Static` (screen.rip): its display stays none @focusable = @autofocus = @disabled = false @cursor = null @glow = null @@ -357,7 +361,8 @@ export class Element extends Node catch error throw blame error, this - put: (key, value) -> + put!: (key, value) -> + value = 'none' if key is 'display' and @still if kept key (@attributes ?= Map.new()).set key, value return @@ -398,7 +403,10 @@ export class Element extends Node # A border or a background recolors the box; any other style can # reach everything under it. @mark (if key.startsWith('border') or key is 'backgroundColor' then 2 else 3) - if value is undefined then delete @styles[key] else @styles[key] = value + # A cleared style keeps its slot, holding `undefined`: a `delete` + # would turn the node's styles into a dictionary and every later + # read of any style on it would miss the engine's inline caches. + @styles[key] = value @mark 3 if key is 'display' @changed kind is 'layout' @@ -493,7 +501,7 @@ export failed! =! (doc) -> doc.failed = null throw error -export class Document extends Target +class Document extends Target constructor: -> super() @damage = Damage.new() diff --git a/packages/tui/focus.rip b/packages/tui/focus.rip index e435ba8e..4bd20592 100644 --- a/packages/tui/focus.rip +++ b/packages/tui/focus.rip @@ -11,10 +11,15 @@ # focused node that cannot hold focus loses it to nothing, and a node # that arrived with `autofocus` takes it if nothing has it. +# 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). +export visible =! (s) -> not (s.hidden is true or s.display is 'none') + # Whether `node` shuts the keyboard out of itself and all under it. shut =! (node) -> s = node.styles - node.disabled or (s? and (s.hidden is true or s.display is 'none')) + node.disabled or (s? and not visible s) # Whether `node` can hold `doc`'s focus. export open =! (node, doc) -> diff --git a/packages/tui/input.rip b/packages/tui/input.rip index 583389d6..032bf3aa 100644 --- a/packages/tui/input.rip +++ b/packages/tui/input.rip @@ -74,7 +74,9 @@ RUN =! 1024 # the reads of a paste joined into one string STREAM =! { stream: true } WHOLE =! { ignoreBOM: true } # a byte-order mark is a character, not the decoder's to take -TIMERS =! { after: (ms, fn) -> (timer = setTimeout fn, ms; -> clearTimeout timer) } +# The timers the package runs on outside a test: the process's own, which +# never keep it alive by themselves (the clock in tui.rip reads them too). +export TIMERS =! { now: (-> performance.now()), after: (ms, fn) -> (timer = setTimeout fn, ms; timer.unref?(); -> clearTimeout timer) } BODY =! /^[\d;:]*$/ STOP =! /[\x07\x1b]/g @@ -109,7 +111,7 @@ TILDE =! { # DOM having none. NAMED =! { 8: 'Backspace', 9: 'Tab', 13: 'Enter', 27: 'Escape', 127: 'Backspace' } -name =! (from, names) -> +name! =! (from, names) -> NAMED[from + n] = key for key, n in names.split ' ' return @@ -161,7 +163,7 @@ keyOf =! (code, mods, shifted) -> # ctrl; 32 is motion; 64 a wheel, whose low bits are up, down, left, # right; 128 xterm's buttons 8 to 11, DOM's 3 to 6. Low bits of 3 are # "no button" only under neither flag. -mouse =! (fields, final, out) -> +mouse! =! (fields, final, out) -> return unless fields.length is 3 and (final is 'M' or final is 'm') bits = num fields[0] x = num fields[1] @@ -184,7 +186,7 @@ mouse =! (fields, final, out) -> # (kitty, "Detection of support"), CSI ? … c (primary device # attributes), and CSI ? row ; column R (DECXCPR, the cursor report no # key can be mistaken for). -answer =! (fields, final, out) -> +answer! =! (fields, final, out) -> switch final when 'u' flags = num fields[0] @@ -198,7 +200,7 @@ answer =! (fields, final, out) -> # The plain form is a key's reply to `CSI 6 n`, which the package never # sends; only the `?` form is an answer to its own probe. -cursor =! (fields, out, marked = false) -> +cursor! =! (fields, out, marked = false) -> y = num fields[0] x = num fields[1] return unless x >= 1 and y >= 1 @@ -245,7 +247,7 @@ export class Parser # is let go. flush: -> out = [] - @settle out + @lapse out @half = '' @utf8.decode() out @@ -285,13 +287,13 @@ export class Parser expire!: -> out = [] - @settle out + @lapse out @late? out if out.length # What is held when its wait runs out: ESC alone is the Escape key, a # bare introducer is Alt and its character, an open paste ends as what # was collected, and anything else is discarded. - settle!: (out) -> + lapse!: (out) -> @stop?() @stop = null state = @state diff --git a/packages/tui/layout.rip b/packages/tui/layout.rip index 9d27dc87..e6541872 100644 --- a/packages/tui/layout.rip +++ b/packages/tui/layout.rip @@ -682,6 +682,8 @@ export blame =! (error, node) -> error.message += " (on #{whereabouts node})" if error instanceof Error error +# A value written past the document — the Yoga suite's shim writes +# styles straight — is refused here, with the node named. boxOf =! (node) -> s = node.styles try @@ -784,7 +786,7 @@ sync =! (node, owner) -> node.dirty = true if turned styles = node.styles ?? PLAIN bare = true - for key of styles + for key, value of styles when value isnt undefined bare = false break unless bare and lay.bare diff --git a/packages/tui/mouse.rip b/packages/tui/mouse.rip index c54a74a9..d3163dbd 100644 --- a/packages/tui/mouse.rip +++ b/packages/tui/mouse.rip @@ -9,7 +9,8 @@ # puts on the clipboard. import { Event } from './document.rip' -import { open, take, tend } from './focus.rip' +import { open, take, tend, visible } from './focus.rip' +import { clipped, clipOf } from './paint.rip' import { glyph } from './text.rip' # ── Hit test ────────────────────────────────────────────────────────────────── @@ -40,22 +41,21 @@ hit =! (node, x, y, dx, dy, left, top, right, bottom) -> return node s = node.styles lent = s.display is 'contents' - edged = s.borderStyle? unless lent - if (s.overflowX ?? s.overflow) is 'hidden' - left = Math.max left, nx + (if edged and s.borderLeft isnt false then 1 else 0) - right = Math.min right, nx + rect.w - (if edged and s.borderRight isnt false then 1 else 0) - if (s.overflowY ?? s.overflow) is 'hidden' - top = Math.max top, ny + (if edged and s.borderTop isnt false then 1 else 0) - bottom = Math.min bottom, ny + rect.h - (if edged and s.borderBottom isnt false then 1 else 0) + if s.overflow? or s.overflowX? or s.overflowY? + clipOf s, nx, ny, rect.w, rect.h, left, top, right, bottom + left = clipped[0] + top = clipped[1] + right = clipped[2] + bottom = clipped[3] dx -= Math.trunc s.contentOffsetX if s.contentOffsetX dy -= Math.trunc s.contentOffsetY if s.contentOffsetY unless x < left or x >= right or y < top or y >= bottom kid = node.lastChild while kid ks = kid.styles - shown = kid.nodeType is 3 or (kid.nodeType is 1 and not (ks.hidden is true or ks.display is 'none')) - if shown and (ks?.display is 'contents' or not (x < kid.bl + dx or x >= kid.br + dx or y < kid.bt + dy or y >= kid.bb + dy)) + seen = kid.nodeType is 3 or (kid.nodeType is 1 and visible ks) + if seen and (ks?.display is 'contents' or not (x < kid.bl + dx or x >= kid.br + dx or y < kid.bt + dy or y >= kid.bb + dy)) deep = hit kid, x, y, dx, dy, left, top, right, bottom return deep if deep kid = kid.previousSibling diff --git a/packages/tui/paint.rip b/packages/tui/paint.rip index 4803311a..d6ee8763 100644 --- a/packages/tui/paint.rip +++ b/packages/tui/paint.rip @@ -11,6 +11,7 @@ # have recolored, a span of columns on each row. import { kids, layout } from './layout.rip' +import { visible } from './focus.rip' import { CLUSTER, glyph, crowded, evict, sanitize, Flow, refresh, MODES, A0, A1, B0, B1, DOTS, ROW } from './text.rip' # ── Styles ──────────────────────────────────────────────────────────────────── @@ -140,8 +141,6 @@ export intern =! (fg, bg, flags, link = null) -> key = if link then "#{flags}|#{link}" else flags id = held.get key return id if id? - for [color, base] in [[fg, 30], [bg, 40]] when color? - throw Error.new "rip/tui: #{JSON.stringify color} is not a color — write #{COLORS}" unless tint color, base throw Error.new "rip/tui: more than #{LIMIT} distinct styles are in use — a cell holds a style id in sixteen bits" if records.length >= LIMIT records.push { fg, bg, flags, link, sgr: dress(fg, bg, flags), key } held.set key, records.length - 1 @@ -207,8 +206,8 @@ BORDERS =! { singleDouble: '╓─╖║╜─╙║', doubleSingle: '╒═╕│╛═╘│', classic: '+-+|+-+|', arrow: '↘↓↙←↖↑↗→' } -frames =! Map.new() -probe =! Flow.new() +outlines =! Map.new() +gauge =! Flow.new() # The eight cells of a border style: one of the names, or an object that # gives each part a glyph of its own, one cell wide. A node keeps a copy @@ -223,16 +222,16 @@ glyphsOf =! (style, key = 'borderStyle') -> unless custom or (typeof style is 'string' and BORDERS[style]) throw Error.new "rip/tui: #{key}: #{JSON.stringify style} is not one of #{Object.keys(BORDERS).join ', '}, or an object of #{PARTS.join ', '}" name = if custom then "#{style.topLeft}\n#{style.top}\n#{style.topRight}\n#{style.right}\n#{style.bottomRight}\n#{style.bottom}\n#{style.bottomLeft}\n#{style.left}" else style - cells = frames.get name + cells = outlines.get name return cells if cells cells = Uint32Array.new 8 for part, n in PARTS text = if custom then style[part] else String.fromCodePoint BORDERS[style].codePointAt(n) - probe.set (if typeof text is 'string' then text else ''), null - unless typeof text is 'string' and sanitize(text) is text and probe.count is 1 and probe.span(0) is 1 + gauge.set (if typeof text is 'string' then text else ''), null + unless typeof text is 'string' and sanitize(text) is text and gauge.count is 1 and gauge.span(0) is 1 throw Error.new "rip/tui: #{key}: #{part}: #{JSON.stringify text} is not a border glyph — write one printing character, one cell wide" - cells[n] = probe.code 0 - frames.set name, cells + cells[n] = gauge.code 0 + outlines.set name, cells cells # ── Values ──────────────────────────────────────────────────────────────────── @@ -446,8 +445,8 @@ export class Grid # One line of any text at (x, y). Answers the column after the last. write: (x, y, text, id) -> - probe.set text, null - if probe.ascii then @ascii(x, y, text, 0, text.length, id) else @units(x, y, probe, 0, probe.count, id) + gauge.set text, null + if gauge.ascii then @ascii(x, y, text, 0, text.length, id) else @units(x, y, gauge, 0, gauge.count, id) # A rectangle of spaces in one style: a background covers what lies # under it. A wide glyph the rectangle's side cuts is blanked. @@ -615,7 +614,7 @@ export class Damage up = node loop if s = up.styles - return if s.hidden or s.display is 'none' + return unless visible s if up isnt node and s.display isnt 'contents' dx -= Math.trunc s.contentOffsetX if s.contentOffsetX dy -= Math.trunc s.contentOffsetY if s.contentOffsetY @@ -695,7 +694,7 @@ survey! =! (node, dx, dy, damage) -> # between the top and bottom edges that show. Each edge takes its own # color, background and dimming, else the border's. A frame wholly # outside the clip, or too small to hold its edges, draws nothing. -frame! =! (grid, s, x, y, w, h) -> +border! =! (grid, s, x, y, w, h) -> cells = glyphsOf s.borderStyle top = if s.borderTop is false then 0 else 1 bottom = if s.borderBottom is false then 0 else 1 @@ -791,6 +790,28 @@ words! =! (grid, node, inherited, x, y) -> grid.put col, y + n, DOT, id stretch grid, flow, col + 1, y + n, table[at + B0], table[at + B1] +# The cells a box's border takes on one side: one where an edge is drawn. +export edge =! (s, side) -> if s.borderStyle? and s[side] isnt false then 1 else 0 + +# The clip a box hands what is under it — the clip it was handed, +# narrowed to its padding box on each axis it hides — answered in `clipped` +# (left, top, right, bottom), so no call makes an array. The one rule of +# where a box's children may show: the painter, the hit test (mouse.rip) +# and the cursor (screen.rip) all read it. +export clipped = Int32Array.new 4 +export clipOf! =! (s, x, y, w, h, left, top, right, bottom) -> + if (s.overflowX ?? s.overflow) is 'hidden' + left = Math.max left, x + edge(s, 'borderLeft') + right = Math.min right, x + w - edge(s, 'borderRight') + if (s.overflowY ?? s.overflow) is 'hidden' + top = Math.max top, y + edge(s, 'borderTop') + bottom = Math.min bottom, y + h - edge(s, 'borderBottom') + clipped[0] = left + clipped[1] = top + clipped[2] = right + clipped[3] = bottom + return + # One node and everything under it. (dx, dy) is how far content offsets # above have shifted it from where layout put it, and the four integers # are the clip: the padding boxes of the `overflow: 'hidden'` ancestors, @@ -811,19 +832,18 @@ draw! =! (grid, node, inherited, dx, dy, left, top, right, bottom) -> grid.clip left, top, right, bottom if s.backgroundColor? # The background fills inside the border (Ink's `renderBackground`). - l = if edged and s.borderLeft isnt false then 1 else 0 - t = if edged and s.borderTop isnt false then 1 else 0 - r = if edged and s.borderRight isnt false then 1 else 0 - b = if edged and s.borderBottom isnt false then 1 else 0 + l = edge s, 'borderLeft' + t = edge s, 'borderTop' + r = edge s, 'borderRight' + b = edge s, 'borderBottom' grid.fill x + l, y + t, rect.w - l - r, rect.h - t - b, intern(null, s.backgroundColor, 0) - frame grid, s, x, y, rect.w, rect.h if edged + border grid, s, x, y, rect.w, rect.h if edged if s.overflow? or s.overflowX? or s.overflowY? - if (s.overflowX ?? s.overflow) is 'hidden' - left = Math.max left, x + (if edged and s.borderLeft isnt false then 1 else 0) - right = Math.min right, x + rect.w - (if edged and s.borderRight isnt false then 1 else 0) - if (s.overflowY ?? s.overflow) is 'hidden' - top = Math.max top, y + (if edged and s.borderTop isnt false then 1 else 0) - bottom = Math.min bottom, y + rect.h - (if edged and s.borderBottom isnt false then 1 else 0) + clipOf s, x, y, rect.w, rect.h, left, top, right, bottom + left = clipped[0] + top = clipped[1] + right = clipped[2] + bottom = clipped[3] return if left >= right or top >= bottom dx -= Math.trunc s.contentOffsetX if s.contentOffsetX dy -= Math.trunc s.contentOffsetY if s.contentOffsetY @@ -846,7 +866,7 @@ lent =! (node, owner, id) -> styleOf node, lent(node.parentNode, owner, id) patch = null # the damage the paint under way is held to -PASSES =! 4 # the most paints one frame may take +PAINTS =! 4 # the most paints one frame may take # Paint the tree onto a grid. With no `damage` that is every node, onto # a grid the caller cleared. With it, `grid` is a scratch for the frame @@ -884,7 +904,7 @@ export paint! =! (grid, node, damage = null, front = null) -> draw grid, node, 0, 0, 0, 0, grid.top, grid.cols, grid.top + grid.rows damage.passes += 1 break unless grid.cut - throw Error.new "rip/tui: a paint did not settle in #{PASSES} passes — a wide glyph is cut by a row that is owed whole" if damage.passes >= PASSES + throw Error.new "rip/tui: a paint did not settle in #{PAINTS} passes — a wide glyph is cut by a row that is owed whole" if damage.passes >= PAINTS grid.clip 0, -FAR, grid.cols, FAR # ── Sweep ───────────────────────────────────────────────────────────────────── @@ -919,7 +939,7 @@ sweep! =! (grid) -> records.push record shelf(record.fg, record.bg).set record.key, n evict grid.ch - frames.clear() + outlines.clear() # ── Grid → bytes ────────────────────────────────────────────────────────────── diff --git a/packages/tui/screen.rip b/packages/tui/screen.rip index 4e8c2626..0c3161f1 100644 --- a/packages/tui/screen.rip +++ b/packages/tui/screen.rip @@ -11,13 +11,13 @@ # 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 { tend } from './focus.rip' +import { tend, visible } from './focus.rip' import { failed } from './document.rip' import { layout } from './layout.rip' -import { Grid, paint, diff, overlay, still, rowsToString } from './paint.rip' +import { Grid, paint, diff, overlay, still, rowsToString, clipped, clipOf } from './paint.rip' PACE =! 8 # the least milliseconds between two frames -PASSES =! 32 # the most layouts one frame may take to settle +LAYOUTS =! 32 # the most layouts one frame may take to settle # Whether every node above `node`, up to `body`, is shown. showing =! (node, body) -> @@ -25,38 +25,46 @@ showing =! (node, body) -> node = node.parentNode return false unless node return true if node is body - s = node.styles - return false if s.hidden is true or s.display is 'none' + return false unless visible node.styles # 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. -settle =! (doc, cols) -> +converge! =! (doc, cols) -> passes = 0 while doc.moved - if (passes += 1) > PASSES - throw Error.new "rip/tui: layout did not settle in #{PASSES} passes — a binding keeps changing the box it reads, so no size satisfies it" + if (passes += 1) > LAYOUTS + throw Error.new "rip/tui: layout did not settle in #{LAYOUTS} passes — a binding keeps changing the box it reads, so no size satisfies it" doc.moved = false layout doc.body, cols return export class Screen - constructor: (@doc, @out) -> + # `whole` owes every cell of every frame, for a test to compare + # against; `interactive` says frames go to a terminal someone is + # 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 } = {}) -> @front = null @spare = null @booked = false @last = 0 @failed = null - @whole = false # owe every cell of every frame, for a test to compare against + @whole = whole @owed = 0 # the cells the last frame owed @parked = null # where the cursor is shown, `{ x, y }` on the grid, or null while it is hidden - @origin = 0 # the terminal row the frame's top-left is on: what the cursor probe answers, and lower once a frame scrolls the terminal - @alt = false # the alternate screen: the frame's top-left is the screen's, nothing is written above, and `leave` moves nowhere - @interactive = true # whether frames go to a terminal someone is watching (terminal.rip); off one, frames are kept and only text is written + @origin = origin # what the cursor probe answers, and lower once a frame scrolls the terminal + @alt = alt + @interactive = interactive @selection = { from: -1, to: -1 } # the selected cells (mouse.rip), none while `from` is under zero @after = null # run after every frame: the pointer looks again at what is under it @statics = [] # the `Static` containers mounted (tui.rip), in the order they arrived @written = WeakSet.new() # the items written above the frame + @tail = WeakMap.new() # each container's last item written, where the next look starts @scrollback = '' # everything written above the frame so far, as it was written @lead = '' # what the next frame's write begins with: the rows written above the frame @osc = '' # the progress report the next frame's write carries @@ -106,20 +114,32 @@ export class Screen @batching = true try for el in @statics + # Items arrive at the end, so the look starts after the last one + # written; an item that went away sends it back to the front. + last = @tail.get el + kid = if last and last.parentNode is el then last.nextSibling else el.firstChild fresh = [] - for kid in el.childNodes when kid.nodeType is 1 - if @written.has kid then (kid.set 'display', 'none' if kid.styles.display isnt 'none') else fresh.push kid + while kid + fresh.push kid if kid.nodeType is 1 and not @written.has kid + kid = kid.nextSibling continue unless fresh.length and showing el, @doc.body grid = still el, @cols @above rowsToString(grid, @interactive) + '\n' if grid.rows - @written.add kid for kid in fresh + # Written, an item is put away at once and stays away whatever its + # own component writes (document.rip), so no later look revisits it. + for kid in fresh + @written.add kid + kid.still = true + kid.set 'display', 'none' + @tail.set el, fresh[fresh.length - 1] finally @batching = false # Progress on the terminal's own indicator (OSC 9;4, ConEmu's # sequence, which Windows Terminal, Ghostty, kitty and iTerm2 honor): # a number from 0 to 1, 'error', 'indeterminate', or null to clear, - # sent with the next frame's write. Nothing off a terminal. + # sent with the next frame's write, and only where the frames are + # watched (`interactive`), as a mount's are. progress!: (value) -> state = if value is null then '0' else if value is 'error' then '2' @@ -146,7 +166,7 @@ export class Screen @last = performance.now() failed @doc tend @doc - settle @doc, @cols + converge @doc, @cols @doc.stale = false tall = @doc.body.rect.h shown = Math.min tall, @rows @@ -192,20 +212,23 @@ export class Screen return null unless node?.cursor x = node.rect.x + node.cursor.x y = node.rect.y + node.cursor.y + # A place is a cell the node inks — its box, and words of its own + # that spill past it — as the hit test has it (mouse.rip); the cell + # after the last column is a place too, an insertion point. + return null unless x >= node.bl and x <= node.br and y >= node.bt and y < node.bb up = node while up = up.parentNode s = up.styles + # A `contents` node lends its children to its parent and is never + # drawn: its offsets and clip count for nothing, as the painter and + # the hit test (mouse.rip) have it. + continue if s.display is 'contents' x -= Math.trunc s.contentOffsetX if s.contentOffsetX y -= Math.trunc s.contentOffsetY if s.contentOffsetY continue unless s.overflow? or s.overflowX? or s.overflowY? rect = up.rect - edged = s.borderStyle? - if (s.overflowX ?? s.overflow) is 'hidden' - return null if x < rect.x + (if edged and s.borderLeft isnt false then 1 else 0) - return null if x >= rect.x + rect.w - (if edged and s.borderRight isnt false then 1 else 0) - if (s.overflowY ?? s.overflow) is 'hidden' - return null if y < rect.y + (if edged and s.borderTop isnt false then 1 else 0) - return null if y >= rect.y + rect.h - (if edged and s.borderBottom isnt false then 1 else 0) + clipOf s, rect.x, rect.y, rect.w, rect.h, -Infinity, -Infinity, Infinity, Infinity + return null if x < clipped[0] or x >= clipped[2] or y < clipped[1] or y >= clipped[3] y -= @front.top if x >= 0 and x < @front.cols and y >= 0 and y < @front.rows then { x, y } else null diff --git a/packages/tui/terminal.rip b/packages/tui/terminal.rip index e9f2a77c..4f07c061 100644 --- a/packages/tui/terminal.rip +++ b/packages/tui/terminal.rip @@ -59,11 +59,11 @@ export depth =! (out) -> # Whether the app is on a terminal someone is watching: what `run` # read of its stdout, on the screen (`Screen.interactive`). -watched =! (held) -> held.terminal and held.view.interactive +awake =! (held) -> held.terminal and held.view.interactive # A stream that keeps nothing: where frames go while the terminal is # another's, so the screen sees the size and writes nothing. -export class Quiet +class Quiet constructor: (@out) -> get columns: -> @out.columns get rows: -> @out.rows @@ -90,10 +90,10 @@ unguard! =! (held) -> # again where the keyboard was decided; the alternate screen entered; # the console captured. Nothing off a terminal, or under `mount`. export setup! =! (held) -> - return if held.up or not held.terminal + return if held.up held.up = true { out, stdin, view } = held - if watched held + if awake held out.write HIDE if stdin?.isTTY and typeof stdin.setRawMode is 'function' stdin.setRawMode true @@ -115,19 +115,24 @@ export setup! =! (held) -> # progress indicator cleared if one was reported; the alternate screen # left; the parser flushed; stdin cooked, paused and let go; the # console given back and the lines it kept replayed. On a stdout +# One step of a way out: the work is done whatever an earlier step +# raised, and the holder keeps the first error for the end. +export stepping =! (holder) -> + (work) -> + try + work() + catch caught + holder.error ?= caught + # nobody is watching, `drawn` writes the last frame once, as text. # Answers the first error a step raised, or null. down =! (held, drawn) -> return null unless held.up held.up = false - error = null - step = (work) -> - try - work() - catch caught - error ?= caught + caught = { error: null } + step = stepping caught { out, stdin, view } = held - if watched held + if awake held step -> held.pointer?.clear() if held.raw step -> out.write (if held.pushed then POP else '') + (if held.mouse then track(held.mouse, false) else '') + WITHDRAW @@ -148,7 +153,7 @@ down =! (held, drawn) -> step -> view.frame() if view.doc.stale step -> out.write rowsToString(view.front, held.depth > 0) + '\n' if view.front step -> release held - error + caught.error # The way out: the handlers off the process first, then the terminal # given back, and a continue nobody waits for any more forgotten. @@ -202,8 +207,8 @@ handlers =! (held) -> # ── The probes and their answers ────────────────────────────────────────────── # Ask the terminal where its cursor is, from the frame's top-left, where -# the frame sits: unknown until the answer, and taken to be the bottom -# meanwhile. +# the frame sits: unknown until the answer, and a mouse report that +# arrives before it is nobody's (mouse.rip). export ask! =! (held) -> held.asked = true held.view.origin = Infinity @@ -252,7 +257,7 @@ export reply =! (held, event) -> # change books one as ever, and the resume draws whole. pause! =! (held) -> held.suspended = true - held.view.out = Quiet.new held.out if watched held + held.view.out = Quiet.new held.out if awake held # Take the terminal again — unless the app was closed meanwhile — ask # the questions again, forget the press whose release was never seen @@ -279,7 +284,7 @@ resume! =! (held) -> # and nothing changes hands. Refused while one is under way. export handover =! (held, fn) -> throw Error.new 'rip/tui: the terminal is already suspended — resume before suspending again' if held.suspended - if watched(held) and (error = down held, true) + if awake(held) and (error = down held, true) setup held throw error pause held @@ -301,7 +306,7 @@ export handover =! (held, fn) -> # the app is closed before it wakes. Nothing on a stdout that is no # terminal, on Windows, or while a hand-over is under way. export stop! =! (held) -> - return unless watched(held) and process.platform isnt 'win32' and not held.suspended + return unless awake(held) and process.platform isnt 'win32' and not held.suspended down held, true pause held held.hold = setInterval (->), 0x7fffffff diff --git a/packages/tui/test.rip b/packages/tui/test.rip index 452f01f4..5d78cdc6 100644 --- a/packages/tui/test.rip +++ b/packages/tui/test.rip @@ -142,6 +142,33 @@ def mounted(App, body) console.log "\nPackage" +test "a Static container is looked at from its last written item: an item taken away sends the look back to the front, and nothing is written twice", -> + Log = component + @items := ['a', 'b'] + render + Box flexDirection: 'column' + Static + for item in @items + Text key: item, "done #{item}" + Text "live" + view = mount Log, cols: 20 + try + view.frame() + eq view.scrollback, "done a\ndone b\n" + view.app.items.value = ['a', 'b', 'c'] + view.frame() + eq view.scrollback, "done a\ndone b\ndone c\n" + view.app.items.value = ['a', 'b'] # the last written item goes away + view.frame() + view.app.items.value = ['a', 'b', 'd'] # the look starts over and finds only what is not written + view.frame() + eq view.scrollback, "done a\ndone b\ndone c\ndone d\n" + view.app.items.value = ['a', 'b', 'd'] + view.frame() + eq view.scrollback, "done a\ndone b\ndone c\ndone d\n", 'nothing is written twice' + finally + view.close() + test "module exports the entry surface and nothing else", -> eq Object.keys(mod).sort(), ['Box', 'Newline', 'Spacer', 'Static', 'Text', 'clock', 'focus', 'mount', 'print', 'quit', 'renderToString', 'run', 'screen', 'suspend'] @@ -154,6 +181,34 @@ test "declares no dependencies", -> console.log "\nDocument" +test "a cleared style keeps its slot as undefined, and a node whose every style is cleared is bare to layout and free to nest as text", -> + Bare = component + render + div + span "words" + view = mount Bare, cols: 20 + try + view.frame() + box = document.body.firstChild + box.set 'paddingLeft', 2 + view.frame() + eq box.styles.paddingLeft, 2 + box.set 'paddingLeft', undefined + view.frame() + ok Object.hasOwn(box.styles, 'paddingLeft'), 'the slot stays' + eq box.styles.paddingLeft, undefined + ok box.lay.bare, 'layout reads the node as bare again' + # A span whose layout style was cleared may be nested in text: the + # cleared key is no style. + inner = document.createElement 'span' + inner.set 'marginLeft', 1 + inner.set 'marginLeft', undefined + outer = box.firstChild + outer.appendChild inner + ok inner.parentNode is outer + finally + view.close() + test "install is scoped: the process gets its document slot back", -> eq typeof document, 'undefined' { doc, restore } = install() @@ -384,7 +439,9 @@ test "a style object taken away takes its keys with it, and leaves the props", - eq show(), "┌────┐\n│hi │\n└────┘" app.look.value = undefined eq show(), "hi" - eq document.body.firstChild.styles, { width: 6 } + styles = document.body.firstChild.styles + eq styles.width, 6 + eq [styles.borderStyle, styles.paddingLeft], [undefined, undefined] # ==[ Layout ]== @@ -2370,3 +2427,15 @@ test "progress is nothing off a terminal, and with no app mounted", -> eq view.held.view.clearProgress(), '' finally view.close() + +test "a closed mount takes no key and draws no frame: press, paste and frame refuse by name", -> + Quiet = component + render + Text "still" + view = mount Quiet, cols: 10 + view.frame() + view.close() + for drive in [(-> view.press 'a'), (-> view.paste 'x'), (-> view.send 'b'), (-> view.frame())] + refused = null + try drive() catch error then refused = error + ok refused?.message.includes('this mount is closed'), "refused: #{refused?.message}" diff --git a/packages/tui/test/events.rip b/packages/tui/test/events.rip index ee73c201..14bb215d 100644 --- a/packages/tui/test/events.rip +++ b/packages/tui/test/events.rip @@ -794,12 +794,33 @@ test "a cursor is refused unless it is `{ x, y }` in whole cells", -> mounted Trio, {}, (view) -> node = find 'a' throws (-> node.set 'cursor', { x: 1 }), 'cursor' - throws (-> node.set 'cursor', { x: -1, y: 0 }), 'cursor' throws (-> node.set 'cursor', { x: 0.5, y: 0 }), 'cursor' throws (-> node.set 'cursor', 'here'), 'cursor' node.set 'cursor', { x: 0, y: 0 } node.set 'cursor', undefined +test "a cursor outside its node's box is kept and hidden, as one a clip hides is: a stale place moves the cursor away, never the app", -> + Edge = component + render + Box flexDirection: 'row' + Box id: 'e', width: 5, focusable: true, autofocus: true + Text "child" + mounted Edge, {}, (view) -> + node = find 'e' + node.set 'cursor', { x: 0, y: 0 } + view.frame() + ok view.cursor?, 'a place inside the box is shown' + node.set 'cursor', { x: node.rect.w, y: 0 } + view.frame() + ok view.cursor?, 'the cell after the last column is a place: an insertion point' + for place in [{ x: -1, y: 0 }, { x: 0, y: -3 }, { x: node.rect.w + 1, y: 0 }, { x: 0, y: node.rect.h }] + node.set 'cursor', place + view.frame() + eq view.cursor, null, "#{JSON.stringify place} is hidden" + node.set 'cursor', { x: 0, y: 0 } + view.frame() + ok view.cursor?, 'and shown again when the place is back in the box' + test! "on quit the cursor returns to the line below the frame and is shown", -> Parked = component render @@ -1109,6 +1130,12 @@ holds =! (node) -> at = at.parentNode false +# Every element of the tree, in tree order. +every =! (node, out = []) -> + out.push node if node.nodeType is 1 + every kid, out for kid in node.childNodes + out + ordered =! (node, out = []) -> return out if blocked node out.push node if node.focusable @@ -1137,7 +1164,7 @@ test "random trees under random change: focus is always a node that can hold it, seed = (seed * 1664525 + 1013904223) % 4294967296 seed / 4294967296 pick = (list) -> list[Math.floor rand() * list.length] - counts = { steps: 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 } for round in [0...120] view = mount Bare, cols: 20 try @@ -1251,6 +1278,13 @@ test "random trees under random change: focus is always a node that can hold it, say "#{idOf now} has focus and cannot hold it" if now and not holds now say "the events say #{idOf holder} has focus, and the document says #{idOf now}" unless holder is now say "focus.active is #{idOf focus.active}, not #{idOf now}" unless focus.active is now + # Focus is recorded three times — the document's active node, its + # reactive `activeElement`, and each node's own `focused` — and + # every step must leave all three in step, for every node. + say "document.activeElement is #{idOf document.activeElement}, not #{idOf now}" unless document.activeElement is now + for node in every document.body when node.focused isnt (node is now) + say "#{idOf node}.focused reads #{node.focused} while #{idOf now} has focus" + counts.agreed += 1 counts.cleared += 1 if had and not now counts.claimed += 1 if now and not had and act < 0.45 # A cycle: Tab from here reaches every node that can hold focus @@ -1269,5 +1303,5 @@ test "random trees under random change: focus is always a node that can hold it, finally view.close() # A fuzz that does nothing passes everything: hold it to its volume. - floors = { steps: 9600, 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 } ok counts[name] >= floor, "#{name}: #{counts[name]} is under the floor of #{floor} — #{JSON.stringify counts}" for own name, floor of floors diff --git a/packages/tui/test/ink/harness.rip b/packages/tui/test/ink/harness.rip index fd58fb7e..41a07ccd 100644 --- a/packages/tui/test/ink/harness.rip +++ b/packages/tui/test/ink/harness.rip @@ -121,4 +121,4 @@ export differs =! (actual, ours, inks, why) -> throw Error.new "this frame equals Ink's — the stated difference is stale: #{why}" if got is JSON.stringify inks return -process.on 'exit', -> console.log "#{stated} frames differ from Ink's tests by stated decision\n" if stated +process.on 'exit', -> console.log "#{stated} cases answer other than Ink's tests expect, each by a stated difference or a refusal\n" if stated diff --git a/packages/tui/test/mouse.rip b/packages/tui/test/mouse.rip index f63b0bfb..209f59f9 100644 --- a/packages/tui/test/mouse.rip +++ b/packages/tui/test/mouse.rip @@ -1070,7 +1070,7 @@ test "random trees, random cells: the target is the node the painter put there, seed = (seed * 1664525 + 1013904223) % 4294967296 seed / 4294967296 pick = (list) -> list[Math.floor rand() * list.length] - counts = { rounds: 0, clicks: 0, spans: 0, divs: 0, bodies: 0, texts: 0, covered: 0, scrolled: 0, targets: 0 } + counts = { rounds: 0, clicks: 0, spans: 0, divs: 0, bodies: 0, texts: 0, covered: 0, scrolled: 0, targets: 0, cursors: 0 } for round in [0...300] view = mount Bare, cols: 30, mouse: true, selection: false try @@ -1141,11 +1141,24 @@ test "random trees, random cells: the target is the node the painter put there, counts.texts += 1 { dx, dy } = shift target say "(#{x}, #{y}) on #{idOf target} at (#{target.rect.x + dx}, #{target.rect.y + dy}) came as (#{got.x}, #{got.y})" unless got.x is x - target.rect.x - dx and got.y is y - target.rect.y - dy + # The cursor's place is the hit test's cell: a cursor declared at + # the cell's place in the target lands on the cell (screen.rip). A + # `contents` node has no box of its own, so no cursor: it is the + # target only as the parent of a bare text. + if target.nodeType is 1 and target isnt document.body and target.styles.display isnt 'contents' + target.focusable = true + target.set 'cursor', { x: got.x, y: got.y } + target.focus() + view.frame() + say "(#{x}, #{y}) on #{idOf target}: a cursor at its place in the node is placed at #{JSON.stringify view.cursor}" unless view.cursor?.x is x and view.cursor?.y is y + target.set 'cursor', undefined + target.blur() + counts.cursors += 1 counts.covered += 1 if nodes.some (node) -> node.nodeType is 1 and node isnt target and not under(target, node) and boxed node, x, y counts.scrolled += 1 if nodes.some (node) -> node isnt target and under(target, node) and node.styles?.overflow is 'hidden' and (node.styles.contentOffsetX or node.styles.contentOffsetY) counts.targets += seen.size finally view.close() # A fuzz that does nothing passes everything: hold it to its volume. - floors = { rounds: 250, clicks: 5000, spans: 450, divs: 3500, bodies: 900, texts: 60, covered: 1000, scrolled: 40, targets: 1000 } + floors = { rounds: 250, clicks: 5000, spans: 450, divs: 3500, bodies: 900, texts: 60, covered: 1000, scrolled: 40, targets: 1000, cursors: 3000 } ok counts[name] >= floor, "#{name}: #{counts[name]} is under the floor of #{floor} — #{JSON.stringify counts}" for own name, floor of floors diff --git a/packages/tui/text.rip b/packages/tui/text.rip index 54357aa7..7c592fdd 100644 --- a/packages/tui/text.rip +++ b/packages/tui/text.rip @@ -240,7 +240,7 @@ export A1 =! 1 export B0 =! 2 export B1 =! 3 export DOTS =! 4 -export WIDE =! 5 +WIDE =! 5 export ROW =! 6 ASKS =! 8 # the narrow offers a flow remembers its answers to diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index 43b308c0..898a7a24 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -6,11 +6,11 @@ import { __effect } from '../../src/runtime/reactive.js' import { install, failed, watch, Event } from './document.rip' import { tend, take, advance } from './focus.rip' -import { Parser } from './input.rip' +import { Parser, TIMERS } from './input.rip' import { Screen } from './screen.rip' import { Pointer } from './mouse.rip' import { palette } from './paint.rip' -import { guard, setup, probe, reply, teardown, handover, stop as halt, ask, interactive as watched, depth as depthOf } from './terminal.rip' +import { guard, setup, probe, reply, teardown, handover, stepping, stop as halt, ask, interactive as attended, depth as depthOf } from './terminal.rip' # ── Widgets ─────────────────────────────────────────────────────────────────── # A box is a div and text is a span. Every prop a widget does not @@ -67,9 +67,6 @@ print.err = (text) -> print text, live?.err ?? process.stderr # ── Animation ───────────────────────────────────────────────────────────────── -# The timers a clock runs on outside a test: the process's own, which -# never keep it alive by themselves. -TIMERS =! { now: (-> performance.now()), after: (ms, fn) -> (timer = setTimeout fn, ms; timer.unref?(); -> clearTimeout timer) } # A clock: `frame`, `time` and `delta` as reactive reads, moved by one # timer for its interval. The timer runs only while a component that @@ -273,14 +270,12 @@ open =! (App, options, out, terminal, stdin = null, clock = undefined) -> keys = options.keyboard ?? 'basic' throw Error.new "rip/tui: keyboard: #{JSON.stringify keys} is not 'basic' or 'enhanced'" unless keys is 'basic' or keys is 'enhanced' { doc, restore } = install() - view = Screen.new doc, out - view.whole = options.damage is false - view.interactive = not terminal or watched out - view.alt = terminal and view.interactive and options.altScreen is true + 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.origin = if terminal and not view.alt 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) } cols = view.cols rows = view.rows interactive = view.interactive @@ -289,7 +284,7 @@ open =! (App, options, out, terminal, stdin = null, clock = undefined) -> palette depth held = null # The next frame is drawn whole, at the size the terminal has now. - refresh = -> + redraw = -> cols = view.cols rows = view.rows held.pointer?.clear() @@ -298,13 +293,13 @@ open =! (App, options, out, terminal, stdin = null, clock = undefined) -> # A resize reflows the terminal, so where the frame sits is asked again. resized = -> return if view.cols is cols and view.rows is rows - refresh() + redraw() ask held if held.mouse and held.raw and not view.alt settled = {} done = Promise.new (resolve, reject) -> settled.resolve = resolve settled.reject = reject - live = held = { app: null, view, out, err: options.stderr ?? out.err ?? process.stderr, doc, restore, resized, redraw: refresh, done, settled, terminal, depth, console: options.console isnt false, closing: false, over: false, parser: null, stdin, read: null, clock, mouse, pointer: null, probing: keys is 'enhanced', asked: false, enhanced: false, pushed: false, raw: false, up: false, entered: false, suspended: false, wake: null, hold: null, handlers: null, was: null, logs: [], close: (drawn, error = null) -> close drawn, error if live is held } + live = held = { app: null, view, out, err: options.stderr ?? out.err ?? process.stderr, doc, restore, resized, redraw, done, settled, terminal, depth, console: options.console isnt false, closing: false, over: false, parser: null, stdin, read: null, clock, mouse, pointer: null, probing: keys is 'enhanced', asked: false, enhanced: false, pushed: false, raw: false, up: false, entered: false, suspended: false, wake: null, hold: null, handlers: null, was: null, logs: [], close: (drawn, error = null) -> close drawn, error if live is held } held.parser = Parser.new { clock, late: (events) -> receive held, events } held.read = (chunk) -> receive held, held.parser.feed chunk if mouse @@ -382,13 +377,16 @@ class Mount # is one paste; `send` is raw bytes through the parser, whose waits run # on a clock `tick` moves. press!: (key, mods = {}) -> + @driven() if key.charCodeAt(0) < 0x20 or key is '\x7f' @send key return receive @held, [{ type: 'key', key, ctrlKey: !!mods.ctrl, shiftKey: !!mods.shift, altKey: !!mods.alt, metaKey: !!mods.meta, repeat: !!mods.repeat, sequence: key }] type!: (text) -> @send text - paste!: (text) -> receive @held, [{ type: 'paste', text }] - send!: (bytes) -> receive @held, @held.parser.feed bytes + paste!: (text) -> @driven(); receive @held, [{ type: 'paste', text }] + send!: (bytes) -> @driven(); receive @held, @held.parser.feed bytes + # A closed mount takes no key, as it draws no frame. + driven!: -> throw Error.new 'rip/tui: this mount is closed — there is no app to drive' unless live is @held tick!: (ms) -> @held.clock.tick ms # Draw what the app shows and hand the frame back as plain text. @@ -421,7 +419,7 @@ export mount =! (App, options = {}) -> # 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) -> +close! =! (drawn, error = null, result = undefined) -> held = live { app, view, out, doc, restore, resized, pointer, settled } = held live = null @@ -429,24 +427,22 @@ close =! (drawn, error = null, result = undefined) -> keyboard = 'basic' doc.onOwe = null view.booked = false - step = (work) -> - try - work() - catch caught - error ?= caught + caught = { error } + step = stepping caught # A selection is the screen's, never the scrollback's. step -> pointer.clear() if pointer selected = '' if held.terminal step -> - caught = teardown held, drawn - error ?= caught + missed = teardown held, drawn + caught.error ?= missed step -> app?.unmount() + # The parser's wait is disarmed on every road, a mount's included. step -> held.parser.flush() step -> out.off? 'resize', resized step restore palette 3 - if error then settled.reject error else settled.resolve result + if caught.error then settled.reject caught.error else settled.resolve result # Hand the terminal to `fn` — an editor, a shell — and take it back # once `fn` settles, with a whole frame: the road Ctrl-Z takes, without @@ -459,7 +455,7 @@ export suspend =! (fn) -> # process its `document` slot back. The work waits for the turn to end, # so a `quit` called from inside an effect still draws what that same # change owed; an app closed by hand before then is left as it was. -export quit =! (result) -> +export quit! =! (result) -> return unless live and not live.closing held = live held.closing = true