diff --git a/bin/rip b/bin/rip index 88262d18..7a470b7a 100755 --- a/bin/rip +++ b/bin/rip @@ -8,6 +8,7 @@ import { readFileSync, writeFileSync, existsSync, statSync, rmSync, mkdirSync, readdirSync, realpathSync } from 'fs'; import { spawn, spawnSync } from 'child_process'; import { randomBytes } from 'crypto'; +import os from 'os'; import { join, dirname, resolve, relative, sep, delimiter } from 'path'; import { fileURLToPath } from 'url'; import packageJson from '../package.json' with { type: 'json' }; @@ -566,7 +567,7 @@ const run = (path, argv) => { watchdog.unref(); return new Promise(() => { child.once('error', () => process.exit(1)); - child.once('exit', (code) => process.exit(code ?? 1)); + child.once('exit', (code, signal) => process.exit(code ?? (signal ? 128 + os.constants.signals[signal] : 1))); }); }; diff --git a/packages/tui/PLAN.md b/packages/tui/PLAN.md index cadfbeac..c46aa7f4 100644 --- a/packages/tui/PLAN.md +++ b/packages/tui/PLAN.md @@ -131,17 +131,17 @@ write by hand; hot loops use the indexed `for x, i in` form). | Module | Job | Code lines | |---|---|---| -| `tui.rip` | Entry: `run`, `mount` and its input, `renderToString`, `screen`, `focus`, widgets; stdin, the modes, the probes, the delivery of events, the default actions; to come: `clock` | 249, about 285 when complete | +| `tui.rip` | Entry: `run`, `mount` and its input, `renderToString`, `suspend`, `screen`, `focus`, widgets, `print`, `clock`; the delivery of events, the default actions | 310 | | `document.rip` | Terminal document: nodes, tree links, the event and its dispatch, style road, keyboard traits, damage marks | 388 | | `focus.rip` | Who can hold focus, tree order, taking it, settling it | 62 | | `layout.rip` | Flexbox, containing blocks, baseline, cache, edge rounding | 1,466 | | `text.rip` | Sanitize, grapheme clusters, width, wrap, truncate | 421 | -| `paint.rip` | Cell grids, styles, clip, borders, backgrounds, the selection overlay, damage, diff | 650 | -| `screen.rip` | Frames, pacing, the cursor, where the frame sits; to come: alternate screen, `Static`, non-TTY | 105, about 300 | +| `paint.rip` | Cell grids, styles at the terminal's depth, clip, borders, backgrounds, the selection overlay, damage, diff, 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 | | `input.rip` | Key tokenizer and decoder, paste, mouse, replies | 315 | | `mouse.rip` | Hit test, the mouse events, hover, selection and the clipboard | 211 | -| `terminal.rip` | To come: setup / teardown, signals, suspend, console capture | about 170 | -| | **Total** | **3,868 built; about 4,280 complete** | +| `terminal.rip` | Setup / teardown: raw mode, the modes, the probes, the cursor, the alternate screen, the signals, suspend and resume, the console, the depth read | 181 | +| | **Total** | **4,222 built; about 4,250 complete** | Lines are counted as §2 counts them: non-blank and non-comment. Events, focus, the cursor and stdin are 285 of them (85 in `tui.rip`, 99 in @@ -552,8 +552,15 @@ a win: both disagree with some terminals and with tmux. **Inline rendering is its own design item.** Drawing below the prompt uses relative cursor moves and clips the live region to the terminal height, showing the bottom. `Static` paints an item once above the -live region, invalidates the front buffer, and detaches its nodes; on -the alternate screen it is a documented no-op and nothing accumulates. +live region: the items a container gained since the last frame are +laid out together as a root at the terminal's width, painted to a grid +of their own, and written through `Screen.above` — the frame's rows +cleared, the rows written where they were, the frame drawn again whole +below, in the frame's one write, the frame's origin moved down by the +rows written — and then hidden, so the live frame never holds them and +what happens to them later is nobody's. `print` takes the same road +with a line of text, and console capture (§8) will. On the alternate +screen `Static` is a documented no-op and nothing accumulates. **Resize** is coalesced to one frame: reallocate, then erase and paint (alternate screen) or move up by the estimated reflowed rows, erase @@ -561,8 +568,10 @@ down, and repaint (inline). **Non-TTY and CI** write static output immediately and the final frame once, with no cursor control, and honor `NO_COLOR` / `FORCE_COLOR` and color depth. -One serializer, `rowsToString`, serves `renderToString`, `Static`, and -the non-TTY final frame. +One serializer, `rowsToString` (`Grid.toString` under its own name, +paint.rip), serves `renderToString`, `Static`, and the non-TTY final +frame; with escape sequences it leaves a row's trailing default-style +blanks off, as the plain form does. ## 7. Input, focus, cursor (`input.rip`, `document.rip`) @@ -616,8 +625,7 @@ a dialog takes a key before the node under it does. In Ink every listener therefore hears Ctrl-C before it quits, where Ink exits before any handler runs. **Escape has none** — closing a dialog or clearing an input is the app's — where Ink takes focus away on every - Escape. Ctrl-Z (suspend) joins them with the lifecycle (§8): the seam - is `act` in `tui.rip`. + Escape. Ctrl-Z stops the process (§8). - **Each key is a turn of its own.** The keys of one read are dispatched one after another with no batch around them, so a key that opens a dialog is followed by a key that reaches the dialog. Once the @@ -701,7 +709,7 @@ in `test/input/`, 205 held to Ink's answer under one mapping to DOM names and 39 stated differences; `test/input/SOURCE.md` lists them and the 12 left out. -**Enhanced keyboard** (built: `tui.rip`) is opt-in (`run App, keyboard: +**Enhanced keyboard** (built: `terminal.rip`) is opt-in (`run App, keyboard: 'enhanced'`; the default is `'basic'`, and any other value is refused by name). Setup asks the terminal for the kitty protocol's disambiguation flag and teardown withdraws it; the decoder is always @@ -733,7 +741,7 @@ forwards xterm's modify-other-keys form (`CSI 27 ; mod ; code ~`), which the decoder reads; and the 500 ms `escape-time` that delays a lone Escape for every terminal program, which is the user's to lower. -**Mouse** (built: `mouse.rip`, the modes and the probe in `tui.rip`) is +**Mouse** (built: `mouse.rip`, the modes and the probe in `terminal.rip`) is opt-in (`run App, mouse: true`), because capture takes over the terminal's own text selection. `true` asks for button-event tracking (`CSI ? 1002 h`) and SGR reports (`CSI ? 1006 h`): presses, releases, @@ -755,9 +763,9 @@ a shell's prompt integration is not it. Until the answer, and on a terminal that never answers, the frame is taken to sit at the bottom. `mount` has no terminal: its frame is at row 0. A report names a terminal cell; the tree cell is `(x, y - origin + top)`, `top` being the -tree row the grid shows first. The lifecycle's resume (§8) asks again -the same way once the modes are sent again (`ask` in `tui.rip`), and -the alternate screen sets the origin to 0 with no probe. +tree row the grid shows first. A resume (§8) asks again the same way +once the modes are sent again (`ask` in `terminal.rip`), and the +alternate screen sets the origin to 0 with no probe. The target is found by a hit test that walks the tree as the painter does, backwards: the children of a box from last to first, then the box @@ -921,7 +929,7 @@ hardware cursor. A cursor belongs to a node, so in a frame taller than the terminal it stays with its row of the tree, where Ink counts `y` from the top of what is shown. -**stdin** (built: `tui.rip`; the lifecycle's in full with §8). `run +**stdin** (built: `terminal.rip`; every way out is §8's). `run App, stdin:` reads a stdin that is a terminal and can be set raw: raw mode, `ref`, `resume`, one `data` listener feeding the `Parser`, and bracketed paste (`CSI ? 2004 h`) and focus reports (`CSI ? 1004 h`) @@ -942,22 +950,120 @@ no keys from the terminal the process is on. ## 8. Lifecycle (`terminal.rip`) -One idempotent `setup()` / `teardown()` pair is shared by exit, -signals, crash, `suspend`, and Ctrl-Z / SIGCONT. What `tui.rip`'s -`listen` and `close` do for stdin and the two modes (§7) is the first -of it, and moves there. The host owns raw -mode and bracketed paste for the app's lifetime; there is no -ref-counting. - -Verified on Bun 1.4.2: an unhandled SIGTERM skips exit hooks, so -SIGINT / SIGTERM / SIGHUP get explicit handlers that restore and exit -with 128 + n; Bun restores termios on exit but not the cursor, -alternate screen, or paste mode; `console.log` bypasses -`process.stdout.write`, so the `console.*` methods are captured, not -the stream. Inline mode clears, writes the log line, and repaints; the -alternate screen buffers logs and replays them at exit. - -An uncaught error restores the terminal, prints the stack, and exits 1. +One idempotent `setup` / `teardown` pair is shared by `quit`, Ctrl-C, +a listener that throws, a frame that fails, SIGINT / SIGTERM / SIGHUP, +an uncaught error or an unhandled rejection, `process.exit`, a loop +that drains, Ctrl-Z / SIGCONT and `suspend`. `setup` hides the +cursor, sets stdin raw and reads it, asks for bracketed paste, focus +reports and the mouse modes, pushes the kitty flag again where the +keyboard was decided, enters the alternate screen, captures the +console, and puts the handlers on the process; `teardown` pops the +flag if it was pushed, withdraws the modes, draws what is owed and +leaves the cursor below the last frame — or, on a failure, shows it +where it is — leaves the alternate screen, flushes the parser, gives +stdin back cooked, paused and unref'd, takes the handlers off, and +gives the console back. Every step is taken whatever the others do, +each pair is taken once until the other, and a `setup` that fails +halfway is torn down by the steps that were taken. The host owns raw +mode and the modes for the app's life; there is no ref-counting. The +probes are asked once the app stands, so a constructor that throws +leaves no answer for the shell. + +**Signals.** An unhandled SIGTERM skips Bun's exit hooks, so SIGINT, +SIGTERM and SIGHUP have handlers that close the app — its last frame +drawn, `done` resolved — and exit with 128 + n; a `done` an app +awaits never settles after a signal exit, since the process is gone +before any continuation runs. A signal that arrives while the process +is stopped stays pending until it is continued, and then the continue +and the signal, in either order, end the same way, the app's exit +hook run. With raw mode on, Ctrl-C arrives as a key and its default +action is `quit`. An uncaught error or an unhandled rejection closes +the app with the error — `done` rejects — and leaves the error to the +listeners after this package's, which the runtime already called +once: its own, which prints it with its frames remapped +(`src/cli/run.js`) and exits 1, or the app's; with none, it is printed +and the exit is 1. `beforeExit` closes a live app as `quit` would, so +a script whose loop drains ends with its last frame in the scrollback; +`exit` tears down an app still live when `process.exit` is called +with it on the terminal. The seven handlers belong to the app's life, +never to the terminal's state: put on the process as `open` takes the +app, ahead of the runtime's own and any the app adds, and taken off +as the first step of `teardown`, whatever the terminal's state is +then — never under `mount`. + +**Suspend.** Ctrl-Z is a default action of `keydown`, preventable +like Ctrl-C: teardown with the cursor below the last frame, then +SIGTSTP to the whole process group — what the terminal's driver does +for a cooked Ctrl-Z, and under `rip app.rip` the group holds the +launcher too, so the shell sees one stopped job. The signal is sent +only when the app reads the process's own stdin: with any other +stream — a test's fake, a `stdout:` given with no stdin — Ctrl-Z +takes the same road and sends nothing, since a stream of one's own is +not the terminal's job, and whoever gave it continues the app with +SIGCONT to the process. While stopped, a timer is held so the loop +does not drain on the continue and close the app before it wakes. +SIGCONT sets up again, asks the questions again — the frame's row +with the mouse, the keyboard if it was still undecided — forgets the +press whose release was never seen (`Pointer.reset`) and the bytes +held mid-sequence (`parser.reset`), and draws whole at whatever size +the terminal has now; the keys left in the read with the Ctrl-Z are +nobody's. `suspend(fn)` +is the same road without the signal: teardown, `await fn()`, setup, +whole frame — for an editor or a shell that takes the terminal for a +while. Meanwhile a booked frame goes nowhere, a key is nobody's, a +resize writes nothing, and a log is the console's own. A nested +suspend is refused; a teardown that fails is set up again and thrown +before `fn` runs; a `quit` while suspended closes the app without +taking the terminal back. On a stdout nobody is watching, `fn` runs +and nothing changes hands. + +**Alternate screen.** `run App, altScreen: true` enters `CSI ? 1049 h` +after the modes and homes the cursor: the frame's top-left is the +screen's, the origin is 0 with no probe, a resize repaints whole with +no probe, and `Static` is a documented no-op. Every way out leaves it +with `CSI ? 1049 l` after the last frame is drawn, so the frame +vanishes with the screen and the shell's own comes back where it was. +A suspend leaves it and a resume enters it again. + +**Non-TTY and CI.** When stdout is no terminal, or `CI` is set to +anything but `''`, `'0'` or `'false'`, `screen.interactive` reads +false (`Screen.interactive`): no modes, no cursor bytes, no probe, no +raw mode even on a stdin that is a terminal; frames are laid out and +kept, a `Static` item and a `print` are written as they arrive, and +the last frame is written once at exit through `rowsToString` — with +its escapes when a depth is forced. `altScreen` and the console +capture are nothing there. + +**Colors.** The depth is read once at `run` and exposed as +`screen.colors` (0, 16, 256 or 16777216), in this order: `NO_COLOR` +set to anything but `''` is none (no-color.org); `FORCE_COLOR` `0` or +`false` is none, `''` or `true` the 16, a number that depth up to 3, +any other word the 16 (supports-color's reading); output nobody is +watching (no TTY, or CI) or `TERM=dumb` is none; `COLORTERM` +`truecolor` / `24bit` is 24-bit; `TERM` `*256color*` is 256; else 16. +The painter emits every style at that depth (`paint.rip`), from one +table of xterm's own 256 values: a 24-bit color becomes the nearest of +xterm's 256 by RGB distance (a color on the cube is sent as that +point), and below that the nearest of xterm's 16 — so a mid-gray is +bright black, never white; at none, no style sends anything. The cost +is paid per style interned, not per cell. `mount` and `renderToString` +stay at full depth. + +**Console.** `console.log` bypasses `process.stdout.write`, so while +an app runs on a terminal its writing methods — `log`, `table`, +`group`, `trace`, `assert`, `count`, `time` and the rest — are +replaced by a `node:console` `Console` whose two sinks relay, and +given back at teardown. A line takes the road a `Static` item takes +(`Screen.above`, §6): inline, the frame is cleared from its top-left, +the line written to the stream it always went to, and the frame drawn +again below it, all in the next frame's write, which the line books — +so logs scroll into the scrollback above the app, and the frame's row +moves down by the lines written, with no probe; a line for stderr has +its clear written at once and the frame follows. On the alternate +screen lines are kept and replayed once the screen is left; a log +while suspended, or with the app closed, is the console's own. `run +App, console: false` leaves the console alone. The progress indicator +(§9) is cleared on every way out, after the last frame. ## 9. Public surface @@ -980,24 +1086,34 @@ App = component run App ``` -- `run(App, {stdin, stdout, damage, mouse, keyboard, selection})` → - `{app, done, quit, flush}`. To come: `altScreen`; `suspend(fn)`; - `print(text)`. +- `run(App, {stdin, stdout, stderr, damage, mouse, keyboard, selection, + altScreen, console})` → `{app, done, quit, flush}`, and `suspend(fn)` + hands the terminal to `fn` and takes it back (§8). +- `print(text)` writes text above the live frame, its line ended, and + `print.err(text)` the same on stderr with the frame cleared on stdout + first; with no app mounted the text goes to the stream as it is. - `mount(App, {cols, rows, props, damage, mouse, keyboard, selection})` - → `{app, frame, ansi, bytes, damage, resize, close, done}`, and for - input `{press, type, paste, send, tick, focused, cursor}`, is the test - driver (§10), and `renderToString(App, {cols, rows, props, ansi})` is - a mount, one frame, and a close. + → `{app, frame, ansi, bytes, damage, scrollback, stderr, resize, + close, done}`, and for input `{press, type, paste, send, tick, + focused, cursor}`, is the test driver (§10), and + `renderToString(App, {cols, rows, props, ansi})` is a mount, one + frame, and a close, answering the rows `Static` wrote and then the + frame. - `screen` (`cols`, `rows`, `focused`, `keyboard`, and `selection`, the - selected text; to come: `interactive`) and `focus` (`active`, - `next()`, `previous()`, `to(node)`, with `to(null)` letting go) are - **getter-backed objects**. An imported - `:=` cell is not unwrapped across modules, so raw cells are never - exported. + selected text; `interactive` and `colors`, §8; `progress(value)`, the + terminal's own indicator through OSC 9;4 — 0 to 1, `'error'`, + `'indeterminate'`, null — sent with the next frame's write and + cleared on every way out) and `focus` (`active`, `next()`, + `previous()`, `to(node)`, with `to(null)` letting go) are + **getter-backed objects**. An imported `:=` cell is not unwrapped + across modules, so raw cells are never exported. - `clock(interval)` is the animation helper: a getter-backed object with `frame`, `time`, and `delta`, driven by one shared timer per - interval that runs only while a mounted component reads it and never - when output is not interactive. A spinner is + interval. A component holds the clock it makes in its body + (`tick = clock 80`), and the timer runs only while a holder is + mounted and has read it, never when output is not interactive, and + on the mount's own clock under `mount`, which `view.tick` moves along + with the parser's waits. A spinner is `frames[tick.frame % frames.length]`. The frame scheduler already coalesces every change into one paint, so the helper is a convenience, not a requirement. @@ -1081,6 +1197,24 @@ run App and the hit target must be the node the painter says owns the cell (a text where its words reach, painted after that owner), held to a floor of clicks, targets and covered cells. +- **Terminal:** `test/terminal.rip` — Ink's suspend, exit, error, + console and CI titles (`test/terminal/SOURCE.md` counts them); then + every way out — `quit`, Ctrl-C, a listener that throws, a suspend + then a quit, inline and on the alternate screen — held to one rule + for the modes, the keyboard stack, the cursor, stdin and the + process's handlers; SIGTERM, SIGHUP, SIGINT, an uncaught error, an + unhandled rejection, a `process.exit` and a loop that drains, each + in a spawned `rip` whose stdout claims to be a terminal, by exit code + and by its last bytes; `suspend`'s bytes and raw-mode calls in + order, Ctrl-Z through a SIGTSTP handler of the test's own and, for + real, a child that stops itself and is continued; the alternate + screen's entry, exit after the last frame, resize and mouse origin; + a stdout that is no terminal, and `CI`; the depth under every + environment and a 24-bit color at each; the console's bytes; and a + fuzz of random keys, resizes, logs, suspends and ways out on fake + streams, the terminal's modes held to what the app believes after + every step and every exit to everything off, held to floors of + steps, exits, suspends and logs. - **The test driver is public,** because users' tests are a contract too. `mount(App, {cols, rows, props})` is `run` without a terminal: the same install, the same `Screen`, the same close, drawing to a diff --git a/packages/tui/README.md b/packages/tui/README.md index 9c5c190d..a75f3335 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -47,6 +47,66 @@ run App value given to `quit`. Ctrl-C quits. The last frame stays in the scrollback and the cursor lands on the line below it. +## Running + +`run App, options` takes the terminal for the app's life and gives it +back on every way out, by one road: + +- **Exit.** `quit()`, Ctrl-C, a listener that throws, a frame that + fails, or a script whose loop drains: the last frame stays in the + scrollback, the cursor lands below it, and stdin, the terminal's + modes and the process's handlers are as they were. A failure leaves + what reached the screen, and `done` rejects with it. +- **Signals.** SIGINT, SIGTERM and SIGHUP give the terminal back and + exit with 128 plus the signal's number; a `done` an app awaits never + settles after a signal exit, since the process is gone before any + continuation runs. An uncaught error or an unhandled rejection gives + the terminal back, rejects `done` with the error, and leaves it to + the runtime — which prints it and exits 1 — or to the app's own + handler, once; a `process.exit` with the app live gives the terminal + back on the way. +- **Suspend.** Ctrl-Z gives the terminal back and stops the job as the + terminal would — the whole process group, so under `rip app.rip` the + shell sees one stopped job; `fg` draws the app again, whole, at the + terminal's size now, whether or not the app has a timer. It is a + default action of `keydown`, preventable like Ctrl-C. The stop + signal is sent only when the app reads the process's own stdin: with + any other stream — a test's, or a `stdout:` given with no stdin — + Ctrl-Z takes the same road and sends nothing, since a stream of one's + own is not the terminal's job, and whoever gave it continues the app + with `process.kill process.pid, 'SIGCONT'`. `suspend fn` is the same + road without the signal — the terminal is `fn`'s until it settles: + + ```coffee + import { suspend } from 'rip/tui' + suspend! -> Bun.spawn(['vim', path], stdio: ['inherit', 'inherit', 'inherit']).exited + ``` + + A key that arrives meanwhile is nobody's; a `quit` meanwhile closes + the app without taking the terminal back. +- **Alternate screen.** `run App, altScreen: true` draws on the + terminal's alternate screen from its top-left; every way out leaves + it after the last frame, so the frame vanishes and the shell's own + screen comes back where it was. `Static` is nothing there. +- **CI and pipes.** On a stdout that is no terminal, or with `CI` set, + nothing is asked of the terminal and the last frame alone is + written, as text, at exit; `screen.interactive` reads false. +- **Colors.** The depth is read once at `run` and `screen.colors` + reads it: 0, 16, 256 or 16777216. `NO_COLOR` set to anything but the + empty string is none; `FORCE_COLOR` `0` or `false` is none, empty or + `true` the 16, a number that depth up to 3, any other word the 16; + otherwise a pipe, CI or a dumb terminal is none, `COLORTERM` + `truecolor` 24-bit, `TERM` `256color` 256, and any other terminal + 16. A 24-bit color is drawn as the nearest of xterm's 256 — a color + on the cube as that point — and below that as the nearest of xterm's + 16. +- **Console.** While the app runs, every console method that writes + (`log`, `table`, `group`, `trace`, `assert`, `count`, `time*`, …) + clears the frame, writes where it always went, and draws the frame + again below, so logs scroll into the scrollback above the app; on the + alternate screen they are kept and replayed at exit. `run App, + console: false` leaves the console alone. + ## Widgets and styles A box is a `div` and text is a `span`. `Box`, `Text`, and `Spacer` are @@ -54,6 +114,8 @@ four-line components over them, and the raw tags are the zero-overhead spelling of the same nodes. Every prop a widget does not declare is a terminal style, forwarded to its node as written; bare text under a box is a text leaf, and text nested in text restyles its own words. +`Newline count: n` is `n` line breaks inside text, and `Static` is the +scrollback (below). | Moves boxes | Recolors cells | |---|---| @@ -227,6 +289,8 @@ view.send '\x1b[1;5A' # raw bytes, through the parser: keys, mouse reports view.tick 50 # move the parser's clock: a lone ESC is Escape after 50 ms view.focused # the node that has focus, or null view.cursor # where the last frame parked the cursor, { x, y }, or null while hidden +view.scrollback # what `Static` and `print` wrote above the frame so far, as it was written +view.stderr # what `print.err` wrote view.close() # unmount, and give the process its `document` slot back ``` @@ -250,7 +314,8 @@ mounted at a time: a second `mount`, `run`, or `renderToString` is refused by name until the first is closed — close in a `finally`. `renderToString App, cols: 40` is a mount, one frame, and a close; it -takes `props`, and `ansi: true` keeps the escape sequences. A child that +takes `props`, and `ansi: true` keeps the escape sequences. The rows +the app's `Static` items wrote come first, then the frame. A child that fails to construct — at the mount, from a key, or from a state set by the test — fails the mount, the key, or the next frame with the child's own error, and a `done` is settled by `close` as well as by `quit`. @@ -546,6 +611,69 @@ on: - A lone Escape arrives 50 ms after the key, since ESC also opens every sequence; under the enhanced keyboard it arrives at once. +## Static output + +A log of finished work belongs in the scrollback, not in the frame. +`Static` around a keyed `for` writes each item once, above the live +frame, when it first appears — and never paints it in the frame, so +the frame stays the size of what is live. + +```coffee +import { run, quit, print, Box, Text, Static } from 'rip/tui' + +Build = component + @done := [] # the steps finished so far + @step := 'compile' + render + Box flexDirection: 'column' + Static + for name in @done + Text key: name, color: 'green', "✓ #{name}" + Text "… #{@step}" + +build = run Build +build.app.done.value = ['resolve', 'fetch'] # two rows into the scrollback, the frame drawn again below +print 'warning: fetch took the slow road' # a line above the frame, the same way; print.err for stderr +``` + +An item is laid out at the terminal's width, with the items that arrive +in the same frame, in tree order; `Static`'s own props — `padding`, +`margin`, `backgroundColor` — go around each such batch. Once written, +an item is done: a change to its state or its removal from the list +changes nothing on the terminal. An item under a hidden ancestor waits +until it is shown. Off a terminal the rows go out as plain text as they +arrive; on the alternate screen nothing is written above. `examples/log.rip` +is a build log this way, with a spinner and a progress bar for the +step under way. + +## Animation + +`clock(interval)` is `{ frame, time, delta }` as reactive reads, moved +by one timer per interval: `frame` counts the intervals since the +timer started, `time` the milliseconds, `delta` the milliseconds since +the last tick. A spinner is `frames[tick.frame % frames.length]`. + +```coffee +Spinner = component + tick = clock 80 + render + Text color: 'cyan', "#{'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[tick.frame % 10]}" +``` + +The component that makes the clock in its body holds it, and every +component holding one interval shares its timer, which runs only while +one of them is mounted and has read it, and never off a terminal. Under +`mount` the clock runs on the mount's own time, so `view.tick 80` moves +the spinner a frame, as it moves the parser's waits. + +## Progress + +`screen.progress value` puts the app's progress on the terminal's own +indicator — the taskbar, the tab — through OSC 9;4, which Windows +Terminal, Ghostty, kitty and iTerm2 honor: a number from 0 to 1, +`'error'`, `'indeterminate'`, or `null` to clear. It goes out with the +next frame's write, and is cleared on every way out. + ## What is here, and what is planned [PLAN.md](PLAN.md) is the design and the order of work: the app @@ -572,7 +700,9 @@ cell rounding, `if` / `else` and keyed `for` on a terminal, nested text styles, hyperlinks byte for byte, `ref:` metrics, the grid diff replayed through a terminal, 70,000 colors and 300,000 clusters through the swept tables, a running app from first frame to `quit`, the `mount` -driver, and what each kind of change owes a frame, by its cells. +driver, what each kind of change owes a frame, by its cells, the bytes +of a `Static` write and of `print`, the mouse after one, the clock's +one timer and where it stops, and the progress sequence. `test/damage.rip` changes random trees a step at a time and holds every frame painted from its damage to the same tree painted whole, cell for cell, and to the bytes sent, replayed. `test/text.rip` holds the @@ -596,7 +726,15 @@ mode and under a frame taller than the terminal, the events and their road, the modes' bytes on every way out, the probe and both answers, the selection's cells, overlay, damage and clipboard bytes, and a fuzz of random trees and random cells where the hit target must be the node -the painter put there. `test/yoga.rip` runs Yoga's +the painter put there. `test/ink/static.rip` holds Ink's `Static` cases +and its `useStdout` / `useStderr` cases through `print` +(`test/ink/SOURCE.md`). `test/terminal.rip` holds every way out to one +rule — Ink's suspend, exit, error, console and CI cases +(`test/terminal/SOURCE.md`), the signals and the crash in a spawned +process, Ctrl-Z and `suspend` byte for byte, the alternate screen, a +stdout that is no terminal, the color depth, the console, and a fuzz +of keys, resizes, logs and suspends that holds the terminal's modes +to what the app believes after every step. `test/yoga.rip` runs Yoga's 543 generated layout cases, vendored unmodified under `test/yoga/` (MIT, © Meta Platforms), against the engine through a shim of the `yoga-layout` API. `test/yoga-aspect.rip` is a port of Yoga's 37 diff --git a/packages/tui/TODO.md b/packages/tui/TODO.md index 3b2111be..073cd887 100644 --- a/packages/tui/TODO.md +++ b/packages/tui/TODO.md @@ -38,6 +38,15 @@ steps are in [PLAN.md](PLAN.md). over the 300 updates of `bun run tui`, and about 170 µs over 12,000: the run ends while the damage path is still being compiled. Warm the bench longer, or make the path smaller. +- [ ] A `Static` batch lays its container out as a root, and the + body's next layout puts the container away again by visiting + every item under it: a walk as long as the list, per batch. The + 1,000-append row of the bench (PLAN §11) is unmeasured. +- [ ] `Static`'s items are elements; a bare text under `Static` is + never hidden and is painted again with every batch. +- [ ] `print` and a `Static` item write nothing on the alternate + screen, where Ink keeps them for the way out; console capture + (PLAN §8) decides what a run there keeps. ## 6. Input and focus @@ -53,13 +62,6 @@ steps are in [PLAN.md](PLAN.md). - [ ] A select list that marks its choice with one binding an item (`inverse: n is at`) pays for every item on every arrow key: about 55 µs at a hundred items where ten cost 8 (`bun run keys`). -- [ ] `run`'s stdin and mode handling — raw mode, bracketed paste, - focus reports, the mouse modes, the cursor probe, the kitty - query and its pushed flag — is `listen`, `answer` and `close` in - `tui.rip`; it moves into `terminal.rip`'s setup and teardown with - the lifecycle (PLAN §8), where Ctrl-Z and the signals need it: - a suspend must pop the flag and withdraw the mouse, and a resume - ask for both again. - [ ] After a resize the frame's row is asked of the terminal again from what was the top-left; a terminal whose reflow moves the cursor off that row places clicks wrongly until the next resize. @@ -70,7 +72,15 @@ steps are in [PLAN.md](PLAN.md). - [ ] `mouseenter` and `mouseleave` carry the terminal's cell and the other target, not `x`, `y` from the corner of each node they reach. -## 7. Compiler-side, filed separately +## 7. The terminal + +- [ ] A log while the console is captured is written to the run's + stdout, as Ink writes it, even when that is a stream of the + caller's own: a test whose app is still live when it fails hands + its own report to that stream. `test/terminal.rip` quits after + every test for that; the other suites do not. + +## 8. Compiler-side, filed separately - A typed vocabulary for non-HTML hosts, so `rip check` and the editor accept terminal props (PLAN §14). diff --git a/packages/tui/document.rip b/packages/tui/document.rip index 601b0ef5..1e7c44e5 100644 --- a/packages/tui/document.rip +++ b/packages/tui/document.rip @@ -82,7 +82,7 @@ unknown =! (key, tag) -> # A reactive cell, minted on the first read of what it holds — a node's # box, whether it has focus — so a node nobody asks carries none. -watch =! (first) -> +export watch =! (first) -> held := first { read: (-> held), write: ((next) -> held = next) } diff --git a/packages/tui/examples/log.rip b/packages/tui/examples/log.rip new file mode 100644 index 00000000..2968bbeb --- /dev/null +++ b/packages/tui/examples/log.rip @@ -0,0 +1,39 @@ +# A build log: each finished step scrolls into the scrollback above a +# live line that spins and fills a bar for the step under way, a +# warning is printed above the frame on the way, the terminal's own +# progress indicator follows, and the app quits when the last step is +# done — or on q. +# +# rip examples/log.rip + +import { run, quit, screen, print, clock, Box, Text, Static } from 'rip/tui' + +STEPS =! ['resolve', 'fetch', 'compile', 'link', 'bundle', 'sign'] +SPIN =! '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' +PACE =! 700 # milliseconds a step takes + +export Log = component + tick = clock 80 + step ~= Math.min STEPS.length, Math.floor(tick.time / PACE) + done ~= STEPS.slice 0, step + bar ~= '█'.repeat(step * 2) + '░'.repeat((STEPS.length - step) * 2) + + ~> print "warning: #{STEPS[2]} took the slow road" if step is 3 + ~> screen.progress (if step < STEPS.length then step / STEPS.length else null) + ~> quit() if step is STEPS.length + + render + Box flexDirection: 'column', focusable: true, autofocus: true, @keydown: ((event) -> quit() if event.key is 'q') + Static + for name in done + Text key: name, color: 'green' + "✓ #{name}" + if step < STEPS.length + Box flexDirection: 'row', gap: 1 + Text color: 'cyan', "#{SPIN[tick.frame % SPIN.length]}" + Text "#{STEPS[step]}" + Text dimColor: true, "[#{bar}] #{step}/#{STEPS.length}" + else + Text color: 'green', bold: true, "all done" + +run Log if import.meta.main diff --git a/packages/tui/package.json b/packages/tui/package.json index 41fa56e5..4ddc0dca 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -8,7 +8,7 @@ ".": "./tui.rip" }, "scripts": { - "test": "rip test.rip && rip test/text.rip && rip test/layout.rip && rip test/input.rip && rip test/events.rip && rip test/mouse.rip && rip test/ink.rip && rip test/yoga.rip && rip test/yoga-aspect.rip && rip test/yoga-hand.rip && rip test/fuzz.rip && rip test/damage.rip", + "test": "rip test.rip && rip test/text.rip && rip test/layout.rip && rip test/input.rip && rip test/events.rip && rip test/mouse.rip && rip test/ink.rip && rip test/yoga.rip && rip test/yoga-aspect.rip && rip test/yoga-hand.rip && rip test/fuzz.rip && rip test/damage.rip && rip test/terminal.rip", "demo": "rip demo.rip" }, "files": [ @@ -19,6 +19,7 @@ "text.rip", "paint.rip", "screen.rip", + "terminal.rip", "input.rip", "README.md", "NOTICE" diff --git a/packages/tui/paint.rip b/packages/tui/paint.rip index 21847a97..4803311a 100644 --- a/packages/tui/paint.rip +++ b/packages/tui/paint.rip @@ -10,7 +10,7 @@ # A frame paints and compares only its damage: the cells some change may # have recolored, a span of columns on each row. -import { kids } from './layout.rip' +import { kids, layout } from './layout.rip' import { CLUSTER, glyph, crowded, evict, sanitize, Flow, refresh, MODES, A0, A1, B0, B1, DOTS, ROW } from './text.rip' # ── Styles ──────────────────────────────────────────────────────────────────── @@ -44,6 +44,70 @@ tint =! (color, base) -> return (if m[1] > 255 then null else "#{base + 8};5;#{Number m[1]}") null +# The depth colors are sent at, 0 to 3 — none, the 16 named, the 256, +# 24-bit — what `run` reads of the terminal once (terminal.rip). Below +# full depth a 24-bit color is sent as the nearest of xterm's 256 by +# RGB distance — a color on the cube is that point — and below that as +# the nearest of xterm's 16, one of the 256 the same way. The table is +# xterm's own: its 16 defaults, the 6×6×6 cube at 0, 95, 135, 175, 215 +# and 255, and the 24 grays from 8 by tens. The cost is paid once per +# style interned, never per cell. At none, no style sends anything. +depth = 3 + +XTERM =! do -> + table = for hex in ['000000', 'cd0000', '00cd00', 'cdcd00', '0000ee', 'cd00cd', '00cdcd', 'e5e5e5', '7f7f7f', 'ff0000', '00ff00', 'ffff00', '5c5cff', 'ff00ff', '00ffff', 'ffffff'] + n = parseInt hex, 16 + [n >> 16, (n >> 8) & 255, n & 255] + levels = [0, 95, 135, 175, 215, 255] + for r in levels + for g in levels + table.push [r, g, b] for b in levels + table.push [8 + 10 * i, 8 + 10 * i, 8 + 10 * i] for i in [0...24] + table + +# The entry of the table from `from` to `to` nearest (r, g, b). +nearest =! (r, g, b, from, to) -> + best = from + least = Infinity + for i in [from...to] + [tr, tg, tb] = XTERM[i] + far = (r - tr) * (r - tr) + (g - tg) * (g - tg) + (b - tb) * (b - tb) + if far < least + least = far + best = i + best + +# The SGR offset of one of the 16: 0 to 7, or 60 up for the bright. +named =! (n) -> if n < 8 then n else n + 52 + +# A color's parameters at the depth in force. +lower =! (sgr, base) -> + return sgr if depth is 3 + if m = /^\d8;2;(\d+);(\d+);(\d+)$/.exec sgr + return "#{base + 8};5;#{nearest +m[1], +m[2], +m[3], 16, 256}" if depth is 2 + return String base + named nearest(+m[1], +m[2], +m[3], 0, 16) + return sgr if depth is 2 + if m = /^\d8;5;(\d+)$/.exec sgr + [r, g, b] = XTERM[+m[1]] + return String base + named nearest(r, g, b, 0, 16) + sgr + +# A style's escape parameters: its switches, then its colors. +dress =! (fg, bg, flags) -> + return '' unless depth + parts = [] + for code, n in CODES + parts.push code if flags & (1 << n) + for [color, base] in [[fg, 30], [bg, 40]] when color? + parts.push lower tint(color, base), base + parts.join ';' + +# Set the depth: every style interned so far is dressed again. +export palette! =! (n) -> + return if n is depth + depth = n + record.sgr = dress record.fg, record.bg, record.flags for record in records + LIMIT =! 65536 # style ids a cell's sixteen bits can hold SWEEP =! 32768 # styles held before those no cell uses are let go @@ -76,15 +140,10 @@ export intern =! (fg, bg, flags, link = null) -> key = if link then "#{flags}|#{link}" else flags id = held.get key return id if id? - parts = [] - for code, n in CODES - parts.push code if flags & (1 << n) for [color, base] in [[fg, 30], [bg, 40]] when color? - sgr = tint color, base - throw Error.new "rip/tui: #{JSON.stringify color} is not a color — write #{COLORS}" unless sgr - parts.push sgr + throw Error.new "rip/tui: #{JSON.stringify color} is not a color — write #{COLORS}" unless tint color, base throw Error.new "rip/tui: more than #{LIMIT} distinct styles are in use — a cell holds a style id in sixteen bits" if records.length >= LIMIT - records.push { fg, bg, flags, link, sgr: parts.join(';'), key } + records.push { fg, bg, flags, link, sgr: dress(fg, bg, flags), key } held.set key, records.length - 1 records.length - 1 @@ -408,16 +467,20 @@ export class Grid @wide.fill 1, at + left, at + right # The grid as text: one string per row, trailing blanks trimmed. - # With `ansi`, each run of a style carries its escape sequence, and a - # link is closed on the row it opens on. + # With `ansi`, each run of a style carries its escape sequence, a + # link is closed on the row it opens on, and the blanks left off a + # row's end are those of the default style. toString: (ansi = false) -> lines = [] for row in [0...@rows] line = '' held = 0 url = null - for col in [0...@cols] - at = row * @cols + col + from = row * @cols + stop = @cols + stop -= 1 while ansi and stop > 0 and @ch[from + stop - 1] is SPACE and @style[from + stop - 1] is 0 and @wide[from + stop - 1] is 1 + for col in [0...stop] + at = from + col continue if @wide[at] is 0 if ansi and @style[at] isnt held line += shift held, @style[at] @@ -431,6 +494,23 @@ export class Grid lines.push (if ansi then line else line.trimEnd()) lines.join '\n' +# The one serializer of a painted grid: `renderToString`, a `Static` +# item and the frame written off a terminal (terminal.rip) all read +# their rows through it. +export rowsToString =! (grid, ansi = false) -> grid.toString ansi + +# Paint `node` alone, as a root laid out at `cols`, onto a grid of its +# own, as tall as the node and the margins above and below it: the rows +# a `Static` batch writes to the scrollback (screen.rip). +export still =! (node, cols) -> + layout node, cols + rect = node.rect + s = node.styles + below = s.marginBottom ?? s.marginY ?? s.margin + grid = Grid.new cols, rect.y + rect.h + (if typeof below is 'number' and below > 0 then Math.round below else 0) + paint grid, node + grid + # The selection (mouse.rip): the cells `from` to `to` in reading order, # each counted as row × columns + column of tree cells, shown inverse. # Only the cells the paint owed are flipped: the rest show as they diff --git a/packages/tui/screen.rip b/packages/tui/screen.rip index 00fc94d8..4e8c2626 100644 --- a/packages/tui/screen.rip +++ b/packages/tui/screen.rip @@ -14,11 +14,20 @@ import { tend } from './focus.rip' import { failed } from './document.rip' import { layout } from './layout.rip' -import { Grid, paint, diff, overlay } from './paint.rip' +import { Grid, paint, diff, overlay, still, rowsToString } from './paint.rip' PACE =! 8 # the least milliseconds between two frames PASSES =! 32 # the most layouts one frame may take to settle +# Whether every node above `node`, up to `body`, is shown. +showing =! (node, body) -> + loop + node = node.parentNode + return false unless node + return true if node is body + s = node.styles + return false if s.hidden is true or s.display is 'none' + # Lay the document out until a pass moves nothing. A binding that reads # a node's box changes the tree it measured, so one change can take a # pass per reader in a chain; readers that never agree are refused. @@ -42,14 +51,97 @@ export class Screen @owed = 0 # the cells the last frame owed @parked = null # where the cursor is shown, `{ x, y }` on the grid, or null while it is hidden @origin = 0 # the terminal row the frame's top-left is on: what the cursor probe answers, and lower once a frame scrolls the terminal + @alt = false # the alternate screen: the frame's top-left is the screen's, nothing is written above, and `leave` moves nowhere + @interactive = true # whether frames go to a terminal someone is watching (terminal.rip); off one, frames are kept and only text is written @selection = { from: -1, to: -1 } # the selected cells (mouse.rip), none while `from` is under zero @after = null # run after every frame: the pointer looks again at what is under it + @statics = [] # the `Static` containers mounted (tui.rip), in the order they arrived + @written = WeakSet.new() # the items written above the frame + @scrollback = '' # everything written above the frame so far, as it was written + @lead = '' # what the next frame's write begins with: the rows written above the frame + @osc = '' # the progress report the next frame's write carries + @reported = false # whether a progress is on the terminal's indicator + @batching = false # whether `still` is writing what the containers hold get cols: -> @out.columns ?? 80 get rows: -> @out.rows ?? 24 + # Write `text` above the live frame — a `Static` batch, a `print` line, + # a console line (terminal.rip) — in the frame's own write: the + # frame's rows are cleared, the text is written where they were, and + # the frame is drawn again below it, whole, all in the next frame's + # write, which the text books. With `err`, the clear goes out at once + # and the text to `err`, and the frame follows on the next write. The + # frame's row moves down by the lines written. Off a terminal the + # text goes as it is; on the alternate screen nothing is written + # above. + above!: (text, err = null) -> + return unless text + @still() unless @batching # what `Static` holds goes first + @scrollback += text + unless @interactive + (err ?? @out).write text + return + return if @alt + clear = @unpark() + "\x1b[J" + if err + @out.write clear + err.write text + else + @lead += clear + text + @spare = @front if @front + @front = null + @origin += text.split('\n').length - 1 + @doc.owe false + + # Write what the `Static` containers hold that was not written yet: + # each container's new items are laid out together, as a root at the + # terminal's width, painted once and written above the frame. Every + # item written before is hidden first — again, if its own component + # showed it — so no batch holds one twice and a later change to it is + # nobody's; the container itself is hidden, so the live frame never + # holds any of them. A container under a hidden ancestor waits. + still!: -> + return if @alt or @batching + @batching = true + try + for el in @statics + fresh = [] + for kid in el.childNodes when kid.nodeType is 1 + if @written.has kid then (kid.set 'display', 'none' if kid.styles.display isnt 'none') else fresh.push kid + continue unless fresh.length and showing el, @doc.body + grid = still el, @cols + @above rowsToString(grid, @interactive) + '\n' if grid.rows + @written.add kid for kid in fresh + finally + @batching = false + + # Progress on the terminal's own indicator (OSC 9;4, ConEmu's + # sequence, which Windows Terminal, Ghostty, kitty and iTerm2 honor): + # a number from 0 to 1, 'error', 'indeterminate', or null to clear, + # sent with the next frame's write. Nothing off a terminal. + progress!: (value) -> + state = if value is null then '0' + else if value is 'error' then '2' + else if value is 'indeterminate' then '3' + else if typeof value is 'number' and value >= 0 and value <= 1 then "1;#{Math.round value * 100}" + else throw Error.new "rip/tui: progress: #{JSON.stringify value} is not a number from 0 to 1, 'error', 'indeterminate' or null" + return if not @interactive or (state is '0' and not @reported) + @reported = state isnt '0' + @osc = "\x1b]9;4;#{state}\x1b\\" + @doc.owe false + + # The bytes that clear the indicator, once a progress was reported: + # for every way out (tui.rip's close). + clearProgress: -> + return '' unless @reported + @reported = false + @osc = '' + "\x1b]9;4;0\x1b\\" + # Draw what is owed, now. frame!: -> + @still() @booked = false @last = performance.now() failed @doc @@ -78,14 +170,17 @@ export class Screen @front = back if damage.whole damage.shape @front, false @after?() + return unless @interactive spot = @place() - return unless bytes or spot?.x isnt @parked?.x or spot?.y isnt @parked?.y - bytes = @unpark() + bytes + return unless bytes or @lead or @osc or spot?.x isnt @parked?.x or spot?.y isnt @parked?.y + bytes = @lead + @unpark() + bytes + @lead = '' @parked = spot if spot bytes += "\x1b[#{spot.y}B" if spot.y bytes += "\x1b[#{spot.x + 1}G\x1b[?25h" - @out.write "\x1b[?2026h#{bytes}\x1b[?2026l" + @out.write "\x1b[?2026h#{bytes}#{@osc}\x1b[?2026l" + @osc = '' # The cell the focused node's cursor lands on, on the grid the terminal # shows, or null: its rounded corner plus what it declares, moved by @@ -132,8 +227,10 @@ export class Screen if wait > 0 then setTimeout (=> @drawBooked()), wait else queueMicrotask (=> @drawBooked()) # A booked frame has no caller to throw to, so its failure goes to - # whoever runs the app. + # whoever runs the app. One drawn by hand meanwhile, or a close, has + # taken the booking. drawBooked!: -> + return unless @booked try @frame() catch error @@ -146,4 +243,4 @@ export class Screen try @frame() if @doc.stale finally - @out.write @unpark() + '\n'.repeat(@front?.rows ?? 0) + "\x1b[?25h" + @out.write @unpark() + '\n'.repeat(if @alt then 0 else @front?.rows ?? 0) + "\x1b[?25h" diff --git a/packages/tui/terminal.rip b/packages/tui/terminal.rip new file mode 100644 index 00000000..e9f2a77c --- /dev/null +++ b/packages/tui/terminal.rip @@ -0,0 +1,351 @@ +# The terminal, for the app's life: what `run` asks of it and gives +# back — raw mode, the modes, the probes, the cursor, the alternate +# screen, the signals, the console — as one `setup` / `teardown` pair +# that every way out shares: `quit`, Ctrl-C, a listener that throws, a +# signal, a crash, `process.exit`, a loop that drains, Ctrl-Z and +# `suspend`. Each takes every step whatever the others do, and each is +# taken once until the other. The host owns the terminal for the app's +# life: there is no share of raw mode to count (PLAN §8). + +import { format } from 'node:util' +import { Console } from 'node:console' +import { Writable } from 'node:stream' +import { rowsToString } from './paint.rip' + +HIDE =! "\x1b[?25l" +SHOW =! "\x1b[?25h" +ASK =! "\x1b[?2004h\x1b[?1004h" # bracketed paste, focus reports (xterm ctlseqs) +WITHDRAW =! "\x1b[?1004l\x1b[?2004l" +PROBE =! "\x1b[?6n" # DECXCPR: which row the frame's top-left is on +QUERY =! "\x1b[?u\x1b[c" # kitty's "is the protocol here", then primary device attributes +PUSH =! "\x1b[>1u" # kitty: push the disambiguation flag +POP =! "\x1b[ + moves = if mouse is 'all' then 1003 else 1002 + if set then "\x1b[?#{moves}h\x1b[?1006h" else "\x1b[?1006l\x1b[?#{moves}l" + +# Whether frames go to a terminal someone is watching: a stdout that is +# one, outside CI (`CI` set to anything but '', '0' or 'false'). +export interactive =! (out) -> + ci = process.env.CI + out?.isTTY is true and not (ci? and ci isnt '' and ci isnt '0' and ci isnt 'false') + +# The depth `out` is drawn at, 0 to 3 — no color, the 16 named, the +# 256, 24-bit — read once at `run`, in this order: NO_COLOR set to +# anything but '' is none (no-color.org); FORCE_COLOR '0' or 'false' +# is none, '' or 'true' the 16, a number that depth up to 3, any other +# word the 16 (supports-color's reading); a stdout nobody is watching, +# or a dumb terminal, has none; COLORTERM's truecolor is 24-bit; +# TERM's 256color is 256; any other terminal has 16. +export depth =! (out) -> + env = process.env + return 0 if env.NO_COLOR + if env.FORCE_COLOR? + forced = env.FORCE_COLOR + return 0 if forced is '0' or forced is 'false' + return Math.min(+forced, 3) if /^\d+$/.test forced + return 1 + return 0 unless interactive(out) and env.TERM isnt 'dumb' + return 3 if /truecolor|24bit/i.test env.COLORTERM ?? '' + return 2 if /256color/i.test env.TERM ?? '' + 1 + +# Whether the app is on a terminal someone is watching: what `run` +# read of its stdout, on the screen (`Screen.interactive`). +watched =! (held) -> held.terminal and held.view.interactive + +# A stream that keeps nothing: where frames go while the terminal is +# another's, so the screen sees the size and writes nothing. +export class Quiet + constructor: (@out) -> + get columns: -> @out.columns + get rows: -> @out.rows + write: -> true + +# ── Setup and teardown ──────────────────────────────────────────────────────── + +# The process's handlers — the signals, the crash, the exit — belong to +# the app's life under `run`, never to the terminal's state: put on as +# `open` takes the app, ahead of the runtime's own and any the app +# adds, and taken off as the first step of `teardown`, whatever the +# terminal's state is then. +export guard! =! (held) -> + held.handlers = handlers held + process.prependListener name, fn for name, fn of held.handlers + +unguard! =! (held) -> + return unless held.handlers + process.off name, fn for name, fn of held.handlers + held.handlers = null + +# Take the terminal: the cursor hidden; stdin raw and read, with the +# paste and focus modes and the mouse's asked for, and the flag pushed +# again where the keyboard was decided; the alternate screen entered; +# the console captured. Nothing off a terminal, or under `mount`. +export setup! =! (held) -> + return if held.up or not held.terminal + held.up = true + { out, stdin, view } = held + if watched held + out.write HIDE + if stdin?.isTTY and typeof stdin.setRawMode is 'function' + stdin.setRawMode true + held.raw = true + stdin.ref?() + stdin.resume() + stdin.on 'data', held.read + held.pushed = held.enhanced + out.write ASK + (if held.mouse then track(held.mouse, true) else '') + (if held.pushed then PUSH else '') + if view.alt + out.write ENTER + held.entered = true + capture held if held.console + +# Give the terminal back, every step taken whatever the others do: the +# flag popped if it was pushed; the mouse, paste and focus modes +# withdrawn; the last frame drawn and left in the scrollback with the +# cursor shown below it (`drawn`), or the cursor shown where it is; the +# 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 +# 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 + { out, stdin, view } = held + if watched held + step -> held.pointer?.clear() + if held.raw + step -> out.write (if held.pushed then POP else '') + (if held.mouse then track(held.mouse, false) else '') + WITHDRAW + held.pushed = false + step -> if drawn then view.leave() else out.write view.unpark() + SHOW + step -> out.write osc if osc = view.clearProgress() + if held.entered + held.entered = false + step -> out.write LEAVE + step -> held.parser.flush() + if held.raw + held.raw = false + step -> stdin.off 'data', held.read + step -> stdin.setRawMode false + step -> stdin.pause() + step -> stdin.unref?() + else if drawn + step -> view.frame() if view.doc.stale + step -> out.write rowsToString(view.front, held.depth > 0) + '\n' if view.front + step -> release held + error + +# The way out: the handlers off the process first, then the terminal +# given back, and a continue nobody waits for any more forgotten. +export teardown =! (held, drawn) -> + unguard held + error = down held, drawn + process.off 'SIGCONT', held.wake if held.wake + held.wake = null + clearInterval held.hold if held.hold + held.hold = null + error + +# ── Signals, the crash, the exit ────────────────────────────────────────────── + +# A signal closes the app — its last frame drawn, the terminal given +# back, `done` resolved — and exits with 128 plus its number, so a +# `done` an app awaits never settles: the process is gone before any +# continuation runs. A signal that arrives while the process is +# stopped stays pending until it is continued (POSIX; a shell sends +# the continue itself), and then the continue and the signal, in +# either order, end the same way, the app's own exit hook run. An +# uncaught error or an unhandled rejection closes the app with the +# error — `done` rejects, and nobody need be awaiting it — then leaves +# the error to the listeners after this one, which the runtime already +# called once: its own, which prints the error with its frames +# remapped and exits 1, or the app's; with none, it is printed here +# and the exit is 1. The loop draining closes the app as `quit` would; +# a `process.exit` with the app live gives the terminal back on the +# way. Every step is taken whatever the others do: none of these +# throws. +handlers =! (held) -> + gone = (code) -> + held.close true + process.exit code + crash = (name, error) -> + held.done.catch -> null + held.close true, error + return if process.listenerCount name + console.error error + process.exit 1 + { + SIGINT: -> gone 130 + SIGTERM: -> gone 143 + SIGHUP: -> gone 129 + uncaughtException: (error) -> crash 'uncaughtException', error + unhandledRejection: (error) -> crash 'unhandledRejection', error + beforeExit: -> held.close true + exit: -> held.close true + } + +# ── The probes and their answers ────────────────────────────────────────────── + +# Ask the terminal where its cursor is, from the frame's top-left, where +# the frame sits: unknown until the answer, and taken to be the bottom +# meanwhile. +export ask! =! (held) -> + held.asked = true + held.view.origin = Infinity + held.out.write held.view.unpark() + PROBE + +# The questions whose answers arrive as input, asked once the app +# stands — a constructor that throws leaves no answer for the shell — +# and again on every resume: with the mouse, where the cursor is, on +# the primary screen; with the enhanced keyboard, whether the terminal +# speaks kitty's protocol, and its attributes, which every terminal +# answers, until one of the two has decided it. +export probe! =! (held) -> + return unless held.raw + ask held if held.mouse and not held.view.alt + held.out.write QUERY if held.probing + +# The terminal's answers. The cursor's row — an answer to the package's +# own probe, marked as DECXCPR marks it, and only while one is +# outstanding — is where the frame's top-left was when it was asked, and +# lower if a frame has scrolled the terminal since. The keyboard is +# decided by whichever of the two queries is answered first: kitty's own +# reply says the protocol is there, and the flag is pushed; the +# attributes reply first says it is not — tmux answers only that one — +# and nothing is pushed, whatever comes later. True when the keyboard +# was just decided enhanced. +export reply =! (held, event) -> + switch event.kind + when 'cursor' + return false unless event.marked and held.asked + held.asked = false + held.view.origin = Math.min held.view.origin, event.y + when 'keyboard', 'attributes' + return false unless held.probing + held.probing = false + return false unless event.kind is 'keyboard' + held.enhanced = true + if held.raw + held.out.write PUSH + held.pushed = true + return true + false + +# ── Suspend and resume ──────────────────────────────────────────────────────── + +# While the terminal is another's, frames go nowhere: a resize or a +# change books one as ever, and the resume draws whole. +pause! =! (held) -> + held.suspended = true + held.view.out = Quiet.new held.out if watched held + +# Take the terminal again — unless the app was closed meanwhile — ask +# the questions again, forget the press whose release was never seen +# and the bytes held mid-sequence, and draw whole at whatever size the +# terminal has now. +resume! =! (held) -> + held.suspended = false + process.off 'SIGCONT', held.wake if held.wake + held.wake = null + clearInterval held.hold if held.hold + held.hold = null + return if held.over + held.view.out = held.out + setup held + probe held + held.pointer?.reset() + held.parser.reset() + held.redraw() + +# Hand the terminal to `fn` and take it back once `fn` settles: the +# road Ctrl-Z takes, without the signal. A step that fails on the way +# out is thrown before `fn` runs, with the terminal taken again so a +# later hand-over can try. On a stdout that is no terminal `fn` runs +# and nothing changes hands. Refused while one is under way. +export handover =! (held, fn) -> + throw Error.new 'rip/tui: the terminal is already suspended — resume before suspending again' if held.suspended + if watched(held) and (error = down held, true) + setup held + throw error + pause held + try + fn! + finally + resume held + +# Ctrl-Z: give the terminal back, stop the process as the terminal's +# driver would have — the stop signal to the whole process group, which +# under `rip app.rip` holds the launcher too, so the shell sees one +# stopped job — and take the terminal again on SIGCONT. The signal is +# sent only when the app reads the process's own stdin: a stream of +# one's own — a test's, a `stdout:` given with no stdin — is not the +# terminal's job, so Ctrl-Z takes the same road and sends nothing, and +# whoever gave the stream continues the app with SIGCONT. A stopped app +# has no timer and no stdin to keep the loop alive, so one is held +# until the continue: without it the loop drains on the continue and +# the app is closed before it wakes. Nothing on a stdout that is no +# terminal, on Windows, or while a hand-over is under way. +export stop! =! (held) -> + return unless watched(held) and process.platform isnt 'win32' and not held.suspended + down held, true + pause held + held.hold = setInterval (->), 0x7fffffff + held.wake = -> resume held + process.once 'SIGCONT', held.wake + process.kill 0, 'SIGTSTP' if held.stdin is process.stdin + +# ── The console ─────────────────────────────────────────────────────────────── + +# A stream whose every write is a line above the frame, to `stream`. +sink =! (held, stream) -> + Writable.new write: (chunk, encoding, next) -> + relay held, stream, String chunk + next() + +# The console's methods bypass `process.stdout.write`, so every one +# that writes — `log`, `table`, `group`, `trace`, `assert`, `count`, +# `time` and the rest — is replaced for the run by a `Console` of the +# runtime's own over two sinks, and given back at teardown, when a +# suspend hands the terminal over as well. A line goes to the stream it +# always went to, by the road a `Static` item takes (`Screen.above`): +# inline, the frame is cleared first and drawn again below the line, +# so it scrolls into the scrollback above the app; on the alternate +# screen it is kept, and replayed once the screen is left. The +# runtime's `trace` drops its label, so it is written here as Node +# writes it: the label, then the stack from the caller down. +capture! =! (held) -> + shadow = Console.new stdout: sink(held, held.out), stderr: sink(held, held.err), colorMode: false + shadow.trace = (...args) -> relay held, held.err, "Trace: #{format ...args}\n#{Error.new().stack.split('\n').slice(2).join '\n'}\n" + held.was = {} + for name in Object.getOwnPropertyNames(shadow) when typeof shadow[name] is 'function' and typeof console[name] is 'function' + held.was[name] = console[name] + console[name] = shadow[name] + +release! =! (held) -> + return unless held.was + console[name] = fn for name, fn of held.was + held.was = null + stream.write text for [stream, text] in held.logs + held.logs = [] + +relay! =! (held, stream, text) -> + view = held.view + if view.alt + held.logs.push [stream, text] + return + view.above text, (if stream is held.out then null else stream) diff --git a/packages/tui/test.rip b/packages/tui/test.rip index c94b822e..452f01f4 100644 --- a/packages/tui/test.rip +++ b/packages/tui/test.rip @@ -3,13 +3,14 @@ # ============================================================================== import { test, eq, ok, throws } from 'rip/testing' -import { run, quit, mount, renderToString, screen, Box, Text, Spacer } from 'rip/tui' +import { run, quit, mount, renderToString, screen, print, clock, Box, Text, Spacer, Static, Newline } from 'rip/tui' import * as mod from 'rip/tui' import { install } from './document.rip' import { layout } from './layout.rip' -import { Grid, paint, diff, tally as styleCount } from './paint.rip' +import { Grid, paint, diff, rowsToString, tally as styleCount } from './paint.rip' import { tally as clusterCount, clusterWidth } from './text.rip' import { __setChildFailureReporter } from '../../src/runtime/components.js' +import { Log as BuildLog } from './examples/log.rip' import { EventEmitter } from 'node:events' import { readFileSync } from 'fs' import { join } from 'path' @@ -17,6 +18,7 @@ import { join } from 'path' # A terminal that remembers what it was sent. def terminal(columns = 40, rows = 10) out = EventEmitter.new() + out.isTTY = true out.columns = columns out.rows = rows out.sent = [] @@ -121,6 +123,9 @@ def words(doc, styles, text, inner = null) tick = -> Promise.new (resolve) -> setTimeout resolve, 20 +# Text with its escape sequences taken out. +bare = (text) -> text.replace /\x1b\[[0-9;]*m/g, '' + # Mount `App`, hand `body(app, show, doc, view)` the instance, a way to # draw it at a width, its document, and the mount, and always close it. def mounted(App, body) @@ -138,7 +143,7 @@ def mounted(App, body) console.log "\nPackage" test "module exports the entry surface and nothing else", -> - eq Object.keys(mod).sort(), ['Box', 'Spacer', 'Text', 'focus', 'mount', 'quit', 'renderToString', 'run', 'screen'] + eq Object.keys(mod).sort(), ['Box', 'Newline', 'Spacer', 'Static', 'Text', 'clock', 'focus', 'mount', 'print', 'quit', 'renderToString', 'run', 'screen', 'suspend'] test "declares no dependencies", -> pkg = JSON.parse readFileSync(join(import.meta.dir, 'package.json'), 'utf8') @@ -595,7 +600,7 @@ test "a wide character cut by a clip edge is a space in its style", -> Text backgroundColor: 'blue' "日本語" eq renderToString(Cut, cols: 6), "日" - eq renderToString(Cut, cols: 6, ansi: true), "\x1b[44m日 \x1b[0m " + eq renderToString(Cut, cols: 6, ansi: true), "\x1b[44m日 \x1b[0m" test "content offsets shift a box's children, and owe a paint but no layout", -> Scrolled = component @@ -672,7 +677,7 @@ test "text's own background fills its glyph cells and no more", -> Box width: 8 Text backgroundColor: 'yellow' "ab" - eq renderToString(Marked, cols: 8, ansi: true), "\x1b[43mab\x1b[0m " + eq renderToString(Marked, cols: 8, ansi: true), "\x1b[43mab\x1b[0m" test "'default' is the terminal's own color: it stands against an ancestor's color and the background beneath", -> Bare = component @@ -710,7 +715,7 @@ test "a link wraps its words in OSC 8, nested text inherits it, and a plain fram "docs" Text link: 'https://example.com/other' "!" - eq renderToString(Linked, cols: 16, ansi: true), "see #{OPEN 'https://example.com/a?b=1;c'}the \x1b[1mdocs\x1b[0m#{OPEN 'https://example.com/other'}!#{SHUT} " + eq renderToString(Linked, cols: 16, ansi: true), "see #{OPEN 'https://example.com/a?b=1;c'}the \x1b[1mdocs\x1b[0m#{OPEN 'https://example.com/other'}!#{SHUT}" eq renderToString(Linked, cols: 16), "see the docs!" # Over a box's background the words keep their link and the box does not take it. Panel = component @@ -732,7 +737,7 @@ test "a link that wraps is closed on every row and opened again on the next, its "abcdefgh" eq renderToString(Long, cols: 5, ansi: true).split('\n'), [ "#{OPEN 'https://example.com'}ab cd#{SHUT}" - "\x1b[31m#{OPEN 'https://example.com'} ef\x1b[0m#{SHUT} " + "\x1b[31m#{OPEN 'https://example.com'} ef#{SHUT}\x1b[0m" "#{OPEN 'https://example.com'}abcd…#{SHUT}" ] @@ -852,7 +857,7 @@ test "a border that straddles a clip edge is cut there, column by column", -> Box width: 4, height: 3, marginLeft: 3, borderStyle: 'single', flexShrink: 0 Text "ab" rows = renderToString(Straddle, cols: 10, ansi: true).split '\n' - eq rows, [" ┌── ", " │ab ", " └── "] + eq rows, [" ┌──", " │ab", " └──"] Under = component render Box width: 6, height: 2, overflowY: 'hidden' @@ -1936,3 +1941,432 @@ test "a sweep as a paint starts owes every cell", -> wholly view finally view.close() + +# ==[ Static, print ]== + +console.log "\nScrollback" + +# A log of finished steps above a live line. +Journal =! component + @done := [] + @live := 'live' + render + Box flexDirection: 'column' + Static + for step in @done + Text key: step, step + Text "#{@live}" + +test "a static item is written in the frame's write: the frame's rows cleared, the item's rows, the frame drawn again below", -> + view = mount Journal, cols: 20, rows: 10 + try + eq view.frame(), 'live' + eq view.bytes, "\x1b[?2026h\x1b[J\x1b[1Glive\r\x1b[?2026l" + view.app.done.value = ['step one'] + eq view.frame(), 'live', 'the live frame does not grow' + eq view.bytes, "\x1b[?2026h\x1b[Jstep one\n\x1b[J\x1b[1Glive\r\x1b[?2026l" + eq view.scrollback, "step one\n" + view.app.done.value = ['step one', 'two', 'three'] + view.frame() + eq view.bytes, "\x1b[?2026h\x1b[Jtwo\nthree\n\x1b[J\x1b[1Glive\r\x1b[?2026l", 'two new items are written in order, in one write' + eq view.scrollback, "step one\ntwo\nthree\n" + eq replay([view.bytes], 20, 10), "two\nthree\nlive" + finally + view.close() + +test "an item is painted once, at the terminal's width: a change to it or its removal writes nothing, and the frame stays", -> + Steps = component + @done := ['a'] + @tint := undefined + @shape := undefined + render + Box flexDirection: 'column' + Static + for step in @done + Text key: step, color: @tint, display: @shape, wrap: 'truncate' + "#{step} #{'x'.repeat 30}" + Text "live" + view = mount Steps, cols: 20 + try + eq view.frame(), 'live' + eq view.scrollback, "a xxxxxxxxxxxxxxxxx…\n" + view.app.tint.value = 'red' + view.frame() + eq view.bytes, '', 'a written item that changes writes nothing' + eq view.scrollback, "a xxxxxxxxxxxxxxxxx…\n" + view.app.shape.value = 'flex' + view.frame() + eq view.scrollback, "a xxxxxxxxxxxxxxxxx…\n", 'an item shown again by its own component is still written once' + view.app.done.value = ['a', 'b'] + view.frame() + eq view.scrollback, "a xxxxxxxxxxxxxxxxx…\n\x1b[31mb xxxxxxxxxxxxxxxxx…\x1b[0m\n", 'the new item is written in its color, the old one not again' + view.app.done.value = ['b'] + view.frame() + eq view.bytes, '', 'a removal writes nothing' + eq view.damage, 0 + finally + view.close() + +test "the mouse lands right after a static write: the frame's origin moves down by the rows written", -> + hits = [] + Rows = component + @done := [] + render + Box flexDirection: 'column' + Static + for step in @done + Text key: step, step + for n in [0, 1] + Text key: n, @click: (-> hits.push n) + "row #{n}" + view = mount Rows, cols: 20, rows: 10, mouse: true + try + view.frame() + view.send "\x1b[<0;1;2M\x1b[<0;1;2m" + eq hits, [1] + view.app.done.value = ['one', 'two'] + view.frame() + view.send "\x1b[<0;1;4M\x1b[<0;1;4m" + eq hits, [1, 1], 'the frame sits two rows lower' + view.send "\x1b[<0;1;2M\x1b[<0;1;2m" + eq hits, [1, 1], 'a click on a written row hits nothing' + finally + view.close() + +test "under the alternate screen a static item writes nothing and paints nothing", -> + view = mount Journal, cols: 20 + try + view.held.view.alt = true + view.app.done.value = ['x'] + eq view.frame(), 'live' + ok not view.bytes.includes('x'), JSON.stringify view.bytes + eq view.scrollback, '' + finally + view.close() + +test "off a terminal, items are written as plain text as they arrive", -> + Tinted = component + @done := [] + render + Box flexDirection: 'column' + Static + for step in @done + Text key: step, color: 'red', step + Text "live" + view = mount Tinted, cols: 20 + try + view.held.view.interactive = false + view.app.done.value = ['x'] + view.frame() + ok view.bytes.startsWith("x\n"), JSON.stringify view.bytes + eq view.scrollback, "x\n" + finally + view.close() + +test "an item under a hidden ancestor waits, and is written once shown", -> + Folded = component + @open := false + render + Box flexDirection: 'column' + Box display: (if @open then undefined else 'none') + Static + for step in ['x'] + Text key: step, step + Text "live" + view = mount Folded, cols: 20 + try + view.frame() + eq view.scrollback, '' + view.app.open.value = true + view.frame() + eq view.scrollback, "x\n" + view.app.open.value = false + view.app.open.value = true + view.frame() + eq view.scrollback, "x\n" + finally + view.close() + +test "renderToString is the rows written above and then the frame, plain or with escape sequences", -> + eq renderToString(Journal, cols: 20, props: { done: ['a', 'b'] }), "a\nb\nlive" + eq renderToString(Journal, cols: 20, props: { done: ['a', 'b'], live: '' }), "a\nb", 'an empty text is no row' + Only = component + render + Static + for step in ['a', 'b'] + Text key: step, color: 'red', step + eq renderToString(Only, cols: 20), "a\nb" + eq renderToString(Only, cols: 20, ansi: true), "\x1b[31ma\x1b[0m\n\x1b[31mb\x1b[0m" + +test "print writes above the frame, ending its line; print.err the same on stderr, the frame cleared on stdout first", -> + view = mount Journal, cols: 20 + try + view.frame() + print 'warning' + view.frame() + eq view.bytes, "\x1b[?2026h\x1b[Jwarning\n\x1b[J\x1b[1Glive\r\x1b[?2026l" + print 'two\nlines\n' + view.frame() + eq view.bytes, "\x1b[?2026h\x1b[Jtwo\nlines\n\x1b[J\x1b[1Glive\r\x1b[?2026l" + print.err 'oops' + ok view.bytes.endsWith("\x1b[J"), 'the frame is cleared at once' + eq view.stderr, "oops\n" + view.frame() + eq view.bytes, "\x1b[?2026h\x1b[J\x1b[1Glive\r\x1b[?2026l", 'the frame is drawn again below' + view.app.done.value = ['step'] + print 'after the step' + view.frame() + eq view.scrollback, "warning\ntwo\nlines\noops\nstep\nafter the step\n", 'an item in the tree when print is called goes first' + finally + view.close() + +test "rowsToString is the grid's serializer, plain and with escape sequences", -> + view = mount Journal, cols: 20, props: { live: 'here' } + try + view.frame() + front = view.held.view.front + eq rowsToString(front), 'here' + eq rowsToString(front, true), front.toString true + finally + view.close() + +test "a Static under an if that swaps is built once for the new branch, its items written once, and the branch let go leaves no container behind", -> + Swap = component + @which := 'a' + render + Box flexDirection: 'column' + if @which is 'a' + Static + for name in ['one', 'two'] + Text key: name, "a #{name}" + else + Static + for name in ['three'] + Text key: name, "b #{name}" + Text "live #{@which}" + view = mount Swap, cols: 20 + try + eq view.frame(), 'live a' + eq [view.scrollback, view.held.view.statics.length], ["a one\na two\n", 1] + view.app.which.value = 'b' + eq view.frame(), 'live b' + eq [view.scrollback, view.held.view.statics.length], ["a one\na two\nb three\n", 1], 'the new container stands alone' + view.app.which.value = 'a' + eq view.frame(), 'live a' + eq [view.scrollback, view.held.view.statics.length], ["a one\na two\nb three\na one\na two\n", 1], 'a branch built again is new: its items are written again, once' + finally + view.close() + +test! "examples/log.rip runs headless: the steps scroll into the scrollback as the clock moves, the spinner turns, the warning is printed, and it quits when done or on q", -> + view = mount BuildLog, cols: 60, rows: 10 + try + first = view.frame() + ok first.startsWith('⠋ resolve'), JSON.stringify first + view.tick 80 + ok view.frame().startsWith('⠙ resolve'), 'the spinner turned' + view.tick 700 * 3 + later = view.frame() + ok later.startsWith('⠧ link'), JSON.stringify later + # The warning's effect runs before the render block adds the step it + # is about, so it stands above that step. + eq bare(view.scrollback), "✓ resolve\n✓ fetch\nwarning: compile took the slow road\n✓ compile\n" + ok view.bytes.includes("\x1b]9;4;1;50\x1b\\"), 'the terminal knows how far along' + view.tick 700 * 3 + eq await view.done, undefined + eq typeof document, 'undefined', 'the app quit when the last step was done' + finally + view.close() + view = mount BuildLog, cols: 60 + view.frame() + view.press 'q' + await view.done + eq typeof document, 'undefined' + +test "Newline breaks a line inside text, `count` times", -> + One = component + render + Text + "Hello" + Newline + "World" + eq renderToString(One, cols: 20), "Hello\nWorld" + Two = component + render + Text + "Hello" + Newline count: 2 + "World" + eq renderToString(Two, cols: 20), "Hello\n\nWorld" + +# ==[ The clock ]== + +console.log "\nClock" + +# A timer source a test drives by hand and counts: `tick` jumps its +# time and runs what fell due, in order. +def timers() + fake = { time: 0, due: [], afters: 0, cancels: 0 } + fake.now = -> fake.time + fake.after = (ms, fn) -> + fake.afters += 1 + entry = { at: fake.time + ms, fn } + fake.due.push entry + -> + fake.cancels += 1 + fake.due = fake.due.filter (other) -> other isnt entry + fake.tick = (ms) -> + fake.time += ms + ready = fake.due.filter((entry) -> entry.at <= fake.time).sort (a, b) -> a.at - b.at + fake.due = fake.due.filter (entry) -> entry.at > fake.time + entry.fn() for entry in ready + fake + +test "a clock a component holds but never reads costs no timer", -> + fake = timers() + Holder = component + tick = clock 30, fake + render + Text "still" + view = mount Holder, cols: 20 + try + eq view.frame(), 'still' + eq fake.afters, 0 + finally + view.close() + eq fake.cancels, 0 + +test "two components reading one interval share one timer, which runs while a reader remains and stops at close", -> + fake = timers() + Spin = component + tick = clock 40, fake + render + Text "#{tick.frame}" + Pair = component + @second := true + render + Box flexDirection: 'row' + Spin + if @second + Spin + view = mount Pair, cols: 20 + try + eq view.frame(), '00' + eq fake.afters, 1, 'one timer for two readers' + fake.tick 80 + eq view.frame(), '22', 'the frame is the time elapsed over the interval, however late the tick' + eq fake.afters, 2, 'the one timer is set again after the tick' + view.app.second.value = false + eq view.frame(), '2' + eq fake.cancels, 0, 'the timer stays while a reader remains' + fake.tick 40 + eq view.frame(), '3' + finally + view.close() + eq fake.cancels, 1, 'the timer stops at close' + eq fake.due, [], 'nothing is left to run' + +test "`tick` moves the mount's clock: frame counts intervals, time the milliseconds, delta the time since the last tick", -> + Watch = component + tick = clock 50 + render + Text "#{tick.frame},#{tick.time},#{tick.delta}" + view = mount Watch, cols: 20 + try + eq view.frame(), '0,0,0' + view.tick 50 + eq view.frame(), '1,50,50' + view.tick 120 + eq view.frame(), '3,150,50' + view.tick 30 + eq view.frame(), '4,200,50', 'the parser and the clock share one time' + finally + view.close() + +test "the frame catches up when the timer is late, and the interval defaults to 100 where none is given or the one given is no number", -> + fake = timers() + Late = component + tick = clock 60, fake + render + Text "#{tick.frame}" + view = mount Late, cols: 20 + try + view.frame() + fake.tick 250 + eq view.frame(), '4' + finally + view.close() + ok clock(NaN) is clock(100) and clock() is clock(100) and clock(Infinity) is clock(100) + +test "a clock never runs where the output is not interactive", -> + fake = timers() + Quiet = component + @shown := false + tick = clock 70, fake + render + Text (if @shown then "#{tick.frame}" else 'still') + view = mount Quiet, cols: 20 + try + view.held.view.interactive = false + eq view.frame(), 'still' + view.app.shown.value = true + eq view.frame(), '0' + eq fake.afters, 0 + finally + view.close() + +# ==[ Progress ]== + +console.log "\nProgress" + +test "progress goes out with the next frame's write: a percent, error, indeterminate, and the clear", -> + view = mount Journal, cols: 20 + try + view.frame() + screen.progress 0.5 + view.frame() + eq view.bytes, "\x1b[?2026h\x1b]9;4;1;50\x1b\\\x1b[?2026l" + screen.progress 'error' + view.frame() + eq view.bytes, "\x1b[?2026h\x1b]9;4;2\x1b\\\x1b[?2026l" + screen.progress 'indeterminate' + view.app.live.value = 'busy' + view.frame() + eq view.bytes, "\x1b[?2026h\x1b[1Gbusy\r\x1b]9;4;3\x1b\\\x1b[?2026l", 'one write carries the cells and the progress' + screen.progress null + view.frame() + eq view.bytes, "\x1b[?2026h\x1b]9;4;0\x1b\\\x1b[?2026l" + screen.progress 1 + screen.progress 0.333 + view.frame() + eq view.bytes, "\x1b[?2026h\x1b]9;4;1;33\x1b\\\x1b[?2026l", 'the last value set before the write is the one sent' + throws (-> screen.progress 2), "progress: 2 is not a number from 0 to 1, 'error', 'indeterminate' or null" + finally + view.close() + +test "the clear on the way out is answered once a progress was reported, and not otherwise", -> + view = mount Journal, cols: 20 + try + view.frame() + eq view.held.view.clearProgress(), '' + screen.progress null + view.frame() + eq view.bytes, '', 'a clear with nothing reported is nothing' + screen.progress 0.25 + view.frame() + eq view.held.view.clearProgress(), "\x1b]9;4;0\x1b\\" + eq view.held.view.clearProgress(), '' + finally + view.close() + +test "progress is nothing off a terminal, and with no app mounted", -> + screen.progress 0.5 + view = mount Journal, cols: 20 + try + view.held.view.interactive = false + view.frame() + screen.progress 0.5 + view.frame() + ok not view.bytes.includes("\x1b]9;4"), JSON.stringify view.bytes + eq view.held.view.clearProgress(), '' + finally + view.close() diff --git a/packages/tui/test/events/SOURCE.md b/packages/tui/test/events/SOURCE.md index f47ff33f..44e96f1c 100644 --- a/packages/tui/test/events/SOURCE.md +++ b/packages/tui/test/events/SOURCE.md @@ -38,7 +38,9 @@ pins. Of Ink's 194 titles in the fourteen files, 164 are ported: `harness.rip`: the answer must equal this package's stated one and must not equal Ink's, with the decision in a sentence, so a pin the package outgrows fails. They are listed below; -- 30 are left out, each with its reason. +- 23 are left out, each with its reason, and 7 more — `cursor.tsx`'s + `useStdout().write` and `useStderr().write` cases — are ported in + `test/ink/static.rip`, where `print` is pinned. `test/events.rip` holds `harness.rip`'s tally of titles to these counts, file by file, so the table cannot drift from what runs. A case @@ -182,7 +184,7 @@ outside a paste, the Linux console's `CSI [`, `SS3 Z`), one of - `hooks-use-input`: useInput - discrete priority keeps states in sync with useTransition during rapid input - `cursor`: cursor position does not leak from suspended concurrent render to fallback -**Writing beside the frame (`useStdout().write`, `useStderr().write`, Ink's debug writer): `print` is step 5 of PLAN §12** (7) +**Writing beside the frame (`useStdout().write`, `useStderr().write`, Ink's debug writer): ported in `test/ink/static.rip`, through `print` and `print.err`** (7) - `cursor`: cursor remains visible after useStdout().write() - `cursor`: cursor remains visible after useStderr().write() diff --git a/packages/tui/test/events/harness.rip b/packages/tui/test/events/harness.rip index 2d56497c..a7a063ca 100644 --- a/packages/tui/test/events/harness.rip +++ b/packages/tui/test/events/harness.rip @@ -22,7 +22,9 @@ ASKS =! { '?u': 'keyboard', 'c': 'attributes', '?6n': 'cursor' } # terminal shows until it is told otherwise); `queries` is what it was # asked, in order; `kitty` is how deep its keyboard stack is pushed; # `clipboard` is what OSC 52 last put there; `scrolled` counts the rows -# pushed into the scrollback; `sent` is every write. +# pushed into the scrollback; `sent` is every write. The alternate screen +# (1049) is a second buffer: entering it saves the lines and the cursor, +# leaving it brings them back. export class Terminal extends EventEmitter constructor: (@columns = 40, @rows = 10) -> super() @@ -35,6 +37,7 @@ export class Terminal extends EventEmitter @kitty = 0 @clipboard = null @scrolled = 0 + @saved = null get shown: -> @modes.has 25 get cursor: -> if @shown then { x: @x, y: @y } else null @@ -90,12 +93,22 @@ export class Terminal extends EventEmitter if params[0] is '?' for mode in params.slice(1).split ';' if verb is 'h' then @modes.add +mode else @modes.delete +mode + if +mode is 1049 and verb is 'h' + @saved = { lines: @lines, x: @x, y: @y } + @lines = [] + else if +mode is 1049 and @saved + { lines: @lines, x: @x, y: @y } = @saved + @saved = null return n = parseInt(params) or 1 switch verb when 'A' then @y = Math.max 0, @y - n when 'B' then @y = Math.min @rows - 1, @y + n when 'G' then @x = n - 1 + when 'H' + [row, col] = params.split ';' + @y = (parseInt(row) or 1) - 1 + @x = (parseInt(col) or 1) - 1 when 'J' @lines.length = @y + 1 @lines[@y]?.length = @x diff --git a/packages/tui/test/ink.rip b/packages/tui/test/ink.rip index baec5ba7..f3360634 100644 --- a/packages/tui/test/ink.rip +++ b/packages/tui/test/ink.rip @@ -39,3 +39,4 @@ import './ink/render-to-string.rip' import './ink/update.rip' import './ink/style-update-consistency.rip' import './ink/reconciler.rip' +import './ink/static.rip' diff --git a/packages/tui/test/ink/SOURCE.md b/packages/tui/test/ink/SOURCE.md index f8779ab7..76797efd 100644 --- a/packages/tui/test/ink/SOURCE.md +++ b/packages/tui/test/ink/SOURCE.md @@ -12,12 +12,15 @@ ## What runs -`rip test/ink.rip` runs 490 tests: 478 ported cases and 12 self-tests of -`cells.rip` (`cells-check.rip`). Of the ported cases: - -- 459 hold the frame to Ink's, row for row — 53 of them Ink's - `rerender` cases, which hold every frame of a mounted tree; -- 18 are **stated differences**, pinned through `differs` in +`rip test/ink.rip` runs 540 tests: 521 ported paint-side cases, the 7 +`useStdout` / `useStderr` cases of `cursor.tsx` (`static.rip`, counted +in `test/events/SOURCE.md`'s table), and 12 self-tests of `cells.rip` +(`cells-check.rip`). Of the paint-side cases: + +- 501 hold the frame to Ink's, row for row — 53 of them Ink's + `rerender` cases, which hold every frame of a mounted tree, and 16 + the `Static` cases that draw a mounted tree frame by frame; +- 19 are **stated differences**, pinned through `differs` in `harness.rip`: the frame must equal this package's stated frame and must not equal Ink's, with the decision in a sentence, so a pin the package outgrows fails. They are listed below; @@ -115,6 +118,21 @@ comment at the literal says which when it is not the first: the `link` prop where Ink's writes it into the text, and `cells.rip` reads OSC 8 and refuses a hyperlink left open at the end of a row. +Ink's `{fn}` is `Static` around a keyed +`for` here (`static.rip`), and Ink's `style` prop on it is the same +props on `Static`. Ink's `renderToString` answers the static output and +then the live frame, and so does this package's, so the +`renderToString` cases run through `plain` and `styled` unchanged. Ink's +`render` cases read its debug output, which is every static item so far +and then the frame; here that is what the terminal shows, the scrollback +and then the frame (`shown` in `static.rip`), and a case that asks +whether a stale item was written again holds the scrollback still. A +case that reconstructs a terminal of some height replays the bytes +through `test/events/harness.rip`'s terminal. The published build +draws several of these frames a row short, keeps a `Static` that a +layout effect replaced, or writes a removed `Static`'s items again with +every frame, and the comments at those literals say what it draws. + `wrap-text.tsx` calls Ink's `wrapText` function and cannot load against the published build, so `oracle/extra/wrap-text.tsx` asks the same questions through components and `wrap-text.rip` ports that; the one @@ -143,6 +161,7 @@ and `link`, and the hyperlink half is a stated difference. - `width-height`: set max width in percent - `flex-justify-content`: row - align two text nodes with equal space around them - `flex-justify-content`: column - align two text nodes with equal space around them +- `background`: Static background color is inherited by its text and the refusal test, `content-offset`: contentOffsetX/Y - non-finite offsets fall back to zero. @@ -151,7 +170,7 @@ offsets fall back to zero. | Ink test file | cases | ported | left out | |---|---:|---:|---:| -| `components` | 93 | 31 | 62 | +| `components` | 93 | 42 | 51 | | `text` | 57 | 49 | 8 | | `wrap-text` | 17 | 13 | 4 | | `text-width` | 18 | 18 | 0 | @@ -160,7 +179,7 @@ offsets fall back to zero. | `styled-combining-marks` | 9 | 9 | 0 | | `borders` | 52 | 48 | 4 | | `border-backgrounds` | 5 | 5 | 0 | -| `background` | 32 | 25 | 7 | +| `background` | 32 | 26 | 6 | | `overflow` | 44 | 39 | 5 | | `content-offset` | 23 | 23 | 0 | | `clip-wide-background` | 10 | 10 | 0 | @@ -179,14 +198,20 @@ offsets fall back to zero. | `flex-align-items` | 9 | 9 | 0 | | `flex-align-self` | 9 | 9 | 0 | | `flex-justify-content` | 12 | 12 | 0 | -| `render-to-string` | 37 | 23 | 14 | +| `render-to-string` | 37 | 28 | 9 | | `style-update-consistency` | 15 | 15 | 0 | | `reconciler` | 12 | 8 | 4 | -| **total** | **602** | **478** | **124** | +| `static-blank-lines` | 9 | 9 | 0 | +| `static-string-replacement` | 4 | 4 | 0 | +| `static-trailing-layout` | 7 | 7 | 0 | +| `static-runtime-blank-lines` | 8 | 5 | 3 | +| `static-abandoned-render` | 1 | 0 | 1 | +| `issue-973-static-commit` | 1 | 1 | 0 | +| **total** | **632** | **521** | **111** | A case counts once per title Ink registers, loops included. Files -finished by hand after the draft: `absolute-truncation` (written by -hand), `content-offset`, `overflow`, `render-to-string`, +finished by hand after the draft: `absolute-truncation` and `static` +(written by hand), `content-offset`, `overflow`, `render-to-string`, `styled-combining-marks`, `text-width`, `wrap-text`, the hyperlink case of `components`, the comments in `clip-wide-background` and `rendering-regressions`, and every `differs` @@ -197,42 +222,28 @@ and a rerender is one component drawn again. ## Cases left out -**A rerender of a mounted tree that is out of scope for another reason** (15) +**A rerender of a mounted tree that is out of scope for another reason** (6) -- `components`: static output stops accumulating after Static unmounts (#904) — `` -- `components`: separate Ink instances do not clobber each other’s staticNode — `` -- `components`: unmounting a ancestor in concurrent mode does not crash — `` +- `components`: separate Ink instances do not clobber each other’s staticNode — one app is mounted at a time +- `components`: updating in one instance after another instance mounted sets the dirty flag on the correct root — one app is mounted at a time +- `components`: unmounting a ancestor in concurrent mode does not crash — concurrent rendering +- `components`: unmounting a ancestor in screen-reader mode does not replay stale output — screen-reader output - `background`: Box preserves child state when adding a background color — a component with hooks - `background`: Box preserves child state when removing a background color — a component with hooks -- `components`: static padding is not emitted again when there are no new items — `` -- `components`: skip previous output when rendering new static output — `` -- `components`: fullStaticOutput is reset when unmounts so stale items are not replayed — `` -- `components`: unmounting an ancestor of clears staticNode and does not crash the renderer — `` -- `components`: removing a ancestor that is a direct child of the root does not crash — `` -- `components`: updating in one instance after another instance mounted sets the dirty flag on the correct root — `` -- `components`: unmounting a ancestor in screen-reader mode does not replay stale output — screen-reader output -- `components`: remounting via key change emits the new items (nested under ) — `` -- `components`: remounting via key change emits the new items (root-level — removeChildFromContainer) — `` -- `components`: render only new items in static output on final render — `` -**Concurrent rendering: the `- concurrent` twin of a case that is ported** (43) +**Concurrent rendering: the `- concurrent` twin of a case that is ported** (44) -`borders` 4, `background` 4, `overflow` 4, `text` 8, `width-height` 2, `position` 1, `display` 2, `margin` 2, `padding` 2, `gap` 3, `flex-direction` 2, `flex-align-content` 1, `components` 8 +`borders` 4, `background` 4, `overflow` 4, `text` 8, `width-height` 2, `position` 1, `display` 2, `margin` 2, `padding` 2, `gap` 3, `flex-direction` 2, `flex-align-content` 1, `components` 9 -**Concurrent rendering, of a case that is itself left out** (2) +**Concurrent rendering, of a case that is itself left out** (1) - `components`: transform children - concurrent — `` -- `components`: static output - concurrent — `` -**``** (7) +**The final frame written off a terminal at exit: terminal.rip** (3) -- `background`: Static background color is inherited by its text -- `render-to-string`: skip Static content inside a hidden ancestor -- `render-to-string`: render Static component with items -- `render-to-string`: Static preserves its margins and all items -- `render-to-string`: render static-only output has no trailing newline -- `render-to-string`: render static + dynamic output has exactly one newline between parts -- `components`: static output +- `static-runtime-blank-lines`: runtime Static preserves 0 blank rows in non-interactive output +- `static-runtime-blank-lines`: runtime Static preserves 1 blank rows in non-interactive output +- `static-runtime-blank-lines`: runtime Static preserves 2 blank rows in non-interactive output **``** (10) @@ -247,8 +258,9 @@ and a rerender is one component drawn again. - `components`: with null children - `render-to-string`: runs effect cleanup when a transform throws -**Suspense** (4) +**Suspense** (5) +- `static-abandoned-render`: abandoned transition render does not replace committed Static - `reconciler`: Suspense hides nested text while showing its fallback - `reconciler`: resuming Suspense preserves display none - `reconciler`: support suspense @@ -274,7 +286,7 @@ and a rerender is one component drawn again. - `render-to-string`: text outside Text component throws -**Input, the app lifecycle, the alternate screen, or CI output: no frame to compare** (27) +**Input, the app lifecycle, the alternate screen, or CI output: no frame to compare** (26) - `components`: disable raw mode when all input components are unmounted - `components`: do not disable raw mode when swapping components that use useInput @@ -302,7 +314,6 @@ and a rerender is one component drawn again. - `components`: alternate screen - content is rendered between enter and exit - `components`: alternate screen - ignored when isTTY is false - `components`: alternate screen - ignored when isTTY is false even if interactive is true -- `components`: static output is written immediately in non-interactive mode **An empty `render` block has no spelling in Rip** (1) @@ -323,7 +334,7 @@ and a rerender is one component drawn again. - **Input, focus, hooks, the cursor, and the kitty keyboard protocol:** `cursor`, `cursor-exit-position`, `cursor-helpers`, `focus`, `focus-empty-id-regression`, `focus-order`, `focus-strict-mode`, `hooks`, `hooks-use-input`, `hooks-use-input-kitty`, `hooks-use-input-navigation`, `hooks-use-paste`, `input-buffered-ctrl-c`, `input-keypad-enter`, `input-parser`, `kitty-keyboard`, `kitty-negotiation`, `parse-keypress`, `rerender-input`, `use-animation`. - **The app lifecycle, the terminal, and log-update internals:** `alternate-screen-example`, `clear-rerender`, `exit`, `exit-keyboard`, `log-update`, `log-update-blank-growth`, `render`, `render-callback`, `suspend-terminal`, `suspension-exit`, `suspension-handle`, `suspension-input-disable`, `suspension-output`, `suspension-resize`, `terminal-resize`, `write-synchronized`. - **Escape and control sequences embedded in text:** `ansi-newlines`, `ansi-tokenizer`, `c1-rendering`, `colon-colors`, `sanitize-ansi`, `text-controls`. -- **`` and ``:** `component-regressions`, `issue-973-static-commit`, `squash-text-nodes`, `static-abandoned-render`, `static-blank-lines`, `static-runtime-blank-lines`, `static-string-replacement`, `static-trailing-layout`. +- **``:** `component-regressions`, `squash-text-nodes`. - **Error boundaries and Ink's style table:** `error-overview`, `errors`, `styles`. - **measureElement, useBoxMetrics, and Ink's `measureText` function:** `measure-element`, `measure-text`, `use-box-metrics`. - **Screen-reader output:** `screen-reader`. diff --git a/packages/tui/test/ink/static.rip b/packages/tui/test/ink/static.rip new file mode 100644 index 00000000..58866fbc --- /dev/null +++ b/packages/tui/test/ink/static.rip @@ -0,0 +1,622 @@ +# Ink's Static and stdout-hook tests, ported: the trees of +# test/static-blank-lines.tsx, static-string-replacement.tsx, +# static-trailing-layout.tsx, static-runtime-blank-lines.tsx and +# issue-973-static-commit.tsx, the Static cases of components.tsx, +# render-to-string.tsx and background.tsx, and the useStdout / +# useStderr cases of cursor.tsx (test/events/SOURCE.md names them). +# +# Each test keeps Ink's title and draws Ink's tree, prop for prop. A +# `` with a render function is `Static` around a keyed +# `for`. SOURCE.md says where each expected frame comes from and which +# of Ink's cases are left out. +# +# rip test/ink/static.rip + +import { test, eq, ok } from 'rip/testing' +import { mount, print, Static } from 'rip/tui' +import { Box, Text, plain, styled, differs } from './harness.rip' +import { Terminal } from '../events/harness.rip' +import * as cells from './cells.rip' + +console.log "\nStatic" + +# Ink's debug output is every static item written so far and then the +# live frame, which is what a terminal shows: the scrollback, then the +# frame. +shown =! (view) -> + frame = view.frame() + cells.plain view.scrollback + frame + +mounted =! (App, options, body) -> + view = mount App, { cols: 100, ...options } + try + body view + finally + view.close() + +# ── static-blank-lines.tsx ──────────────────────────────────────────────────── + +# Published Ink draws "after" where one blank row is expected. +for height in [0, 1, 2, 3] + test "Static preserves #{height} blank rows before dynamic output", -> + App = component + render + Box flexDirection: 'column' + Static + for item in ['blank'] + Box key: item, height: height + Text "after" + Expected = component + render + Box flexDirection: 'column' + Box height: height + Text "after" + expected = plain Expected, 80 + eq expected, cells.plain('\n'.repeat(height) + 'after') + eq plain(App, 80), expected + +# What published Ink draws, by content: for " " it draws "after". +WHITESPACE =! { undefined: 'after', '""': 'after', '" "': '\nafter', '"\\n"': '\n\nafter' } + +for content in [undefined, '', ' ', '\n'] + test "Static preserves whitespace content #{JSON.stringify content}", -> + App = component + render + Box flexDirection: 'column' + Static + for key in ['item'] + Box key: key + Text "#{content ?? ''}" + Text "after" + Expected = component + render + Box flexDirection: 'column' + Text "#{content ?? ''}" + Text "after" + eq plain(App, 80), plain(Expected, 80) + eq plain(App, 80), cells.plain(WHITESPACE[JSON.stringify content]) + +test "empty Static does not insert a blank row", -> + App = component + render + Box flexDirection: 'column' + Static + for item in [] + Text key: item, item + Text "after" + eq plain(App, 80), ["after"] + +# ── static-string-replacement.tsx ───────────────────────────────────────────── + +# Ink's layout effect writes its state before the first frame is read; +# an effect here runs as the component is made, before any frame. + +# Published Ink draws "Old\nLive" and "Old\nNew\nLive": its +# renderToString keeps a Static its layout effect took away. +for replace in [false, true] + test "renderToString reflects a layout-effect Static #{if replace then 'replacement' else 'removal'}", -> + Example = component + updated := false + ~> updated = true + render + Box flexDirection: 'column' + if not updated or replace + Static + for item in [(if updated then 'New' else 'Old')] + Text key: item, item + Text "Live" + eq plain(Example, 80), cells.plain(if replace then 'New\nLive' else 'Live') + +test "layout-effect appends to the same Static retain earlier items", -> + Example = component + items := ['First'] + ~> items = ['First', 'Second'] + render + Static + for item in items + Text key: item, item + eq plain(Example, 80), ["First", "Second"] + +test "replacement Static survives cleanup in static-only output", -> + cleanups = 0 + Example = component + updated := false + ~> + updated = true + -> cleanups += 1 + render + Static + for item in [(if updated then 'New' else 'Old')] + Text key: item, item + # Published Ink draws "Old\nNew". + eq plain(Example, 80), ["New"] + eq cleanups, 1 + +# ── static-trailing-layout.tsx ──────────────────────────────────────────────── + +# Published Ink draws "A" for one trailing row. +for height in [0, 1, 2] + test "renderToString preserves #{height} trailing dynamic rows after Static", -> + App = component + render + Box flexDirection: 'column' + Static + for item in ['A'] + Text key: item, item + Box height: height + eq plain(App, 80), cells.plain "A#{'\n'.repeat height}" + +# Published Ink draws "A" and "A\nB", a row short. +TRAILING =! { '""': '', '"A"': 'A\n', '"A\\nB"': 'A\nB\n' } + +for staticText in ['', 'A', 'A\nB'] + test "blank dynamic rows agree with ordinary layout after Static #{JSON.stringify staticText}", -> + App = component + render + Box flexDirection: 'column' + Static + for item in ['item'] + Text key: item + staticText + Box height: 1 + Expected = component + render + Box flexDirection: 'column' + Text staticText + Box height: 1 + eq plain(App, 80), plain(Expected, 80) + eq plain(App, 80), cells.plain(TRAILING[JSON.stringify staticText]) + +test "hidden dynamic content does not add a separator to Static output", -> + App = component + render + Box flexDirection: 'column' + Static + for item in ['A'] + Text key: item, item + Box display: 'none', height: 1 + Text "Hidden" + eq plain(App, 80), ["A"] + +# ── static-runtime-blank-lines.tsx ──────────────────────────────────────────── + +# Published Ink shows "after" for one blank row. +for height in [0, 1, 2] + test "runtime Static preserves #{height} blank rows in debug output", -> + App = component + render + Box flexDirection: 'column' + Static + for item in ['blank'] + Box key: item, height: height + Text "after" + mounted App, {}, (view) -> + eq shown(view), cells.plain('\n'.repeat(height) + 'after') + +# Every frame here is drawn incrementally; Ink's flag has no counterpart. +# Published Ink shows ["first", "last", "updated", ""]: the blank row +# is lost. +for incremental in [false, true] + test "a blank Static append survives a later live update (incremental: #{incremental})", -> + View = component + @items := ['first'] + @label := 'live' + render + Box flexDirection: 'column' + Static + for item in @items + Box key: item, height: (if item is 'blank' then 1 else undefined) + if item isnt 'blank' + Text item + Text "#{@label}" + mounted View, { rows: 8 }, (view) -> + term = Terminal.new 100, 8 + draw = -> + view.frame() + term.write view.bytes + draw() + view.app.items.value = ['first', 'blank'] + draw() + view.app.items.value = ['first', 'blank', 'last'] + draw() + view.app.label.value = 'updated' + draw() + eq term.text.split('\n').slice(0, 4), ['first', '', 'last', 'updated'] + +# ── issue-973-static-commit.tsx ─────────────────────────────────────────────── + +# Ink's fixture: a live region stands, a static item taller than the +# terminal arrives beside it, then a live-only update follows; the +# erase of that update must not take the static item's last row. +test "#973: static item taller than viewport keeps its last line", -> + rows = 6 + Commit = component + @phase := 'mount' + render + Box flexDirection: 'column' + Static + for item in (if @phase is 'mount' then [] else [("line #{n}" for n in [1..rows]).join '\n']) + Text key: item, item + Box flexDirection: 'column' + Text (if @phase is 'nudge' then 'INPUT BOX' else 'input box') + mounted Commit, { rows }, (view) -> + term = Terminal.new 100, rows + for phase in ['mount', 'live', 'nudge'] + view.app.phase.value = phase + view.frame() + term.write view.bytes + seen = term.text.split('\n').filter (line) -> line.length + ok seen.includes('line 6'), "the last static line must stay visible, got #{JSON.stringify seen}" + ok seen.includes('INPUT BOX'), "the live region must render, got #{JSON.stringify seen}" + ok not term.sent.join('').includes("\x1b[H"), 'the terminal was never homed and erased' + +# ── components.tsx ──────────────────────────────────────────────────────────── + +test "static output", -> + App = component + render + Box + Static paddingBottom: 1 + for letter in ['A', 'B', 'C'] + Text key: letter, letter + Box marginTop: 1 + Text "X" + eq plain(App), ["A", "B", "C", "", "", "X"] + +test "static padding is not emitted again when there are no new items", -> + Test = component + @status := 'Waiting' + @items := ['A'] + render + Box flexDirection: 'column' + Static padding: 1 + for item in @items + Text key: item, item + Text "#{@status}" + # Published Ink draws the padding again with every frame: + # "\n A\n\n\n\nWaiting", "\n A\n\n\n\n\n\nReady" and + # "\n A\n\n\n\n\n\n\n B\n\n\n\nDone". + mounted Test, {}, (view) -> + eq shown(view), cells.plain "\n A\n\nWaiting" + view.app.status.value = 'Ready' + eq shown(view), cells.plain "\n A\n\nReady" + view.app.status.value = 'Done' + view.app.items.value = ['A', 'B'] + eq shown(view), cells.plain "\n A\n\n\n B\n\nDone" + +# Ink's debug write is every item so far; what reaches the terminal for +# a frame is its new items, and the earlier ones are not written again. +test "skip previous output when rendering new static output", -> + Dynamic = component + @items := ['A'] + render + Static + for item in @items + Text key: item, item + mounted Dynamic, {}, (view) -> + view.frame() + eq view.scrollback, "A\n" + view.app.items.value = ['A', 'B'] + view.frame() + eq view.scrollback, "A\nB\n" + ok not view.bytes.includes('A'), "the frame wrote A again: #{JSON.stringify view.bytes}" + +test "static output stops accumulating after Static unmounts (#904)", -> + App = component + @show := true + render + Box + if @show + Static + for item in ['A', 'B'] + Text key: item, item + Text "Dynamic" + mounted App, {}, (view) -> + eq shown(view), ['A', 'B', 'Dynamic'] + view.app.show.value = false + eq view.frame(), 'Dynamic' + written = view.scrollback + for n in [0...10] + view.frame() + eq view.scrollback, written + eq view.frame(), 'Dynamic' + +test "fullStaticOutput is reset when unmounts so stale items are not replayed", -> + App = component + @show := true + @dynamicLabel := 'd1' + render + Box + if @show + Static + for item in ['HISTORY-A', 'HISTORY-B'] + Text key: item, item + Text "#{@dynamicLabel}" + mounted App, {}, (view) -> + eq shown(view), ['HISTORY-A', 'HISTORY-B', 'd1'] + written = view.scrollback + view.app.show.value = false + view.app.dynamicLabel.value = 'd2' + eq view.frame(), 'd2' + eq view.scrollback, written + ok not view.bytes.includes('HISTORY'), "a stale item was written again: #{JSON.stringify view.bytes}" + +# Published Ink writes HISTORY-X again with every frame after the unmount. +test "unmounting an ancestor of clears staticNode and does not crash the renderer", -> + Wrapper = component + render + Box + slot + App = component + @showWrapper := true + @label := 'live-1' + render + Box + if @showWrapper + Wrapper + Static + for item in ['HISTORY-X'] + Text key: item, item + Text "#{@label}" + mounted App, {}, (view) -> + eq shown(view), ['HISTORY-X', 'live-1'] + written = view.scrollback + view.app.showWrapper.value = false + view.app.label.value = 'live-2' + eq view.frame(), 'live-2' + eq view.scrollback, written + view.app.label.value = 'live-3' + eq view.frame(), 'live-3' + eq view.scrollback, written + +# Published Ink writes ROOT-HISTORY again after the unmount. +test "removing a ancestor that is a direct child of the root does not crash", -> + App = component + @showWrapper := true + @label := 'root-1' + render + Box flexDirection: 'column' + if @showWrapper + Box + Static + for item in ['ROOT-HISTORY'] + Text key: item, item + Text "#{@label}" + mounted App, {}, (view) -> + eq shown(view), ['ROOT-HISTORY', 'root-1'] + written = view.scrollback + view.app.showWrapper.value = false + view.app.label.value = 'root-2' + eq view.frame(), 'root-2' + eq view.scrollback, written + +# The items of the first Static stay in the terminal's scrollback, where +# Ink's debug replay forgets them; the rows from the remount on are held. +test "remounting via key change emits the new items (nested under )", -> + App = component + @session := 1 + render + Box + if @session is 1 + Static + for item in ['old-A', 'old-B'] + Text key: item, item + else + Static + for item in ['new-C', 'new-D'] + Text key: item, item + Text "dynamic" + mounted App, {}, (view) -> + eq shown(view), ['old-A', 'old-B', 'dynamic'] + view.app.session.value = 2 + eq shown(view).slice(2), ['new-C', 'new-D', 'dynamic'] + +test "remounting via key change emits the new items (root-level — removeChildFromContainer)", -> + App = component + @session := 1 + render + Box flexDirection: 'column' + if @session is 1 + Static + for item in ['old-A', 'old-B'] + Text key: item, item + else + Static + for item in ['new-C', 'new-D'] + Text key: item, item + mounted App, {}, (view) -> + eq shown(view), ['old-A', 'old-B', ''] + view.app.session.value = 2 + eq shown(view).slice(2), ['new-C', 'new-D', ''] + +test "render only new items in static output on final render", -> + Dynamic = component + @items := [] + render + Static + for item in @items + Text key: item, item + mounted Dynamic, {}, (view) -> + view.frame() + eq view.scrollback, '' + view.app.items.value = ['A'] + view.frame() + eq view.scrollback, "A\n" + view.app.items.value = ['A', 'B'] + view.frame() + eq view.scrollback, "A\nB\n" + ok view.bytes.includes('B') and not view.bytes.includes('A'), "the last frame wrote #{JSON.stringify view.bytes}" + +# Off a terminal, an item is written as plain text with the frame that +# finds it, and the live frame is not drawn there (terminal.rip). +test "static output is written immediately in non-interactive mode", -> + App = component + @items := ['A'] + render + Box + Static + for item in @items + Text key: item, item + Text "Dynamic" + mounted App, {}, (view) -> + view.held.view.interactive = false + view.frame() + ok view.bytes.startsWith("A\n"), JSON.stringify view.bytes + view.app.items.value = ['A', 'B'] + view.frame() + ok view.bytes.startsWith("B\n"), JSON.stringify view.bytes + eq view.scrollback, "A\nB\n" + +# ── render-to-string.tsx ────────────────────────────────────────────────────── + +# Published Ink throws on this tree: a negative count in its output. +test "skip Static content inside a hidden ancestor", -> + App = component + render + Box flexDirection: 'column' + Box display: 'none' + Static + for item in ['Hidden'] + Box key: item, borderStyle: 'single' + Text item + Text "Visible" + eq plain(App, 80), ["Visible"] + +test "render Static component with items", -> + App = component + render + Box flexDirection: 'column' + Static + for item in ['A', 'B', 'C'] + Text key: item, item + Text "Dynamic" + eq plain(App, 80), ["A", "B", "C", "Dynamic"] + +# Published Ink draws "\n A": one item, one column of margin. +test "Static preserves its margins and all items", -> + App = component + render + Static marginTop: 1, marginBottom: 1, marginLeft: 2 + for item in ['A', 'B'] + Text key: item, item + eq plain(App, 80), ["", " A", " B", ""] + +test "render static-only output has no trailing newline", -> + App = component + render + Static + for item in ['A', 'B'] + Text key: item, item + eq plain(App, 80), ["A", "B"] + +test "render static + dynamic output has exactly one newline between parts", -> + App = component + render + Box flexDirection: 'column' + Static + for item in ['A', 'B'] + Text key: item, item + Text "Dynamic" + eq plain(App, 80), ["A", "B", "Dynamic"] + +# ── background.tsx ──────────────────────────────────────────────────────────── + +# Published Ink colors the two padding cells and not the A. +test "Static background color is inherited by its text", -> + App = component + render + Static width: 3, backgroundColor: 'blue' + for item in ['A'] + Text key: item, item + differs styled(App), ['«on blue»A '], ['«on blue»A ', ''], + "the live frame is empty, so nothing follows the item; Ink's root keeps one row for its absolute Static" + +# ── cursor.tsx: useStdout().write and useStderr().write ─────────────────────── + +# `print` writes above the live frame and `print.err` the same on +# stderr; Ink's `useCursor` places the cursor, which a node's `cursor` +# does here. Ink's effect runs after the first frame; an effect here +# runs as the component is made, and `ready` stands for that turn where +# a case reads the frames after it. + +Hook =! (write) -> + component + ~> write() + render + Box focusable: true, autofocus: true, cursor: { x: 2, y: 0 } + Text "Hello" + +test "cursor remains visible after useStdout().write()", -> + mounted Hook(-> print 'from stdout hook'), {}, (view) -> + view.frame() + ok view.bytes.includes('from stdout hook'), JSON.stringify view.bytes + ok view.bytes.lastIndexOf("\x1b[?25h") > view.bytes.lastIndexOf("\x1b[?25l"), "the cursor is left hidden: #{JSON.stringify view.bytes}" + +test "cursor remains visible after useStderr().write()", -> + mounted Hook(-> print.err 'from stderr hook'), {}, (view) -> + view.frame() + eq view.stderr, "from stderr hook\n" + ok view.bytes.lastIndexOf("\x1b[?25h") > view.bytes.lastIndexOf("\x1b[?25l"), "the cursor is left hidden: #{JSON.stringify view.bytes}" + +Debug =! (write) -> + component + @ready := false + ~> write() if @ready + render + Text "Hello" + +test "debug mode: useStdout().write() replays latest frame", -> + mounted Debug(-> print 'from stdout hook'), {}, (view) -> + term = Terminal.new 100, 24 + view.frame() + term.write view.bytes + view.app.ready.value = true + view.frame() + term.write view.bytes + eq term.text, "from stdout hook\nHello" + ok view.bytes.includes('Hello'), 'the frame was drawn again below the line' + +test "debug mode: useStdout().write() does not leak into stderr", -> + mounted Debug(-> print 'from stdout hook'), {}, (view) -> + view.frame() + view.app.ready.value = true + view.frame() + eq view.stderr, '' + +test "debug mode: useStderr().write() replays latest frame without empty writes", -> + mounted Debug(-> print.err 'from stderr hook'), {}, (view) -> + term = Terminal.new 100, 24 + view.frame() + term.write view.bytes + view.app.ready.value = true + view.frame() + term.write view.bytes + eq view.stderr, "from stderr hook\n" + eq term.text, 'Hello' + ok view.bytes.includes('Hello'), 'the frame was drawn again after the clear' + ok not view.bytes.includes('from stderr hook'), 'stderr text reached stdout' + +Rerendered =! (write) -> + component + text := 'Initial' + ~> text = 'Updated' + ~> write() if text is 'Updated' + render + Text text + +test "debug mode: useStdout().write() replays rerendered frame", -> + mounted Rerendered(-> print 'from stdout hook'), {}, (view) -> + term = Terminal.new 100, 24 + view.frame() + term.write view.bytes + eq term.text, "from stdout hook\nUpdated" + ok not view.bytes.includes('Initial'), 'the frame before the write was drawn' + +test "debug mode: useStderr().write() replays rerendered frame", -> + mounted Rerendered(-> print.err 'from stderr hook'), {}, (view) -> + term = Terminal.new 100, 24 + view.frame() + term.write view.bytes + eq view.stderr, "from stderr hook\n" + eq term.text, 'Updated' + ok not view.bytes.includes('Initial') and not view.bytes.includes('from stderr hook'), JSON.stringify view.bytes diff --git a/packages/tui/test/terminal.rip b/packages/tui/test/terminal.rip new file mode 100644 index 00000000..3caa06d2 --- /dev/null +++ b/packages/tui/test/terminal.rip @@ -0,0 +1,1541 @@ +# ============================================================================== +# tui terminal tests — every way out, the signals, suspend and resume, the +# alternate screen, output that is no terminal, colors, and the console +# +# rip test/terminal.rip +# +# Ink's suspend, exit, error, console and CI cases come first +# (test/terminal/SOURCE.md counts them). Then what this package pins for +# itself: one setup and one teardown shared by every way out, and what +# each leaves; the signals and the crash, taken for real in a spawned +# process; Ctrl-Z and `suspend`, the bytes and the raw-mode calls in +# order; the alternate screen; a stdout that is no terminal; the color +# depth and what a color becomes at each; the console while an app +# runs; and a fuzz that holds the terminal's modes to what the app +# believes after every step. +# ============================================================================== + +import { test as pin, eq, ok, throws, plainEnv } from 'rip/testing' +import { run, quit, suspend, mount, renderToString, print, screen, Box, Text, Static } from 'rip/tui' +import { Log } from '../examples/log.rip' +import { Terminal, Stdin, count, differs, tally } from './events/harness.rip' +import { join } from 'path' +import { tmpdir } from 'os' +import { unlinkSync } from 'fs' + +# A test that fails with its app still live would hand its own report +# to the app's console capture and leave the next test refused: every +# async test ends with a quit, which is nothing when nothing runs. A +# sync test is run as it is: its body is over before the next begins, +# and a quit deferred past it would reach that next test's app. +test =! (name, fn) -> + return pin name, fn unless fn.constructor.name is 'AsyncFunction' + pin name, -> + try + fn! + finally + quit() + sleep! 0 + +CSI =! '\x1b[' +HIDE =! "#{CSI}?25l" +SHOW =! "#{CSI}?25h" +ASK =! "#{CSI}?2004h#{CSI}?1004h" # bracketed paste, focus reports (xterm ctlseqs) +ON =! HIDE + ASK # what `run` opens a terminal with +OFF =! "#{CSI}?1004l#{CSI}?2004l" # and withdraws, before the cursor is shown +MOUSE =! "#{CSI}?1002h#{CSI}?1006h" # button-event tracking, SGR reports +QUIET =! "#{CSI}?1006l#{CSI}?1002l" +PROBE =! "#{CSI}?6n" # DECXCPR: where is the cursor +QUERY =! "#{CSI}?u#{CSI}c" # kitty's "is the protocol here", then primary device attributes +PUSH =! "#{CSI}>1u" # kitty: push the disambiguation flag +POP =! "#{CSI} "#{CSI}<#{bits};#{x + 1};#{y + 1}#{final}" +down =! (x, y) -> report 0, x, y +up =! (x, y) -> report 0, x, y, 'm' + +# What is written apart from escape sequences. +plain =! (text) -> text.replace(/\x1b\[[?<>0-9;]*[A-Za-z]/g, '').replace /\x1b\][^\x1b]*\x1b\\/g, '' + +# A stream that is no terminal, and keeps what it is sent. +class Pipe + constructor: (@columns = 40, @rows = 10) -> + @sent = [] + write: (text) -> + @sent.push text + true + get text: -> @sent.join '' + +Echo =! component + @keys := [] + @label := 'x' + render + Box focusable: true, autofocus: true, @keydown: ((event) => @keys.push event.key) + Text "#{@label}" + +# Throws on X, so a listener that throws is one key away. +Ways =! component + @heard := [] + render + Box focusable: true, autofocus: true, @keydown: ((event) => if event.key is 'X' then raise 'a listener failed' else @heard.push event.key), @click: (=> @heard.push 'click') + Text "w" + +# The console, as the test sees it: each method replaced by a recorder +# for the test's turn, so what passes through the package's patch, and +# what reaches the console once the app is closed, are both read. The +# app is closed before the recorders go, so its own release finds them +# where it left them. +recorded =! (body) -> + held = {} + printed = [] + for name in ['log', 'info', 'debug', 'warn', 'error'] + held[name] = console[name] + console[name] = ((name) -> (...args) -> printed.push [name, args.join ' '])(name) + try + body! printed + finally + quit() + sleep! 0 + console[name] = fn for name, fn of held + +# ── Every way out ───────────────────────────────────────────────────────────── + +# The process's handlers the run may add, counted before and after. +HANDLED =! ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGCONT', 'uncaughtException', 'unhandledRejection', 'beforeExit', 'exit'] +listeners =! -> (process.listenerCount name for name in HANDLED) + +# What a way out must leave: the terminal's modes off, its keyboard +# stack popped, its cursor shown; stdin cooked, paused and let go; no +# handler of the run's on the process; the `document` slot free. +every =! (name, term, stdin, before) -> + ok not term.modes.has(mode), "#{name}: mode #{mode} was left on" for mode in [2004, 1004, 1002, 1003, 1006, 1049] + ok term.shown, "#{name}: the cursor was left hidden" + eq term.kitty, 0, "#{name}: the keyboard stack" + eq [stdin.raw, stdin.listenerCount('data'), typeof document], [false, 0, 'undefined'], name + eq stdin.calls.slice(-3), ['raw off', 'pause', 'unref'], name + eq listeners(), before, "#{name}: a handler was left on the process" + +# ── A process of its own ────────────────────────────────────────────────────── + +ROOT =! join(import.meta.dir, '../../..') +LIMIT =! 20000 # ms a child may take before it is killed + +# test/terminal/child.rip run through the checkout's own loader, its +# stdout read as it comes. A child that outlives the limit is continued +# (a stopped one takes nothing else) and killed, never left behind. +class Child + constructor: (way, extra = {}) -> + @out = '' + env = plainEnv { CI: 'false', RIP_STDLIB_ANCHOR: join(import.meta.dir, 'terminal'), ...extra } + delete env.NO_COLOR unless extra.NO_COLOR? + @proc = Bun.spawn ['bun', "--preload=#{join ROOT, 'src/loader.js'}", join(ROOT, 'src/cli/run.js'), join(import.meta.dir, 'terminal/child.rip'), way], { cwd: import.meta.dir, env: env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } + @timer = setTimeout (=> @kill()), LIMIT + @pumping = @pump() + + pump!: -> + reader = @proc.stdout.getReader() + decoder = TextDecoder.new() + loop + { done, value } = reader.read! + break if done + @out += decoder.decode value, { stream: true } + + kill!: -> + @signal 'SIGCONT' + @signal 'SIGKILL' + + # Wait for `text` to arrive on stdout. + seen: (text) -> + start = performance.now() + until @out.includes text + raise "the child never wrote #{JSON.stringify text}; it wrote #{JSON.stringify @out}" if performance.now() - start > LIMIT + sleep! 5 + return + + # A signal, by the process's own `kill`: a stopped child takes it. + signal: (name) -> process.kill @proc.pid, name + + # The exit code, the whole of stdout, and stderr. + finish: -> + code = await @proc.exited + clearTimeout @timer + err = Response.new(@proc.stderr).text! + await @pumping + { code, out: @out, err } + +# test/terminal/child.rip's `tty` way as a job under ptyrun.py's shell: +# a pty, a process group of its own, and the shell's own account of +# every stop, continue and exit, as JSON lines. `steps` is what the +# shell is scripted to do (ptyrun.py says how). A job that outlives the +# limit is continued and killed, with its shell. +class Pty + constructor: (steps, n = Pty.count += 1) -> + @keys = join tmpdir(), "rip-tui-#{process.pid}-#{n}.json" + Bun.write @keys, JSON.stringify steps + @events = [] + @out = '' + @job = null + @proc = Bun.spawn ['python3', join(import.meta.dir, 'terminal/ptyrun.py'), join(import.meta.dir, 'terminal'), @keys, '--', join(ROOT, 'bin/rip'), 'child.rip', 'tty'], { cwd: import.meta.dir, env: { ...plainEnv({ CI: 'false', TERM: 'xterm-256color' }), NO_COLOR: undefined }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } + @timer = setTimeout (=> @kill()), LIMIT + @pumping = @pump() + + pump!: -> + reader = @proc.stdout.getReader() + decoder = TextDecoder.new() + held = '' + loop + { done, value } = reader.read! + break if done + held += decoder.decode value, { stream: true } + lines = held.split '\n' + held = lines.pop() + for line in lines when line + event = JSON.parse line + @events.push event + @job = event.started if event.started? + @out += event.out if event.out? + + kill!: -> + if @job + for signal in ['SIGCONT', 'SIGKILL'] + try + process.kill -@job, signal + catch error + null + @proc.kill 'SIGKILL' + + # The shell's events, without the output; and the output before the + # first event of `kind`. + get states: -> (event for event in @events when not event.out?) + before: (kind, nth = 1) -> + text = '' + for event in @events + if event[kind]? + nth -= 1 + break if nth is 0 + text += event.out if event.out? + text + + finish: -> + code = await @proc.exited + clearTimeout @timer + err = Response.new(@proc.stderr).text! + await @pumping + unlinkSync @keys + { code, err, states: @states, out: @out } + +Pty.count = 0 + +# ==[ Ink's cases ]== + +console.log "\nInk: suspend-terminal" + +test! "suspendTerminal hands the terminal to the callback, then restores Ink", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term + raw = null + suspend! -> + raw = stdin.raw + ok term.since(0).includes(SHOW), 'the cursor is shown for the callback' + count 'suspend-terminal', 'suspendTerminal hands the terminal to the callback, then restores Ink', 'held' + eq [raw, stdin.raw], [false, true] + mark = term.sent.findIndex (write) -> write.includes SHOW + ok term.since(mark + 1).includes(HIDE), 'the cursor is hidden again after' + quit() + await running.done + +test! "suspendTerminal restores the terminal even if the callback throws", -> + stdin = Stdin.new() + running = run Echo, stdin: stdin, stdout: Terminal.new() + threw = false + try + suspend! -> raise 'the callback failed' + catch error + threw = error.message is 'the callback failed' + count 'suspend-terminal', 'suspendTerminal restores the terminal even if the callback throws', 'held' + eq [threw, stdin.raw], [true, true] + quit() + await running.done + +test! "suspendTerminal keeps Ink off the terminal while suspended", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term + writes = null + suspend! -> + before = term.sent.length + running.app.label.value = 'changed' + sleep! 20 + writes = term.sent.length - before + count 'suspend-terminal', 'suspendTerminal keeps Ink off the terminal while suspended', 'held' + eq writes, 0 + sleep! 20 + ok term.text.endsWith('changed'), "the change is drawn on resume: #{JSON.stringify term.text}" + quit() + await running.done + +test! "suspendTerminal runs the callback but skips the handoff when not interactive", -> + pipe = Pipe.new() + running = run Echo, stdin: Stdin.new(), stdout: pipe + ran = false + suspend! -> ran = true + count 'suspend-terminal', 'suspendTerminal runs the callback but skips the handoff when not interactive', 'held' + eq [ran, pipe.text.includes(SHOW)], [true, false] + quit() + await running.done + +test! "suspendTerminal rejects a nested suspend while already suspended", -> + running = run Echo, stdin: Stdin.new(), stdout: Terminal.new() + refused = null + suspend! -> + try + suspend! -> null + catch error + refused = error.message + count 'suspend-terminal', 'suspendTerminal rejects a nested suspend while already suspended', 'held' + ok /already suspended/.test(refused), refused + quit() + await running.done + +test! "suspendTerminal hands the terminal to a child process, then redraws (PTY)", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term + suspend! -> + term.write 'CHILD_OUTPUT\n' + sleep! 20 + count 'suspend-terminal', 'suspendTerminal hands the terminal to a child process, then redraws (PTY)', 'held' + output = term.since 0 + ok output.includes('CHILD_OUTPUT') and output.includes(SHOW) + after = output.slice output.lastIndexOf('CHILD_OUTPUT') + 'CHILD_OUTPUT'.length + ok plain(after).includes('x') and after.includes(HIDE), JSON.stringify after + eq term.text, "x\nCHILD_OUTPUT\nx", 'the frame stands again below what the child wrote' + quit() + await running.done + +test! "suspendTerminal exits and re-enters the alternate screen", -> + term = Terminal.new() + running = run Echo, stdin: Stdin.new(), stdout: term, altScreen: true + left = null + at = 0 + suspend! -> + left = term.since(0).includes LEAVE + at = term.sent.length + count 'suspend-terminal', 'suspendTerminal exits and re-enters the alternate screen', 'held' + eq [left, term.since(at).includes(ENTER)], [true, true] + quit() + await running.done + +test! "suspendTerminal rolls back so a later suspend works if handover throws", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term + once = true + cooked = stdin.setRawMode + stdin.setRawMode = (raw) -> + if once and not raw + once = false + raise 'EIO: setRawMode' + cooked.call stdin, raw + first = null + try + suspend! -> null + catch error + first = error.message + second = false + suspend! -> second = true + count 'suspend-terminal', 'suspendTerminal rolls back so a later suspend works if handover throws', 'held' + eq [first, second, stdin.raw], ['EIO: setRawMode', true, true] + quit() + await running.done + eq stdin.raw, false + +console.log "\nInk: suspension-exit" + +test! "resuming after unmount does not re-enable terminal input (callback: true)", -> + stdin = Stdin.new() + running = run Echo, stdin: stdin, stdout: Terminal.new() + suspend! -> + quit() + await running.done + count 'suspension-exit', 'resuming after unmount does not re-enable terminal input (callback: true)', 'held' + eq [stdin.raw, stdin.listenerCount('data'), stdin.calls.slice(-3)], [false, 0, ['raw off', 'pause', 'unref']] + +console.log "\nInk: suspension-output" + +test! "non-interactive suspension keeps the latest rendered frame", -> + pipe = Pipe.new() + running = run Echo, stdin: Stdin.new(), stdout: pipe + running.app.label.value = 'before' + suspend! -> + running.app.label.value = 'after' + sleep! 20 + quit() + await running.done + count 'suspension-output', 'non-interactive suspension keeps the latest rendered frame', 'held' + eq pipe.text, 'after\n' + +console.log "\nInk: suspension-resize" + +for columns in [80, 120] + test! "resize to #{columns} columns does not write while suspended", -> + term = Terminal.new 100, 10 + running = run Echo, stdin: Stdin.new(), stdout: term + running.app.label.value = 'hello' + sleep! 20 + before = 0 + suspend! -> + before = term.sent.length + term.columns = columns + term.emit 'resize' + eq term.sent.slice(before), [] + sleep! 20 + count 'suspension-resize', "resize to #{columns} columns does not write while suspended", 'held' + ok term.since(before).includes('hello'), JSON.stringify term.since before + eq screen.cols, columns + quit() + await running.done + +console.log "\nInk: kitty-negotiation" + +test! "suspension cancels pending keyboard negotiation", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term, keyboard: 'enhanced', altScreen: true + at = 0 + suspend! -> + before = term.sent.length + stdin.key KITTY + sleep! 30 + eq term.sent.slice(before), [], "an answer while suspended is nobody's" + at = term.sent.length + differs 'kitty-negotiation', 'suspension cancels pending keyboard negotiation', [term.since(at).includes(PUSH), term.since(at).includes(QUERY)], [false, true], [false, false], 'the question is asked again on resume, so a terminal that answers it then is pushed to; Ink cancels the negotiation for the rest of the run' + quit() + await running.done + +test! "unmount while suspended does not pop the primary keyboard stack", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term, keyboard: 'enhanced' + stdin.key KITTY + suspend! -> + quit() + await running.done + count 'kitty-negotiation', 'unmount while suspended does not pop the primary keyboard stack', 'held' + eq [term.since(0).split(PUSH).length - 1, term.since(0).split(POP).length - 1, term.kitty], [1, 1, 0] + +console.log "\nInk: exit" + +test! "exit normally without unmount() or exit(): the loop drains, and the app is closed", -> + child = Child.new 'drain' + { code, out } = child.finish! + count 'exit', 'exit normally without unmount() or exit()', 'held' + count 'exit', 'exit when app finishes execution', 'held' + eq code, 0 + ok out.endsWith("#{QUIET}#{OFF}\n#{SHOW}exited\n"), JSON.stringify out.slice -60 + +test! "exit on unmount(), on exit(), with raw mode: `quit` closes the app and resolves `done`", -> + stdin = Stdin.new() + running = run Echo, stdin: stdin, stdout: Terminal.new() + quit() + eq await running.done, undefined + count 'exit', 'exit on unmount()', 'held' + count 'exit', 'exit on exit()', 'held' + count 'exit', 'exit on exit() with raw mode', 'held' + count 'exit', 'exit on unmount() with raw mode', 'held' + eq [stdin.raw, typeof document], [false, 'undefined'] + +test! "exit on exit() with a result value, and with an object result", -> + running = run Echo, stdin: Stdin.new(), stdout: Terminal.new() + quit 'hello from ink' + eq await running.done, 'hello from ink' + count 'exit', 'exit on exit() with result value', 'held' + running = run Echo, stdin: Stdin.new(), stdout: Terminal.new() + quit { message: 'hello from ink object' } + eq await running.done, { message: 'hello from ink object' } + count 'exit', 'exit on exit() with object result', 'held' + +test "exit with thrown error", -> + Thrower = component + ~> raise 'errored' + render + Text "never" + stdin = Stdin.new() + throws (-> run Thrower, stdin: stdin, stdout: Terminal.new()), 'errored' + count 'exit', 'exit with thrown error', 'held' + eq [stdin.raw, typeof document], [false, 'undefined'] + +console.log "\nInk: errors" + +test! "clean up raw mode when error is thrown", -> + child = Child.new 'throw' + { code, out, err } = child.finish! + count 'errors', 'clean up raw mode when error is thrown', 'held' + eq code, 1 + ok err.includes('a timer failed'), err + ok out.endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -60 + +console.log "\nInk: render" + +for option in ['omitted', 'undefined'] + test! "intercept console methods with #{option} patchConsole option", -> + Hello = component + render + Text "Hello World" + recorded! (printed) -> + term = Terminal.new() + options = { stdin: Stdin.new(), stdout: term } + options.console = undefined if option is 'undefined' + running = run Hello, options + console.log 'First log' + sleep! 20 + eq term.text, "First log\nHello World" + quit() + await running.done + console.log 'Second log' + count 'render', "intercept console methods with #{option} patchConsole option", 'held' + eq [term.text, printed], ["First log\nHello World", [['log', 'Second log']]] + +console.log "\nInk: components" + +test! "render only last frame when run in CI", -> + child = Child.new 'ci', { CI: 'true' } + { code, out } = child.finish! + count 'components', 'render only last frame when run in CI', 'held' + eq code, 0 + ok not out.includes("count:#{n}"), "count:#{n} was written" for n in [0, 1, 2, 3, 4] + eq out, "count:5\nexited\n" + +test! "render all frames if CI environment variable equals false", -> + child = Child.new 'ci', { CI: 'false' } + { code, out } = child.finish! + count 'components', 'render all frames if CI environment variable equals false', 'held' + eq code, 0 + ok plain(out).startsWith('count:0'), JSON.stringify plain(out).slice 0, 40 + ok out.split(BEGIN).length - 1 >= 6, "#{out.split(BEGIN).length - 1} frames were written" + ok plain(out).endsWith("5\r\nexited\n"), JSON.stringify plain(out).slice -20 + +test "the ported titles are the ones SOURCE.md counts", -> + eq tally, { + 'suspend-terminal': { held: 8, differs: 0 } + 'suspension-exit': { held: 1, differs: 0 } + 'suspension-output': { held: 1, differs: 0 } + 'suspension-resize': { held: 2, differs: 0 } + 'kitty-negotiation': { held: 1, differs: 1 } + 'exit': { held: 9, differs: 0 } + 'errors': { held: 1, differs: 0 } + 'render': { held: 2, differs: 0 } + 'components': { held: 2, differs: 0 } + } + +# ==[ Setup and teardown ]== + +console.log "\nSetup and teardown" + +test! "every way out — quit, Ctrl-C, a listener that throws, a suspend then quit — inline and on the alternate screen, leaves the terminal, stdin and the process as they were", -> + ways = [ + ['quit', (stdin) -> quit()] + ['Ctrl-C', (stdin) -> stdin.key '\x03'] + ['a listener that throws', (stdin) -> stdin.key 'X'] + ['a suspend, then quit', (stdin) -> suspend -> quit()] + ] + for alt in [false, true] + for [way, leave] in ways + name = "#{way}#{if alt then ' on the alternate screen' else ''}" + before = listeners() + stdin = Stdin.new() + term = Terminal.new() + running = run Ways, stdin: stdin, stdout: term, mouse: true, keyboard: 'enhanced', altScreen: alt + stdin.key KITTY + eq [term.kitty, stdin.raw, term.modes.has(1049)], [1, true, alt], name + leave stdin + try + await running.done + catch error + eq error.message, 'a listener failed', name + every name, term, stdin, before + eq term.text, (if alt then '' else 'w'), "#{name}: what the screen shows" + eq term.cursor, { x: 0, y: (if alt or way is 'a listener that throws' then 0 else 1) }, "#{name}: where the cursor lands" + +test! "the run's handlers stand on the process only while the app is live, and a second run sees no stale one", -> + before = listeners() + running = run Echo, stdin: Stdin.new(), stdout: Terminal.new() + eq listeners(), (n + 1 for n in before.slice(0, 3)).concat(before.slice(3, 4), (n + 1 for n in before.slice(4))) + quit() + await running.done + eq listeners(), before + running = run Echo, stdin: Stdin.new(), stdout: Terminal.new() + eq process.listenerCount('SIGTERM'), before[1] + 1 + quit() + await running.done + eq listeners(), before + +# Every state an app can be in, against every way out: the process's +# listeners are as they were after each, and each way ends as it +# should — a signal recorded as 128 + n with `done` resolved, a crash +# seen once by the app's own handler with `done` rejected and no exit +# recorded, an `exit` or a `quit` resolving `done`. +test! "every state — running on a terminal, on a pipe, inside a suspend, stopped and continued, mounted, mounted after a suspend — against every way out: no listener left, and each way ends as it should", -> + before = listeners() + exits = [] + held = process.exit + process.exit = (code) -> exits.push code + seen = [] + user = (error) -> seen.push error.message + process.on 'uncaughtException', user + base = listeners() + Thrower = component + @heard := [] + render + Box focusable: true, autofocus: true, @keydown: ((event) => if event.key is 'X' then raise 'a listener failed' else @heard.push event.key) + Text "t" + ways = ['quit', 'Ctrl-C', 'a listener that throws', 'SIGTERM', 'uncaughtException', 'exit'] + for state in ['running on a terminal', 'running on a pipe', 'inside a suspend', 'stopped and continued', 'mounted', 'mounted after a suspend'] + for way in ways.concat (if state.startsWith 'mounted' then ['close'] else []) + name = "#{state}, #{way}" + continue if state is 'inside a suspend' and way in ['Ctrl-C', 'a listener that throws'] # a key while suspended is nobody's + continue if state is 'running on a pipe' and way in ['Ctrl-C', 'a listener that throws'] # off a terminal no key is read + continue if state.startsWith('mounted') and way in ['Ctrl-C', 'a listener that throws', 'SIGTERM', 'uncaughtException', 'exit'] # no handler stands under mount: a throw is the test's, a signal the process's + stdin = Stdin.new() + term = Terminal.new() + pipe = Pipe.new() + if state.startsWith 'mounted' + view = mount Thrower + suspend! (-> null) if state is 'mounted after a suspend' + eq listeners(), base, name + if way is 'quit' then quit() else view.close() + await view.done + eq listeners(), base, name + continue + running = run Thrower, stdin: stdin, stdout: (if state is 'running on a pipe' then pipe else term) + settled = 'pending' + running.done.then (-> settled = 'resolved'), (-> settled = 'rejected') + if state is 'stopped and continued' + stdin.key '\x1a' + process.kill process.pid, 'SIGCONT' + sleep! 20 + eq stdin.raw, true, "#{name}: continued" + leave = -> + switch way + when 'quit' then quit() + when 'Ctrl-C' then stdin.key '\x03' + when 'a listener that throws' then stdin.key 'X' + when 'SIGTERM' then process.emit 'SIGTERM' + when 'uncaughtException' then process.emit 'uncaughtException', Error.new "crashed #{name}" + # Only this package's exit handler — the first, since it is + # prepended — not the process's whole event, which the test + # reporter listens to as well. + when 'exit' then process.listeners('exit')[0] 0 + if state is 'inside a suspend' then suspend! leave else leave() + sleep! 0 + eq listeners(), base, name + eq typeof document, 'undefined', name + switch way + when 'SIGTERM' + eq [exits, settled], [[143], 'resolved'], name + exits.length = 0 + when 'uncaughtException' + eq [seen, exits, settled], [["crashed #{name}"], [], 'rejected'], name + seen.length = 0 + when 'a listener that throws' then eq settled, 'rejected', name + else eq [settled, exits], ['resolved', []], name + process.off 'uncaughtException', user + process.exit = held + eq listeners(), before + +test! "a suspend under mount hands nothing over and puts no handler on the process, before or after the close", -> + before = listeners() + view = mount Echo + ran = false + suspend! -> ran = true + eq [ran, listeners()], [true, before] + view.close() + eq listeners(), before + +test! "a teardown is taken once: a quit while suspended withdraws nothing twice, and a close after a close writes nothing", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term, mouse: true + suspend! -> + quit() + await running.done + eq [term.since(0).split(OFF).length - 1, term.since(0).split(QUIET).length - 1, term.since(0).split(SHOW).length - 1, stdin.calls.filter((call) -> call is 'raw off').length], [1, 1, 1, 1] + eq term.cursor, { x: 0, y: 1 }, 'the cursor landed below the frame once' + mark = term.sent.length + running.quit() + sleep! 0 + eq term.sent.length, mark + +test! "a frame booked before a quit is drawn by the close, and nothing is drawn after it", -> + term = Terminal.new() + running = run Echo, stdin: Stdin.new(), stdout: term + running.app.label.value = 'last' + quit() + await running.done + ok term.text.endsWith('last'), JSON.stringify term.text + mark = term.sent.length + sleep! 30 + eq term.sent.length, mark + +test "a teardown after a setup that failed halfway takes the steps that were taken and no other", -> + before = listeners() + stdin = Stdin.new() + stdin.setRawMode = -> raise 'EBADF: setRawMode' + term = Terminal.new() + throws (-> run Echo, stdin: stdin, stdout: term, mouse: true, altScreen: true), 'EBADF' + eq [stdin.calls, stdin.listenerCount('data'), typeof document], [[], 0, 'undefined'] + ok not term.since(0).includes('1002') and not term.since(0).includes('1049'), JSON.stringify term.since 0 + ok term.shown, 'the cursor is shown again' + eq listeners(), before + +# ==[ Signals ]== + +console.log "\nSignals" + +for [name, code] in [['SIGTERM', 143], ['SIGHUP', 129], ['SIGINT', 130]] + test! "#{name} gives the terminal back — the mouse, the modes, the cursor below the frame, the progress cleared — and exits #{code}", -> + child = Child.new 'signal' + child.seen! "\x1b]9;4;1;50\x1b\\" + child.signal name + result = child.finish! + eq result.code, code + ok result.out.endsWith("#{QUIET}#{OFF}\n#{SHOW}#{CLEARED}"), JSON.stringify result.out.slice -60 + eq result.err, '' + +test! "an uncaught error gives the terminal back, prints the error, and exits 1", -> + child = Child.new 'throw' + { code, out, err } = child.finish! + eq code, 1 + ok err.includes('a timer failed'), err + ok out.endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -60 + +test! "an unhandled rejection is the same way out", -> + child = Child.new 'reject' + { code, out, err } = child.finish! + eq code, 1 + ok err.includes('a promise failed'), err + ok out.replace(/exited\n$/, '').endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -60 + +test! "an uncaught error while suspended: the app is closed, the error printed once, the cursor shown once, the exit 1", -> + child = Child.new 'throwsuspend' + { code, out, err } = child.finish! + eq code, 1 + eq [err.split('a timer failed while suspended').length - 1, out.split(SHOW).length - 1], [1, 1] + ok not err.includes('Maximum call stack'), err + ok out.endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -80 + +test! "an app's own uncaughtException handler sees the error once, with the terminal already given back, and its exit code stands", -> + child = Child.new 'userhandler' + { code, out, err } = child.finish! + eq [code, err], [5, 'handled once: a timer failed\n'] + ok out.endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -80 + +test! "a suspend under mount, then a close, leaves no handler behind: a later uncaught error is the runtime's, once", -> + child = Child.new 'mountsuspend' + { code, err } = child.finish! + eq code, 1 + eq err.split('a timer failed after the mount').length - 1, 1 + ok not err.includes('Maximum call stack'), err + +test! "a `process.exit` with the app live tears the terminal down on the way, and keeps its code", -> + child = Child.new 'exit' + { code, out } = child.finish! + eq code, 7 + ok out.endsWith("#{QUIET}#{OFF}\n#{SHOW}"), JSON.stringify out.slice -60 + +# ==[ Suspend and resume ]== + +console.log "\nSuspend and resume" + +Keys =! component + @keys := [] + render + Box focusable: true, autofocus: true, @keydown: ((event) => @keys.push event.key), @click: (=> @keys.push 'click') + Text "k" + +test! "suspend gives the terminal back — the flag popped, the mouse, paste and focus modes withdrawn, the cursor shown below the frame, stdin cooked — and takes it back: raw mode, the modes, the flag, the origin asked again, then a whole frame", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Keys, stdin: stdin, stdout: term, mouse: true, keyboard: 'enhanced' + stdin.key KITTY + stdin.key "#{CSI}?1;1R" + down(0, 0) + mark = term.sent.length + at = 0 + suspend! -> + eq term.since(mark), "#{POP}#{QUIET}#{OFF}\n#{SHOW}" + eq [stdin.calls.slice(-3), stdin.raw, stdin.listenerCount('data'), term.cursor, term.kitty], [['raw off', 'pause', 'unref'], false, 0, { x: 0, y: 1 }, 0] + at = term.sent.length + ok term.since(at).startsWith("#{HIDE}#{ASK}#{MOUSE}#{PUSH}#{PROBE}"), JSON.stringify term.since at + eq [stdin.calls.slice(-3), stdin.raw, stdin.listenerCount('data'), term.kitty], [['raw on', 'ref', 'resume'], true, 1, 1] + eq term.queries, ['cursor', 'keyboard', 'attributes', 'cursor'], 'the row is asked again; the keyboard, decided, is not' + sleep! 20 + ok term.since(at).includes(BEGIN), 'a frame is drawn again' + eq term.text, "k\nk", 'the frame stands again below the one left in the scrollback' + stdin.key "#{CSI}?2;1R" + up(0, 1) + eq running.app.keys.value, [], 'the press before the suspend is forgotten: its release is no click' + quit() + await running.done + +test! "Ctrl-Z is a default action of keydown: the terminal is given back and the app waits for SIGCONT — on a stdin of its own no signal is sent, the keys left in the read are nobody's, and the handlers stay; the continue takes the terminal back, asks the row again, and draws whole", -> + stopped = 0 + handler = -> stopped += 1 + process.on 'SIGTSTP', handler + before = listeners() + try + stdin = Stdin.new() + term = Terminal.new() + running = run Keys, stdin: stdin, stdout: term, mouse: true + stdin.key "#{CSI}?1;1R" + live = listeners() + mark = term.sent.length + stdin.key '\x1aab' + eq term.since(mark), "#{QUIET}#{OFF}\n#{SHOW}" + eq [stdin.raw, stdin.calls.slice(-3), running.app.keys.value], [false, ['raw off', 'pause', 'unref'], ['z']], 'a listener hears Ctrl-Z, as it hears Ctrl-C; what follows it in the read is dropped' + sleep! 20 + eq [stopped, process.listenerCount('SIGCONT'), listeners().slice(0, 3)], [0, live[3] + 1, live.slice(0, 3)], "no stop signal for a stream of one's own; the continue is waited for; the signal handlers stand" + stdin.key 'c' + eq running.app.keys.value, ['z'], "a key while stopped is nobody's" + at = term.sent.length + process.kill process.pid, 'SIGCONT' + sleep! 30 + ok term.since(at).startsWith("#{HIDE}#{ASK}#{MOUSE}#{PROBE}"), JSON.stringify term.since at + ok term.since(at).includes("#{BEGIN}#{CSI}J"), 'the frame is drawn whole' + eq [stdin.raw, term.text, process.listenerCount('SIGCONT')], [true, "k\nk", live[3]] + stdin.key 'd' + eq running.app.keys.value, ['z', 'd'] + quit() + await running.done + eq listeners(), before + finally + process.off 'SIGTSTP', handler + +test! "preventDefault on Ctrl-Z keeps the app on the terminal, and Ctrl-Z under mount is nothing", -> + Holds = component + render + Box focusable: true, autofocus: true, @keydown: ((event) -> event.preventDefault() if event.key is 'z' and event.ctrlKey) + Text "h" + stdin = Stdin.new() + term = Terminal.new() + running = run Holds, stdin: stdin, stdout: term + mark = term.sent.length + live = process.listenerCount 'SIGCONT' + stdin.key '\x1a' + sleep! 20 + eq [term.sent.length, stdin.raw, process.listenerCount('SIGCONT')], [mark, true, live] + quit() + await running.done + view = mount Keys + view.press 'z', ctrl: true + sleep! 20 + eq [process.listenerCount('SIGCONT'), view.app.keys.value], [live, ['z']] + view.close() + +test! "a key that arrives while suspended is nobody's, a resize writes nothing, and a log is the console's own", -> + recorded! (printed) -> + stdin = Stdin.new() + term = Terminal.new() + running = run Keys, stdin: stdin, stdout: term + at = 0 + suspend! -> + at = term.sent.length + stdin.key 'a' + term.columns = 60 + term.emit 'resize' + console.log 'while suspended' + eq [term.sent.slice(at), printed], [[], [['log', 'while suspended']]] + eq [running.app.keys.value, screen.cols], [[], 60] + sleep! 20 + eq term.text, "k\nk" + stdin.key 'b' + eq running.app.keys.value, ['b'] + quit() + await running.done + eq printed, [['log', 'while suspended']] + +test! "a suspend with no app running is refused by name", -> + refused = null + try + suspend! -> null + catch error + refused = error.message + ok /no app/.test(refused), refused + +# ==[ Job control ]== + +console.log "\nJob control" + +TORN =! "#{QUIET}#{OFF}\r\n#{SHOW}" # the teardown as the pty shows it: a line feed comes back as CR LF + +test! "Ctrl-Z under `rip app.rip`: the terminal is given back and the job — the launcher and the app — is stopped, as the shell sees it", -> + job = Pty.new [[0, '?count:0'], [200, '\x1a'], [400, '%fg'], [200, '\x03'], [400, '']] + { code, err, states, out } = job.finish! + eq [code, err], [0, ''] + eq states.map((event) -> Object.keys(event)[0]), ['started', 'stopped', 'continued', 'exited', 'end'], JSON.stringify states + eq states[1], { stopped: 18 }, 'the job leader is stopped by SIGTSTP' + ok job.before('stopped').endsWith(TORN), JSON.stringify job.before('stopped').slice -60 + +test! "fg: the app takes the terminal back, asks the row again, draws whole with no timer to keep it alive, and hears the next key", -> + job = Pty.new [[0, '?count:0'], [200, '\x1a'], [400, '%fg'], [400, 'x'], [300, '\x03'], [400, '']] + { code, states, out } = job.finish! + eq code, 0 + eq states[2], { continued: 1 } + after = out.slice job.before('continued').length + ok after.startsWith("#{HIDE}#{ASK}#{MOUSE}#{PROBE}#{BEGIN}#{CSI}J#{CSI}1Gcount:0\r#{END}"), JSON.stringify after.slice 0, 80 + ok after.includes('key:x') and out.includes('exit hook code=0'), JSON.stringify after + +test! "SIGTERM while stopped: pending until the continue, then the app is closed and the exit is 143, the app's exit hook run, no continuation of `done`", -> + job = Pty.new [[0, '?count:0'], [200, '\x1a'], [300, '@SIGTERM'], [300, '%fg'], [800, '']] + { code, states, out } = job.finish! + eq code, 0 + ok states.some((event) -> event.stopped is 18), JSON.stringify states + ok states.some((event) -> event.exited is 143), JSON.stringify states + ok out.includes('exit hook code=143') and not out.includes('exited\r\n'), JSON.stringify out.slice -120 + ok out.replace(/exit hook code=143\r\n$/, '').endsWith(TORN), JSON.stringify out.slice -80 + +test! "keys in the read with Ctrl-Z are nobody's, and a key after the continue is heard", -> + job = Pty.new [[0, '?count:0'], [200, '\x1aab'], [400, '%fg'], [400, 'y'], [300, '\x03'], [400, '']] + { code, out } = job.finish! + eq code, 0 + eq (out.match(/key:\w+/g) ?? []), ['key:z', 'key:y', 'key:c'] + +test! "stopped and continued twice, then Ctrl-C: the shell sees two stops and two continues, and a clean exit", -> + job = Pty.new [[0, '?count:0'], [200, '\x1a'], [400, '%fg'], [400, '\x1a'], [400, '%fg'], [400, '\x03'], [400, '']] + { code, states } = job.finish! + eq code, 0 + eq states.map((event) -> Object.keys(event)[0]), ['started', 'stopped', 'continued', 'stopped', 'continued', 'exited', 'end'], JSON.stringify states + eq states[5], { exited: 0 } + +# ==[ The alternate screen ]== + +console.log "\nThe alternate screen" + +test! "altScreen enters the alternate screen after the modes, at its top-left, with no cursor probe: the frame is absolute", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term, mouse: true, keyboard: 'enhanced', altScreen: true + ok term.since(0).startsWith(ON + MOUSE + ENTER + QUERY), JSON.stringify term.since 0 + eq [term.queries, term.modes.has(1049), term.lines[0]?.join(''), term.y], [['keyboard', 'attributes'], true, 'x', 0] + eq screen.interactive, true + quit() + await running.done + +test! "quit leaves the alternate screen after the last frame is drawn, and the frame vanishes with it: the screen shows what it did before, the cursor where it was", -> + stdin = Stdin.new() + term = Terminal.new() + term.write 'prompt$ ' + running = run Echo, stdin: stdin, stdout: term, mouse: true, altScreen: true + running.app.label.value = 'last' + mark = term.sent.length + quit() + await running.done + out = term.since mark + ok out.startsWith("#{QUIET}#{OFF}#{BEGIN}"), JSON.stringify out + ok out.endsWith("#{END}#{SHOW}#{LEAVE}"), JSON.stringify out + ok out.includes('last'), 'the last frame is drawn, on the alternate screen' + eq [term.text, term.cursor, term.modes.has(1049)], ['prompt$', { x: 8, y: 0 }, false] + +test! "a resize under the alternate screen repaints whole, with no probe", -> + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term, mouse: true, altScreen: true + mark = term.sent.length + term.columns = 60 + term.emit 'resize' + sleep! 20 + ok term.since(mark).includes("#{BEGIN}#{CSI}J"), JSON.stringify term.since mark + eq [term.queries, screen.cols, term.lines[0]?.join('')], [[], 60, 'x'] + quit() + await running.done + +test! "a mouse report under the alternate screen lands on the row it names: the origin is the top", -> + stdin = Stdin.new() + running = run Ways, stdin: stdin, stdout: Terminal.new(), mouse: true, altScreen: true + stdin.key down(0, 0) + up(0, 0) + eq running.app.heard.value, ['click'] + quit() + await running.done + +test! "a log under the alternate screen is kept, and replayed once the screen is left", -> + recorded! (printed) -> + stdin = Stdin.new() + term = Terminal.new() + err = Pipe.new() + running = run Echo, stdin: stdin, stdout: term, stderr: err, altScreen: true + mark = term.sent.length + console.log 'one' + console.error 'two' + console.warn 'three' + console.table [{ a: 1 }] + eq [term.sent.length, err.sent, term.lines[0]?.join('')], [mark, [], 'x'] + quit() + await running.done + ok term.since(mark).endsWith("#{SHOW}#{LEAVE}one\n┌─────────┬───┐\n│ (index) │ a │\n├─────────┼───┤\n│ 0 │ 1 │\n└─────────┴───┘\n"), JSON.stringify term.since mark + eq [err.sent, term.text.split('\n')[0], printed], [['two\n', 'three\n'], 'one', []] + +Steps =! component + @done := [] + render + Box flexDirection: 'column' + Static + for name in @done + Text key: name, "✓ #{name}" + Text "live" + +test! "a Static item and a print under the alternate screen write nothing, above or in the frame, and the primary screen is as it was", -> + stdin = Stdin.new() + term = Terminal.new() + term.write 'prompt$ ' + running = run Steps, stdin: stdin, stdout: term, altScreen: true + running.app.done.value = ['one', 'two'] + print 'aside' + running.flush() + sleep! 20 + eq [term.text, term.saved.lines.length], ['live', 1] + ok not term.since(0).includes('one') and not term.since(0).includes('aside'), JSON.stringify term.since 0 + quit() + await running.done + eq term.text, 'prompt$' + +test! "examples/log.rip under the alternate screen: the steps done are nothing, the live line turns, and the screen comes back as it was", -> + term = Terminal.new() + running = run Log, stdin: Stdin.new(), stdout: term, altScreen: true + sleep! 800 + ok term.text.includes('fetch') and term.text.includes('1/6'), JSON.stringify term.text + ok not term.since(0).includes('✓ resolve'), 'the step done was written nowhere' + ok term.since(0).includes("\x1b]9;4;1;17\x1b\\"), 'the terminal knows how far along' + quit() + await running.done + eq [term.text, term.modes.has(1049)], ['', false] + ok term.since(0).endsWith(CLEARED + LEAVE), JSON.stringify term.since(0).slice -40 + +test! "altScreen on a stdout that is no terminal is nothing", -> + pipe = Pipe.new() + running = run Echo, stdin: Stdin.new(), stdout: pipe, altScreen: true + quit() + await running.done + eq pipe.text, 'x\n' + +# ==[ Output that is no terminal ]== + +console.log "\nOutput that is no terminal" + +test! "on a stdout that is no terminal nothing is asked — no modes, no cursor, no probe, no raw mode even on a stdin that is one — the frames are kept, and the last is written once at exit", -> + pipe = Pipe.new() + stdin = Stdin.new() + running = run Echo, stdin: stdin, stdout: pipe, mouse: true, keyboard: 'enhanced' + eq [stdin.calls, stdin.listenerCount('data'), pipe.sent, screen.interactive, screen.cols], [[], 0, [], false, 40] + for label in ['one', 'two', 'three'] + running.app.label.value = label + sleep! 20 + eq pipe.sent, [] + quit 'done' + eq await running.done, 'done' + eq pipe.sent, ['three\n'] + stdin.key 'a' + eq running.app.keys.value, [] + +test! "off a terminal a Static item and a print are written as they arrive, and the last frame once at exit", -> + pipe = Pipe.new() + running = run Steps, stdin: Stdin.new(), stdout: pipe + eq pipe.sent, [] + running.app.done.value = ['one'] + running.flush() + eq pipe.sent, ['✓ one\n'] + print 'aside' + running.app.done.value = ['one', 'two'] + running.flush() + eq pipe.sent, ['✓ one\n', 'aside\n', '✓ two\n'] + quit() + await running.done + eq pipe.text, "✓ one\naside\n✓ two\nlive\n" + +test! "examples/log.rip off a terminal: the clock never runs, and the first line is written once at exit", -> + pipe = Pipe.new() + running = run Log, stdin: Stdin.new(), stdout: pipe + sleep! 200 + eq pipe.sent, [] + quit() + await running.done + eq pipe.text, "⠋ resolve [░░░░░░░░░░░░] 0/6\n" + +test! "CI set makes a terminal stdout the same, and CI=false does not", -> + process.env.CI = '1' + try + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term + eq [screen.interactive, stdin.calls, term.sent], [false, [], []] + quit() + await running.done + eq term.sent, ['x\n'] + process.env.CI = 'false' + term = Terminal.new() + running = run Echo, stdin: Stdin.new(), stdout: term + eq screen.interactive, true + ok term.since(0).startsWith(ON) + quit() + await running.done + finally + delete process.env.CI + +test! "`screen.interactive` reads true on a terminal, false on a stdout that is no terminal, and true under mount", -> + running = run Echo, stdin: Stdin.new(), stdout: Terminal.new() + eq screen.interactive, true + quit() + await running.done + running = run Echo, stdin: Stdin.new(), stdout: Pipe.new() + eq screen.interactive, false + quit() + await running.done + view = mount Echo + eq screen.interactive, true + view.close() + +test! "the last frame is written once through a real pipe", -> + child = Child.new 'pipe' + { code, out, err } = child.finish! + eq [code, err, out], [0, '', "count:5\nexited\n"] + +# ==[ Colors ]== + +console.log "\nColors" + +# `screen.colors` after a run under `env`, on `out`. +depthUnder =! (env, out = Terminal.new()) -> + held = {} + for name in ['NO_COLOR', 'FORCE_COLOR', 'TERM', 'COLORTERM'] + held[name] = process.env[name] + delete process.env[name] + process.env[name] = value for name, value of env + try + running = run Echo, stdin: Stdin.new(), stdout: out + depth = screen.colors + quit() + await running.done + depth + finally + for name, value of held + if value? then process.env[name] = value else delete process.env[name] + +test! "the depth is read once at run: NO_COLOR set and not empty is none; FORCE_COLOR 0 or false none, empty or true the 16, a number that depth up to 3, any other word the 16; else no terminal or a dumb one has none, and TERM and COLORTERM decide", -> + rows = [ + [{ NO_COLOR: '1' }, 0] + [{ NO_COLOR: '' }, 16] + [{ NO_COLOR: '', COLORTERM: 'truecolor' }, 16777216] + [{ NO_COLOR: '1', FORCE_COLOR: '3' }, 0] + [{ FORCE_COLOR: '0' }, 0] + [{ FORCE_COLOR: 'false' }, 0] + [{ FORCE_COLOR: '' }, 16] + [{ FORCE_COLOR: 'true' }, 16] + [{ FORCE_COLOR: 'yes' }, 16] + [{ FORCE_COLOR: '1' }, 16] + [{ FORCE_COLOR: '2' }, 256] + [{ FORCE_COLOR: '3' }, 16777216] + [{ FORCE_COLOR: '4' }, 16777216] + [{ FORCE_COLOR: '256' }, 16777216] + [{ FORCE_COLOR: 'true', TERM: 'dumb' }, 16] + [{ TERM: 'xterm-256color' }, 256] + [{ TERM: 'screen-256color' }, 256] + [{ TERM: 'xterm-256color', COLORTERM: 'truecolor' }, 16777216] + [{ TERM: 'xterm', COLORTERM: '24bit' }, 16777216] + [{ TERM: 'xterm' }, 16] + [{}, 16] + [{ TERM: 'dumb' }, 0] + [{ TERM: 'dumb', COLORTERM: 'truecolor' }, 0] + ] + for [env, want] in rows + depth = depthUnder! env + eq depth, want, JSON.stringify env + depth = depthUnder! { TERM: 'xterm-256color' }, Pipe.new() + eq depth, 0, 'a pipe' + depth = depthUnder! { FORCE_COLOR: '2' }, Pipe.new() + eq depth, 256, 'a pipe, forced' + +Tinted =! component + render + Box flexDirection: 'row' + Text color: '#ff0000', backgroundColor: '#0000ff', bold: true + "r" + Text color: 'green' + "g" + Text color: '#808080' + "h" + +# The frame `run` draws under `env`, on a terminal, with its escapes. +paintedUnder =! (env) -> + held = {} + for name in ['NO_COLOR', 'FORCE_COLOR', 'TERM', 'COLORTERM'] + held[name] = process.env[name] + delete process.env[name] + process.env[name] = value for name, value of env + try + term = Terminal.new() + running = run Tinted, stdin: Stdin.new(), stdout: term + quit() + await running.done + term.since 0 + finally + for name, value of held + if value? then process.env[name] = value else delete process.env[name] + +test! "a 24-bit color is sent as it is at full depth, as the nearest of the 256 at that depth, as the nearest of the 16 below it, and nothing is sent at none", -> + full = paintedUnder! { FORCE_COLOR: '3' } + ok full.includes("#{CSI}1;38;2;255;0;0;48;2;0;0;255mr") and full.includes("#{CSI}0;32mg") and full.includes("#{CSI}0;38;2;128;128;128mh"), JSON.stringify full + eq screen.colors, 16777216 + cube = paintedUnder! { FORCE_COLOR: '2' } + ok cube.includes("#{CSI}1;38;5;196;48;5;21mr") and cube.includes("#{CSI}0;32mg") and cube.includes("#{CSI}0;38;5;244mh"), JSON.stringify cube + named = paintedUnder! { FORCE_COLOR: '1' } + ok named.includes("#{CSI}1;91;44mr") and named.includes("#{CSI}0;32mg") and named.includes("#{CSI}0;90mh"), JSON.stringify named + none = paintedUnder! { NO_COLOR: '1' } + ok not /\x1b\[[0-9;]*m/.test(none), JSON.stringify none + ok plain(none).includes('rgh'), JSON.stringify none + +# xterm's sixteen, then points the cube, the grays and the palette +# disagree on: each as the nearest of the 256, and of the 16. +POINTS =! [ + ['#000000', 16, 30], ['#cd0000', 160, 31], ['#00cd00', 40, 32], ['#cdcd00', 184, 33] + ['#0000ee', 21, 34], ['#cd00cd', 164, 35], ['#00cdcd', 44, 36], ['#e5e5e5', 254, 37] + ['#7f7f7f', 244, 90], ['#ff0000', 196, 91], ['#00ff00', 46, 92], ['#ffff00', 226, 93] + ['#5c5cff', 63, 94], ['#ff00ff', 201, 95], ['#00ffff', 51, 96], ['#ffffff', 231, 97] + ['#808080', 244, 90], ['#5f5f5f', 59, 90], ['#bcbcbc', 250, 37], ['#eeeeee', 255, 37] + ['#f8f8f8', 231, 97], ['#ff5f00', 202, 91], ['#875faf', 97, 90], ['#870000', 88, 31] + ['#5f0000', 52, 30], ['#d7d7d7', 188, 37], ['#afafaf', 145, 90], ['#404040', 238, 90] + ['#585858', 240, 90], ['#767676', 243, 90], ['#4d4d4d', 239, 90], ['#8a8a8a', 245, 90] +] + +Swatches =! component + render + Box flexDirection: 'column' + for point in POINTS + Text key: point[0], color: point[0], "x" + +# The SGR parameters before each swatch, in order, under `env`. +swatchesUnder =! (env) -> + held = {} + for name in ['NO_COLOR', 'FORCE_COLOR', 'TERM', 'COLORTERM'] + held[name] = process.env[name] + delete process.env[name] + process.env[name] = value for name, value of env + try + term = Terminal.new 40, 40 + running = run Swatches, stdin: Stdin.new(), stdout: term + quit() + await running.done + Array.from term.since(0).matchAll(/\x1b\[0?;?([0-9;]+)mx/g), (m) -> m[1] + finally + for name, value of held + if value? then process.env[name] = value else delete process.env[name] + +test! "every color is the nearest of xterm's own table: its sixteen, a color on the cube as that point, a gray as the nearest gray or cube point, and below that the nearest of the sixteen — mid-grays bright black, never white", -> + at256 = swatchesUnder! { FORCE_COLOR: '2' } + eq at256, ("38;5;#{n}" for [hex, n] in POINTS) + at16 = swatchesUnder! { FORCE_COLOR: '1' } + eq at16, (String(n) for [hex, c, n] in POINTS) + ansi = swatchesUnder! { FORCE_COLOR: '1', COLORTERM: 'truecolor' } + eq ansi, at16, 'FORCE_COLOR is read before the terminal' + +test! "an ansi256 color below the 256 is the nearest of the sixteen by its own RGB", -> + Indexed = component + render + Box flexDirection: 'row' + Text color: 'ansi256(244)', "a" + Text color: 'ansi256(196)', "b" + Text color: 'ansi256(21)', "c" + Text color: 'ansi256(7)', "d" + held = process.env.FORCE_COLOR + process.env.FORCE_COLOR = '1' + try + term = Terminal.new() + running = run Indexed, stdin: Stdin.new(), stdout: term + quit() + await running.done + eq Array.from(term.since(0).matchAll(/\x1b\[0?;?([0-9;]+)m[abcd]/g), (m) -> m[1]), ['90', '91', '34', '37'] + finally + if held? then process.env.FORCE_COLOR = held else delete process.env.FORCE_COLOR + +test "mount and renderToString stay at full depth whatever the environment says", -> + held = process.env.NO_COLOR + process.env.NO_COLOR = '1' + try + ok renderToString(Tinted, cols: 10, ansi: true).includes('38;2;255;0;0') + eq screen.colors, 16777216 + finally + if held? then process.env.NO_COLOR = held else delete process.env.NO_COLOR + +# ==[ The console ]== + +console.log "\nThe console" + +Field =! component + render + Box focusable: true, autofocus: true, cursor: { x: 1, y: 0 } + Text "field" + +test! "a log during a run clears the frame, writes the line as it was, and draws the frame again below it, the cursor parked where it was — one write, with the frame the line books", -> + recorded! (printed) -> + stdin = Stdin.new() + term = Terminal.new() + running = run Field, stdin: stdin, stdout: term + eq term.cursor, { x: 1, y: 0 } + mark = term.sent.length + console.log 'hello', 42 + eq term.sent.length, mark, 'nothing goes out until the frame' + sleep! 20 + out = term.since mark + eq term.sent.length, mark + 1, 'the clear, the line and the frame are one write' + ok out.startsWith("#{BEGIN}#{HIDE}\r#{CSI}Jhello 42\n#{CSI}J"), JSON.stringify out + ok out.endsWith("#{CSI}2G#{SHOW}#{END}"), JSON.stringify out + eq [term.text, term.cursor, printed], ["hello 42\nfield", { x: 1, y: 1 }, []] + console.info 'two' + console.debug 'three' + running.flush() + eq term.text, "hello 42\ntwo\nthree\nfield" + quit() + await running.done + +test! "warn and error go to stderr, the frame cleared for them and drawn again", -> + recorded! (printed) -> + term = Terminal.new() + err = Pipe.new() + running = run Echo, stdin: Stdin.new(), stdout: term, stderr: err + mark = term.sent.length + console.error 'bad', { n: 1 } + console.warn 'worse' + eq [err.sent, term.since(mark)], [['bad { n: 1 }\n', 'worse\n'], "#{CSI}J#{CSI}J"], 'the clear goes out at once, the line to stderr' + running.flush() + ok term.since(mark).startsWith("#{CSI}J#{CSI}J#{BEGIN}#{CSI}J"), JSON.stringify term.since mark + eq [term.text, printed], ['x', []] + quit() + await running.done + +test! "an uncaught error closes the app with it — `done` rejects — and is left to the listeners after this package's, once; a signal closes the app and exits with 128 plus its number, `done` resolved", -> + before = listeners() + seen = [] + user = (error) -> seen.push error.message + exits = [] + held = process.exit + process.exit = (code) -> exits.push code + try + for name in ['uncaughtException', 'unhandledRejection'] + process.on name, user + stdin = Stdin.new() + term = Terminal.new() + running = run Echo, stdin: stdin, stdout: term + failure = null + running.done.catch (error) -> failure = error.message + process.emit name, Error.new "a #{name}" + sleep! 0 + eq [seen, failure, exits, listeners()], [["a #{name}"], "a #{name}", [], before.map((n, i) -> if HANDLED[i] is name then n + 1 else n)], name + every name, term, stdin, before.map (n, i) -> if HANDLED[i] is name then n + 1 else n + process.off name, user + seen.length = 0 + term = Terminal.new() + running = run Echo, stdin: Stdin.new(), stdout: term + result = 'pending' + running.done.then (-> result = 'resolved'), (-> result = 'rejected') + process.emit 'SIGTERM' + sleep! 0 + eq [exits, result, listeners()], [[143], 'resolved', before] + ok term.text.endsWith('x') and term.shown, 'the last frame stands, the cursor shown' + finally + process.exit = held + process.off name, user for name in ['uncaughtException', 'unhandledRejection'] + +test! "every console method that writes takes the road: a table, a group's indent, an assert and a trace to stderr, a count, a time", -> + recorded! (printed) -> + term = Terminal.new() + err = Pipe.new() + running = run Echo, stdin: Stdin.new(), stdout: term, stderr: err + console.table [{ a: 1 }] + console.group 'G' + console.log 'in' + console.groupEnd() + console.count 'c' + console.time 't' + console.timeEnd 't' + console.assert false, 'nope' + console.trace 'here' + running.flush() + ok term.text.startsWith("┌─────────┬───┐\n│ (index) │ a │\n├─────────┼───┤\n│ 0 │ 1 │\n└─────────┴───┘\nG\n in\nc: 1\nt: "), JSON.stringify term.text + ok term.text.endsWith('\nx'), 'the frame stands below' + eq err.sent.length, 2 + eq err.sent[0], 'Assertion failed: nope\n' + ok err.sent[1].startsWith("Trace: here\n at "), JSON.stringify err.sent[1] + ok err.sent[1].includes('test/terminal.rip'), 'the stack begins at the caller' + eq printed, [] + quit() + await running.done + ok console.table isnt running.table and typeof console.table is 'function' + +test! "the console is the process's own again on every way out, and a log with the app closed passes straight through", -> + recorded! (printed) -> + stdin = Stdin.new() + term = Terminal.new() + running = run Ways, stdin: stdin, stdout: term + patched = console.log + stdin.key 'X' + try + await running.done + catch error + null + ok console.log isnt patched, 'the patch is gone' + mark = term.sent.length + console.log 'after' + eq [printed, term.sent.length], [[['log', 'after']], mark] + +test! "console: false leaves the console alone", -> + recorded! (printed) -> + term = Terminal.new() + running = run Echo, stdin: Stdin.new(), stdout: term, console: false + mark = term.sent.length + console.log 'through' + eq [printed, term.sent.length, term.text], [[['log', 'through']], mark, 'x'] + quit() + await running.done + +test! "with the mouse on, the frame's row moves down by the lines a log writes, with no probe: a click lands where the frame is now", -> + recorded! (printed) -> + stdin = Stdin.new() + term = Terminal.new() + running = run Ways, stdin: stdin, stdout: term, mouse: true + stdin.key "#{CSI}?4;1R" + console.log "two\nlines" + running.flush() + console.log 'three' + running.flush() + eq term.queries, ['cursor'], 'one probe, at the start; a log moves the row by what it wrote' + stdin.key down(0, 3) + up(0, 3) + stdin.key down(0, 5) + up(0, 5) + eq running.app.heard.value, [], 'the rows the frame was on are nothing now' + stdin.key down(0, 6) + up(0, 6) + eq running.app.heard.value, ['click'] + quit() + await running.done + +test! "a log on a stdout that is no terminal passes straight through", -> + recorded! (printed) -> + pipe = Pipe.new() + running = run Echo, stdin: Stdin.new(), stdout: pipe + console.log 'plain' + eq [printed, pipe.sent], [[['log', 'plain']], []] + quit() + await running.done + +# ==[ Fuzz ]== + +console.log "\nFuzz" + +ROUNDS =! 40 +STEPS =! 20 + +# A seeded generator, so a failure names its seed. +rnd = null +seeded =! (seed) -> + state = seed >>> 0 + -> + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + state / 4294967296 + +pick =! (list) -> list[Math.floor rnd() * list.length] + +Fuzzed =! component + @keys := [] + @label := 'f' + render + Box focusable: true, autofocus: true, @keydown: ((event) => if event.key is 'X' then raise 'a listener failed' else @keys.push event.key) + Text "#{@label}" + +# The modes a run believes it holds, against the terminal's. +same =! (name, term, stdin, believe) -> + got = { raw: stdin.raw, paste: term.modes.has(2004), focus: term.modes.has(1004), mouse: term.modes.has(1006), kitty: term.kitty is 1, alt: term.modes.has(1049), cursor: term.shown } + eq got, believe, name + +test! "random keys, resizes, logs and suspends, then a random way out: the terminal's modes are what the app believes after every step, and every exit leaves everything off, the cursor shown, below the frame", -> + seed = Math.floor Math.random() * 1000000 + rnd = seeded seed + steps = 0 + exits = 0 + suspends = 0 + logs = 0 + ignored = 0 + recorded! (printed) -> + for round in [0...ROUNDS] + alt = rnd() < 0.3 + mouse = pick [false, true, 'all'] + enhanced = rnd() < 0.5 + term = Terminal.new() + stdin = Stdin.new() + before = listeners() + running = run Fuzzed, stdin: stdin, stdout: term, mouse: mouse, keyboard: (if enhanced then 'enhanced' else 'basic'), altScreen: alt + logged = [] + believe = { raw: true, paste: true, focus: true, mouse: mouse isnt false, kitty: false, alt, cursor: false } + if enhanced and rnd() < 0.6 + stdin.key KITTY + believe.kitty = true + name = "seed #{seed}, round #{round}" + same name, term, stdin, believe + for n in [0...STEPS] + steps += 1 + switch pick ['key', 'key', 'resize', 'log', 'suspend'] + when 'key' + key = pick ['a', 'b', '\t', "#{CSI}A"] + stdin.key key + when 'resize' + term.columns = pick [30, 40, 60] + term.emit 'resize' + when 'log' + logs += 1 + console.log "log #{n}" + logged.push "log #{n}" + unless alt + running.flush() + ok term.text.includes("log #{n}") and term.text.endsWith('f'), "#{name}: after a log the screen shows #{JSON.stringify term.text}" + when 'suspend' + suspends += 1 + heard = running.app.keys.value.length + suspend! -> + same "#{name}, suspended", term, stdin, { raw: false, paste: false, focus: false, mouse: false, kitty: false, alt: false, cursor: true } + if rnd() < 0.5 + stdin.key 'a' + ignored += 1 + term.emit 'resize' if rnd() < 0.5 + at = term.sent.length + console.log 'aside' if rnd() < 0.5 + eq term.sent.slice(at), [], "#{name}: nothing is written while suspended" + eq running.app.keys.value.length, heard, "#{name}: a key while suspended reached the app" + same "#{name}, step #{n}", term, stdin, believe + way = pick ['quit', 'Ctrl-C', 'throw', 'suspend and quit'] + switch way + when 'quit' then quit() + when 'Ctrl-C' then stdin.key '\x03' + when 'throw' then stdin.key 'X' + when 'suspend and quit' then suspend -> quit() + try + await running.done + catch error + eq error.message, 'a listener failed', name + exits += 1 + every "#{name}, #{way}", term, stdin, before + if alt + if logged.length + ok term.text.endsWith(logged[logged.length - 1]), "#{name}, #{way}: the logs are replayed once the alternate screen is left: #{JSON.stringify term.text}" + else + eq [term.text, term.cursor], ['', { x: 0, y: 0 }], "#{name}, #{way}: the alternate screen is left as it was found" + else if way isnt 'throw' + # A failure leaves whatever reached the screen; every other way + # draws what is owed and lands the cursor below it. + ok term.text.endsWith('f'), "#{name}, #{way}: the last frame stands" + eq term.cursor.y, term.lines.length, "#{name}, #{way}: the cursor is below the frame" + eq printed.filter((line) -> line[1] isnt 'aside'), [], "a log while suspended is the console's own, and no other reached it" + ok steps >= ROUNDS * STEPS, "#{steps} steps" + ok exits is ROUNDS, "#{exits} exits" + ok suspends >= 80, "#{suspends} suspends" + ok logs >= 80, "#{logs} logs" + ok ignored >= 20, "#{ignored} keys ignored while suspended" diff --git a/packages/tui/test/terminal/SOURCE.md b/packages/tui/test/terminal/SOURCE.md new file mode 100644 index 00000000..177d3d8b --- /dev/null +++ b/packages/tui/test/terminal/SOURCE.md @@ -0,0 +1,170 @@ +# Ported: Ink's suspend, exit, error, console and CI tests + +- **Upstream:** [vadimdemedes/ink](https://github.com/vadimdemedes/ink) +- **Path:** `test/suspend-terminal.tsx`, `test/suspension-exit.tsx`, + `test/suspension-output.tsx`, `test/suspension-resize.tsx`, + `test/suspension-handle.tsx`, `test/suspension-input-disable.tsx`, + the two suspension rows of `test/kitty-negotiation.tsx`, + `test/exit.tsx`, `test/errors.tsx`, `test/error-overview.tsx`, + `test/alternate-screen-example.tsx`, the console rows of + `test/render.tsx` and the CI rows of `test/components.tsx`, read + from the checkout at `misc/ink` +- **Commit:** `02ae1e59e7c288e971616100c4c11bcca6b5761b` (Ink's main + branch, ahead of the published 7.1.1) — the commit `test/ink/`, + `test/input/`, `test/events/` and `test/mouse/` port from +- **State:** ported, not vendored. None of Ink's files is copied here; + `test/terminal.rip` redraws each case as a Rip component under Ink's + own test title and takes the way out Ink's test takes. +- **License:** MIT, © Vadym Demedes and Sindre Sorhus. The text is at + the foot of `test/ink/SOURCE.md`. + +The lifecycle is written from the xterm control-sequence reference +(DECTCEM `CSI ? 25`, bracketed paste `CSI ? 2004`, focus reports +`CSI ? 1004`, the mouse modes, the alternate screen `CSI ? 1049`, +DECXCPR `CSI ? 6 n`), the kitty keyboard protocol's push and pop, the +POSIX meaning of SIGTSTP and SIGCONT, and Bun's and Node's `process` +documentation for signals, `uncaughtException`, `beforeExit` and +`exit`. Ink's `ink.tsx`, `log-update.ts` and `App.tsx` were read for +what its tests mean; its ref-counted raw mode, its console patch and +its throttled log-update are not carried over. + +## What runs + +Of the 70 titles in these files, 27 are held, 1 differs, and 42 are +left out. A title is counted once per case Ink registers, so a `test(` +site inside a loop counts once per turn of the loop; both columns are +given. `test/terminal.rip` holds `harness.rip`'s tally to these counts. + +| Ink file | sites | titles | held | differs | left out | +|---|---|---|---|---|---| +| `suspend-terminal` | 11 | 14 | 8 | 0 | 6 | +| `suspension-exit` | 1 | 2 | 1 | 0 | 1 | +| `suspension-output` | 2 | 3 | 1 | 0 | 2 | +| `suspension-resize` | 1 | 2 | 2 | 0 | 0 | +| `suspension-handle` | 1 | 2 | 0 | 0 | 2 | +| `suspension-input-disable` | 6 | 7 | 0 | 0 | 7 | +| `kitty-negotiation` (suspension rows) | 2 | 2 | 1 | 1 | 0 | +| `exit` | 15 | 15 | 9 | 0 | 6 | +| `errors` | 8 | 8 | 1 | 0 | 7 | +| `error-overview` | 7 | 7 | 0 | 0 | 7 | +| `alternate-screen-example` | 2 | 2 | 0 | 0 | 2 | +| `render` (console rows) | 1 | 2 | 2 | 0 | 0 | +| `components` (CI rows) | 4 | 4 | 2 | 0 | 2 | + +The suspension rows of `kitty-negotiation` were left out of +`test/mouse/SOURCE.md` for this file; `waitUntilExit preserves the +original component error` is pinned in `test/events.rip` and left out +here. + +## What is compared + +Ink's `useApp().suspendTerminal(fn)` is `suspend fn` here: both give +the terminal back, run `fn`, and take it again with a whole redraw. +Ink's `unmount()` and `exit(value)` are both `quit value`; its +`waitUntilExit()` is `done`; its `patchConsole` option is `console`; +its `interactive: false` is a stdout that is no terminal, or `CI` +set. A case Ink runs on a pty runs here in a spawned `rip` process +whose stdout says it is a terminal and writes through to the pipe +(`test/terminal/child.rip`), so the bytes are read as a pty would show +them and the exit code is the process's own. + +| Ink | here | +|---|---| +| `suspendTerminal(async () => …)` | `suspend! -> …` | +| `stdin.setRawMode` calls | `Stdin.calls` (`harness.rip`) | +| `stdout.getWrites()` | `Terminal.sent`, `Terminal.since` | +| `[?25h` / `[?25l` in a write | the same bytes | +| `[?1049h` / `[?1049l` | the same bytes; `CSI H` follows the entry | +| `alternateScreen: true` | `altScreen: true` | +| `interactive: false` | `stdout` with no `isTTY`, or `CI` set | +| `patchConsole: false` | `console: false` | +| `process.exit`, a fixture's exit code | `Child.finish` in a spawned process | + +## Held + +- `suspend-terminal`: hands the terminal to the callback, then + restores Ink; restores the terminal even if the callback throws; + keeps Ink off the terminal while suspended; runs the callback but + skips the handoff when not interactive; rejects a nested suspend + while already suspended; hands the terminal to a child process, then + redraws (PTY) — the "child" writes to the same fake terminal, and + the frame is drawn again below what it wrote; exits and re-enters + the alternate screen; rolls back so a later suspend works if + handover throws +- `suspension-exit`: resuming after unmount does not re-enable + terminal input (callback: true) +- `suspension-output`: non-interactive suspension keeps the latest + rendered frame +- `suspension-resize`: resize to 80 / 120 columns does not write while + suspended +- `kitty-negotiation`: unmount while suspended does not pop the primary + keyboard stack +- `exit`: exit normally without unmount() or exit(); exit when app + finishes execution — both by the same spawned process, whose loop + drains and whose app is closed on `beforeExit`; exit on unmount(); + exit on exit(); exit on exit() with raw mode; exit on unmount() with + raw mode — raw mode is always on here, so the four are one `quit`; + exit on exit() with result value; exit on exit() with object result; + exit with thrown error — `run` throws it +- `errors`: clean up raw mode when error is thrown — an uncaught error + in a spawned process, which leaves the modes withdrawn and exits 1 +- `render`: intercept console methods with omitted / undefined + patchConsole option +- `components`: render only last frame when run in CI; render all + frames if CI environment variable equals false + +## Stated differences + +- `kitty-negotiation`: suspension cancels pending keyboard negotiation + — an answer that arrives while suspended reaches nobody, as in Ink; + but the question is asked again on resume, so a terminal that + answers it then is pushed to, where Ink cancels the negotiation for + the rest of the run + +## Left out + +- `suspend-terminal`: returns a disposable that resumes on resume(); + disposable resumes via Symbol.asyncDispose — `suspend` takes the + function to run and resumes when it settles; there is no handle +- `suspend-terminal`: shows `` output once after resume + re-enters the alternate screen (four modes) — `Static` is a + documented no-op on the alternate screen (PLAN §6) +- `suspension-exit`: callback: false — a handle +- `suspension-output`: non-interactive suspension preserves stdout / + stderr writes — Ink's `useStdout().write`; a write to a stream is + the stream's own here +- `suspension-handle`: both — handles +- `suspension-input-disable`: all seven — Ink's input hooks each own a + share of raw mode and of bracketed paste, and a resume restores the + shares that still have an owner; the host owns the terminal for the + app's life here, with no share to disable (PLAN §8) +- `exit`: exit on exit() with error; with error with value property; + with raw mode with error — `quit` takes a result and never an error; + an app that fails throws, and `done` rejects with what it threw + (`test/events.rip`) +- `exit`: don't exit while raw mode is active — the process is kept + alive by a real stdin's `ref`, which a fake stdin has no way to show +- `exit`: exit when DEV is set — React's development flag +- `exit`: exit on exit() with error and static output — `Static`, and + an error result +- `errors`: catch and display error; ErrorBoundary catches and + displays nested component errors — the error overview is a deferred + reporter (PLAN §13); display thrown strings and reject waitUntilExit; + display thrown undefined — the same; does not emit unhandledRejection + when render exits with an error and waitUntilExit is unused — `done` + is always handed back, and a rejection nobody awaits is the caller's; + waitUntilExit preserves the original component error — + `test/events.rip` pins that `done` rejects with what a listener threw; + waitUntilExit preserves a component error from another realm — a + `vm` context; an error is whatever object was thrown here +- `error-overview`: all seven, "does not emit duplicate key warnings + for repeated stack lines" among them — the deferred reporter; stacks + are the runtime's own (`src/cli/run.js`) +- `alternate-screen-example`: both — the snake game's reducer, and a + fixture that prints its state; the alternate screen's bytes and the + leave order are pinned by this package's own rows ("The alternate + screen" in `test/terminal.rip`) +- `components`: debug mode in CI does not replay final frame during + unmount teardown; debug mode in CI keeps final newline separation + after waitUntilExit — Ink's + `debug` option, which writes every frame whole, has no counterpart diff --git a/packages/tui/test/terminal/child.rip b/packages/tui/test/terminal/child.rip new file mode 100644 index 00000000..400b939b --- /dev/null +++ b/packages/tui/test/terminal/child.rip @@ -0,0 +1,52 @@ +# A small app under `run`, spawned by test/terminal.rip to take one way +# out for real — a signal, an uncaught error, a `process.exit`, a loop +# that drains — on a stdout that says it is a terminal and writes +# through to the process's own, so whoever spawned this reads every +# byte, and on the harness's stdin; or, as `tty`, on the process's own +# streams under ptyrun.py's shell, with no timer, every key heard +# written to stderr and the exit hook's code with it. The argument is +# the way out; `pipe` and `ci` run on the process's stdout as it is. + +import { run, quit, suspend, mount, screen, Box, Text } from 'rip/tui' +import { Stdin } from '../events/harness.rip' + +way = process.argv[2] +tty = { isTTY: true, columns: 40, rows: 10, write: (text) -> process.stdout.write text } +stdin = if way is 'tty' then process.stdin else Stdin.new() + +App = component + @count := 0 + render + Box focusable: true, autofocus: true, @keydown: ((event) -> process.stderr.write "key:#{event.key}\n" if way is 'tty') + Text "count:#{@count}" + +if way is 'mountsuspend' + view = mount App + suspend! -> null + view.close() + setTimeout (-> raise 'a timer failed after the mount'), 20 +else + process.on 'exit', (code) -> process.stderr.write "exit hook code=#{code}\n" if way is 'tty' + process.on 'uncaughtException', ((error) -> process.stderr.write "handled once: #{error.message}\n"; process.exit 5) if way is 'userhandler' + options = { stdin, stdout: (if way is 'pipe' or way is 'tty' then process.stdout else tty), mouse: true } + running = run App, options + running.done.then (-> console.log 'exited'), (error) -> console.log "errored: #{error.message}" + switch way + when 'throw', 'userhandler' then setTimeout (-> raise 'a timer failed'), 20 + when 'reject' then setTimeout (-> Promise.reject Error.new 'a promise failed'), 20 + when 'exit' then setTimeout (-> process.exit 7), 20 + when 'throwsuspend' + suspend! -> + setTimeout (-> raise 'a timer failed while suspended'), 20 + sleep! 500 + when 'pipe', 'ci' + timer = setInterval -> + running.app.count.value += 1 + return unless running.app.count.value is 5 + clearInterval timer + quit() + , 30 + when 'drain', 'tty' then null + else + screen.progress 0.5 + setInterval (->), 1000 diff --git a/packages/tui/test/terminal/ptyrun.py b/packages/tui/test/terminal/ptyrun.py new file mode 100644 index 00000000..5d5a1afc --- /dev/null +++ b/packages/tui/test/terminal/ptyrun.py @@ -0,0 +1,166 @@ +# A shell with job control, for test/terminal.rip: a pty is opened, the +# command runs in it as a job in a process group of its own, and what the +# shell would do is scripted from outside: keys typed, the job continued +# (%fg), a signal sent to it. Every event goes to stdout as one JSON line +# — started, stopped, continued, exited, killed, and the pty's output as +# it comes — so a test waits on states, never on time. A job that +# outlives the deadline is continued and killed, never left behind. +# +# python3 ptyrun.py -- cmd args... +# +# keys.json is [[delay_ms, step], ...]: a step is text to type, "?text" +# to wait until the pty has shown text, "%fg" to continue a stopped job +# (waiting for the stop first), or "@SIGNAL" to send that signal to the +# job's leader. + +import json, os, pty, select, signal, subprocess, sys, time + +cwd = sys.argv[1] +steps = json.load(open(sys.argv[2])) +cmd = sys.argv[sys.argv.index('--') + 1:] +DEADLINE = 8.0 + +def say(**event): + sys.stdout.write(json.dumps(event) + '\n') + sys.stdout.flush() + +# The shell tells the driver what the job does on one pipe, and the +# driver tells the shell to continue it on the other: neither goes +# through the pty, which is the job's alone. +told_r, told_w = os.pipe() +asked_r, asked_w = os.pipe() + +pid, fd = pty.fork() +if pid == 0: + # The shell: a session leader on the pty, with the job as its + # foreground process group, told of every stop and continue. + import fcntl, struct, termios + os.close(told_r) + os.close(asked_w) + fcntl.ioctl(0, termios.TIOCSWINSZ, struct.pack('HHHH', 10, 40, 0, 0)) + for s in (signal.SIGTSTP, signal.SIGTTIN, signal.SIGINT, signal.SIGTERM): + signal.signal(s, signal.SIG_DFL) + signal.signal(signal.SIGTTOU, signal.SIG_IGN) + env = dict(os.environ) + env.pop('CI', None) + env.pop('NO_COLOR', None) + env['TERM'] = 'xterm-256color' + job = subprocess.Popen(cmd, cwd=cwd, env=env, preexec_fn=lambda: os.setpgid(0, 0)) + try: + os.tcsetpgrp(0, job.pid) + except OSError: + pass + def tell(text): + os.write(told_w, (text + '\n').encode()) + tell('started %d' % job.pid) + asked = os.fdopen(asked_r) + while True: + try: + _, status = os.waitpid(job.pid, os.WUNTRACED | os.WCONTINUED) + except ChildProcessError: + os._exit(0) + if os.WIFSTOPPED(status): + tell('stopped %d' % os.WSTOPSIG(status)) + if asked.readline().strip() == 'fg': + try: + os.tcsetpgrp(0, job.pid) + os.killpg(job.pid, signal.SIGCONT) + except OSError: + pass + elif os.WIFCONTINUED(status): + tell('continued') + elif os.WIFEXITED(status): + tell('exited %d' % os.WEXITSTATUS(status)) + os._exit(0) + elif os.WIFSIGNALED(status): + tell('killed %d' % os.WTERMSIG(status)) + os._exit(0) + +# The driver: relays the pty and the shell's events, and runs the steps. +os.close(told_w) +os.close(asked_r) +job = None +stopped = 0 +continued = 0 +out = b'' +told = b'' +start = time.time() +due = start + steps[0][0] / 1000 if steps else None +at = 0 +open_fds = [fd, told_r] + +def heard(chunk): + global job, stopped, continued, told + told += chunk + lines = told.split(b'\n') + told = lines.pop() + for raw in lines: + line = raw.decode() + word = line.split() + if word[0] == 'started': + job = int(word[1]) + say(started=job) + elif word[0] == 'stopped': + stopped += 1 + say(stopped=int(word[1])) + elif word[0] == 'continued': + continued += 1 + say(continued=continued) + elif word[0] == 'exited': + say(exited=int(word[1])) + elif word[0] == 'killed': + say(killed=int(word[1])) + +def read(which): + global out + try: + chunk = os.read(which, 65536) + except OSError: + chunk = b'' + if not chunk: + open_fds.remove(which) + return + if which == fd: + out += chunk + say(out=chunk.decode('utf-8', 'replace')) + else: + heard(chunk) + +while open_fds and time.time() - start < DEADLINE: + ready, _, _ = select.select(open_fds, [], [], 0.02) + for which in ready: + read(which) + if due is not None and time.time() >= due and at < len(steps): + step = steps[at][1] + if step.startswith('?'): + if step[1:].encode() not in out: + continue + elif step == '%fg': + if stopped <= continued: + continue + os.write(asked_w, b'fg\n') + elif step.startswith('@'): + if job is None: + continue + os.kill(job, getattr(signal, step[1:])) + else: + os.write(fd, step.encode('latin-1')) + at += 1 + due = time.time() + steps[at][0] / 1000 if at < len(steps) else None + +# The loop ends when the shell has gone — both its fds closed — or at +# the deadline, with the job still there. +if open_fds: + if job is not None: + for s in (signal.SIGCONT, signal.SIGKILL): + try: + os.killpg(job, s) + except OSError: + pass + os.kill(pid, signal.SIGKILL) + say(timeout=True) +try: + os.waitpid(pid, 0) +except ChildProcessError: + pass +say(end=True) diff --git a/packages/tui/test/text.rip b/packages/tui/test/text.rip index 39a1c06d..c45a520f 100644 --- a/packages/tui/test/text.rip +++ b/packages/tui/test/text.rip @@ -203,7 +203,7 @@ test "on the grid a baseless mark draws nothing: no cell is its own, and none is grid.write 0, 0, "#{ACUTE}Xy", red eq Array.from(grid.wide), [1, 1, 1, 1] eq grid.toString(), 'Xy' - eq grid.toString(true), "#{ESC}[31mXy#{ESC}[0m " + eq grid.toString(true), "#{ESC}[31mXy#{ESC}[0m" test "a cluster is as wide as string-width 8 says: a spacing mark takes a cell, a mark above takes none", -> bengali = [0x9AC, 0x9BE, 0x982, 0x9B2, 0x9BE].map(at).join '' diff --git a/packages/tui/tui.rip b/packages/tui/tui.rip index 362b760f..43b308c0 100644 --- a/packages/tui/tui.rip +++ b/packages/tui/tui.rip @@ -3,11 +3,14 @@ # into events; `mount` does the same off any terminal, draws when a test # asks, and takes the test's keys by the same road. -import { install, failed, Event } from './document.rip' +import { __effect } from '../../src/runtime/reactive.js' +import { install, failed, watch, Event } from './document.rip' import { tend, take, advance } from './focus.rip' import { Parser } from './input.rip' import { Screen } from './screen.rip' 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' # ── Widgets ─────────────────────────────────────────────────────────────────── # A box is a div and text is a span. Every prop a widget does not @@ -27,6 +30,113 @@ export Spacer = component render div flexGrow: 1 +# `count` line breaks inside text. +export Newline = component + @count := 1 + render + span "\n".repeat(@count) + +# Items written once above the live frame, into the scrollback, and +# never painted in the frame: `Static` around a keyed `for`. Each item +# is painted when it first appears, at the terminal's width, with the +# items that appear in the same frame, in tree order; a change to it or +# its removal afterwards changes nothing written. The box's own styles +# — padding, margins, a background — go around each batch. On the +# alternate screen it writes nothing (screen.rip). +export Static = component extends div + el := null + ~> + return unless el and live + view = live.view + view.statics.push el + -> view.statics = view.statics.filter (other) -> other isnt el + render + div ref: el, display: 'none' + slot + +# Text written above the live frame, its line ended, by the road a +# `Static` item takes (`Screen.above`); `print.err` writes it to stderr, +# with the frame cleared on stdout first. With no app mounted the text +# goes to the stream as it is. +export print =! (text, err = null) -> + text = String text + text += '\n' unless text.endsWith '\n' + return (err ?? process.stdout).write text unless live + live.view.above text, err +print.err = (text) -> print text, live?.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 +# holds the clock is mounted and something has read it — on the mount's +# own clock under `mount`, so `view.tick` moves it — and never off a +# terminal. A component holds the clock it makes in its body +# (`tick = clock 80`) and lets go when it unmounts, which stops the +# timer once no holder is left; a clock made outside any component is +# held for good, and its timer stops when the app closes. +class Tick + constructor: (@interval) -> + @cell = watch { frame: 0, time: 0, delta: 0 } + @holders = 0 + @cancel = null # the running timer's cancel, while it runs + @timers = null # a timer source given by hand, a test's + @source = null + @began = @last = 0 + + get frame: -> @read().frame + get time: -> @read().time + get delta: -> @read().delta + + read: -> + @start() if not @cancel and @holders and live and live.view.interactive isnt false + @cell.read() + + start!: -> + @source = @timers ?? live.clock ?? TIMERS + @began = @last = @source.now() + @cancel = @source.after @interval, => @beat() + + # The timer is set again before the cells are written, so a reader + # that runs again on the write finds it running. + beat!: -> + @cancel = null + unless @holders and live + @stop() + return + @cancel = @source.after @interval, => @beat() + now = @source.now() + @cell.write Object.freeze { frame: Math.floor((now - @began) / @interval), time: now - @began, delta: now - @last } + @last = now + + stop!: -> + @cancel?() + @cancel = null + @cell.write { frame: 0, time: 0, delta: 0 } + + release!: -> + @holders -= 1 + @stop() unless @holders + +clocks =! Map.new() + +# The clock for `interval` milliseconds (100 by default, and where the +# interval is no number); `timers` is a `{ now, after }` of a test's +# own to run it on. +export clock =! (interval = 100, timers = null) -> + interval = if Number.isFinite(interval) then Math.max(1, interval) else 100 + tick = clocks.get interval + clocks.set interval, tick = Tick.new interval unless tick + tick.timers = timers if timers + __effect -> + tick.holders += 1 + -> tick.release() + tick + # ── The mounted app ─────────────────────────────────────────────────────────── cols := 80 @@ -34,18 +144,29 @@ rows := 24 shown := true keyboard := 'basic' selected := '' +interactive := true +colors := 16777216 live = null +DEPTHS =! [0, 16, 256, 16777216] + class Viewport get cols: -> cols get rows: -> rows get focused: -> shown get keyboard: -> keyboard get selection: -> selected + get interactive: -> interactive + get colors: -> colors + # Progress on the terminal's own indicator: 0 to 1, 'error', + # 'indeterminate', or null to clear (screen.rip). + progress: (value) -> live?.view.progress value # The terminal's size, whether it is the window the user is in (its # focus reports), and whether its keyboard is enhanced, as reactive -# reads; and the text the mouse has selected, as it was copied. +# reads; the text the mouse has selected, as it was copied; whether +# frames go to a terminal someone is watching, and how many colors it +# is drawn with, as `run` read them. export screen =! Viewport.new() # The node the keyboard goes to, a reactive read that settles what the @@ -64,56 +185,6 @@ export focus =! Focus.new() # ── Input ───────────────────────────────────────────────────────────────────── -ASK =! "\x1b[?2004h\x1b[?1004h" # bracketed paste, focus reports (xterm ctlseqs) -WITHDRAW =! "\x1b[?1004l\x1b[?2004l" -PROBE =! "\x1b[?6n" # DECXCPR: which row the frame's top-left is on -QUERY =! "\x1b[?u\x1b[c" # kitty's "is the protocol here", then primary device attributes -PUSH =! "\x1b[>1u" # kitty: push the disambiguation flag -POP =! "\x1b[ - moves = if mouse is 'all' then 1003 else 1002 - if set then "\x1b[?#{moves}h\x1b[?1006h" else "\x1b[?1006l\x1b[?#{moves}l" - -# Give `held` its parser, and read `stdin` through it if it is a terminal -# that can be set raw. Any other stdin — a pipe, none — is left alone: -# no key arrives, and nothing is asked of the terminal. With the mouse -# the terminal is asked for reports. -listen! =! (held, stdin, clock) -> - held.parser = Parser.new { clock, late: (events) -> receive held, events } - return unless stdin?.isTTY and typeof stdin.setRawMode is 'function' - stdin.setRawMode true - # From here the stdin is the app's to give back. - held.stdin = stdin - held.read = (chunk) -> receive held, held.parser.feed chunk - stdin.ref?() - stdin.resume() - stdin.on 'data', held.read - held.out.write ASK + (if held.mouse then track(held.mouse, true) else '') - -# Ask the terminal where its cursor is, from the frame's top-left, where -# the frame sits: unknown until the answer, and taken to be the bottom -# meanwhile. A resume after a suspend asks again the same way, once the -# modes are sent again; the alternate screen has no such row. -ask! =! (held) -> - held.asked = true - held.view.origin = Infinity - held.out.write held.view.unpark() + PROBE - -# The questions whose answers arrive as input, asked once the app -# stands, so a constructor that throws leaves no answer for the shell: -# with the mouse, where the cursor is; with the enhanced keyboard, -# whether the terminal speaks kitty's protocol, and its attributes, -# which every terminal answers. -probe! =! (held) -> - return unless held.stdin - ask held if held.mouse - held.out.write QUERY if held.probing - # Hand the app what was read. A listener that throws under `run` — or # a child a key's change made, which failed to construct — has no # caller to throw to: the terminal is given back, and `done` rejects. @@ -131,76 +202,70 @@ fail! =! (held, error) -> # Each event is a turn of its own: the tree is as the key before left it. # A key goes to the focused node, or to the body while nothing has # focus, as DOM sends it; the document hears it either way. Once the app -# is closing, what is left of a read is nobody's. A mouse report is the +# is closing, or the terminal is another's, what is left of a read is +# nobody's. A mouse report is the # pointer's to hit-test, and dropped while the mouse is off; a reply # answers a probe. deliver! =! (held, events) -> for event in events - return unless live is held and not held.closing + return unless live is held and not held.closing and not held.suspended switch event.type when 'focus' then shown = true when 'blur' shown = false held.pointer?.leave() when 'mouse' then held.pointer?.report event - when 'reply' then answer held, event + when 'reply' then keyboard = 'enhanced' if reply held, event when 'key', 'paste' tend held.doc sent = Event.new (if event.type is 'key' then 'keydown' else 'paste'), event act held, sent if (held.doc.active ?? held.doc.body).dispatchEvent(sent) and sent.type is 'keydown' -# The terminal's answers. The cursor's row — an answer to the package's -# own probe, marked as DECXCPR marks it, and only while one is -# outstanding — is where the frame's top-left was when it was asked, and -# lower if a frame has scrolled the terminal since. The keyboard is decided by whichever of the two queries is -# answered first: kitty's own reply says the protocol is there, and the -# flag is pushed; the attributes reply first says it is not — tmux -# answers only that one — and nothing is pushed, whatever comes later. -answer! =! (held, event) -> - switch event.kind - when 'cursor' - return unless event.marked and held.asked - held.asked = false - held.view.origin = Math.min held.view.origin, event.y - when 'keyboard', 'attributes' - return unless held.probing - held.probing = false - return unless event.kind is 'keyboard' - keyboard = 'enhanced' - return unless held.stdin - held.out.write PUSH - held.pushed = true - -# The default actions, which run for a key no listener prevented. Ctrl-Z -# (suspend) joins them with the lifecycle, PLAN §8. +# The default actions, which run for a key no listener prevented: Tab +# and Shift-Tab move focus, Ctrl-C quits, Ctrl-Z stops the process +# (terminal.rip). act! =! (held, event) -> - if event.key is 'Tab' and not (event.ctrlKey or event.altKey or event.metaKey) + return if event.altKey or event.metaKey + if event.key is 'Tab' and not event.ctrlKey advance held.doc, event.shiftKey - else if event.key is 'c' and event.ctrlKey and not (event.altKey or event.metaKey) + else if event.key is 'c' and event.ctrlKey quit() + else if event.key is 'z' and event.ctrlKey + halt held -# A clock a test moves by hand, for the parser's waits. +# A clock a test moves by hand, for the parser's waits and the +# animation clocks: `tick` runs what falls due in order, the time +# standing at each as it runs. class Clock constructor: -> @time = 0 @due = [] + now: -> @time + after: (ms, fn) -> entry = { at: @time + ms, fn } @due.push entry => @due = @due.filter (other) -> other isnt entry tick!: (ms) -> - @time += ms - ready = @due.filter((entry) => entry.at <= @time).sort (a, b) -> a.at - b.at - @due = @due.filter (entry) => entry.at > @time - entry.fn() for entry in ready + stop = @time + ms + loop + next = null + next = entry for entry in @due when entry.at <= stop and (not next or entry.at < next.at) + break unless next + @due = @due.filter (entry) -> entry isnt next + @time = next.at + next.fn() + @time = stop # Put `App` on a terminal document of its own, to be drawn to `out`. # The document is a global of the process, so one app is mounted at a # time and a second is refused. `live` stands before the app is # constructed, so a `quit` from an effect that runs during construction -# is heard. +# is heard. A terminal is taken as terminal.rip takes it — unless `out` +# is no terminal, or CI, in which case the frames are kept and only the +# last is written, at the end, as text. open =! (App, options, out, terminal, stdin = null, clock = undefined) -> throw Error.new 'rip/tui: an app is already mounted in this process — `quit` or `close` it first' if live mouse = options.mouse ?? false @@ -210,39 +275,52 @@ open =! (App, options, out, terminal, stdin = null, clock = undefined) -> { 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 # On a terminal the frame sits wherever the cursor was, which the # probe answers; until then, and where nothing answers, at the bottom. - view.origin = Infinity if terminal + # The alternate screen's is its top. + view.origin = if terminal and not view.alt then Infinity else 0 cols = view.cols rows = view.rows + interactive = view.interactive + depth = if terminal then depthOf out else 3 + colors = DEPTHS[depth] + palette depth held = null - # A resize reflows the terminal, so where the frame sits is asked again. - resized = -> - return if view.cols is cols and view.rows is rows + # The next frame is drawn whole, at the size the terminal has now. + refresh = -> cols = view.cols rows = view.rows held.pointer?.clear() view.front = null - ask held if held.mouse and held.stdin doc.owe true + # A resize reflows the terminal, so where the frame sits is asked again. + resized = -> + return if view.cols is cols and view.rows is rows + refresh() + ask held if held.mouse and held.raw and not view.alt settled = {} done = Promise.new (resolve, reject) -> settled.resolve = resolve settled.reject = reject - live = held = { app: null, view, out, doc, restore, resized, interrupted: (-> quit()), done, settled, terminal, closing: false, parser: null, stdin: null, read: null, clock, mouse, pointer: null, probing: keys is 'enhanced', asked: false, pushed: false } + live = held = { app: null, view, out, err: options.stderr ?? out.err ?? process.stderr, doc, restore, resized, redraw: refresh, done, settled, terminal, depth, console: options.console isnt false, closing: false, over: false, parser: null, stdin, read: null, clock, mouse, pointer: null, probing: keys is 'enhanced', asked: false, enhanced: false, pushed: false, raw: false, up: false, entered: false, suspended: false, wake: null, hold: null, handlers: null, was: null, logs: [], close: (drawn, error = null) -> close drawn, error if live is held } + held.parser = Parser.new { clock, late: (events) -> receive held, events } + held.read = (chunk) -> receive held, held.parser.feed chunk if mouse held.pointer = Pointer.new doc, view, out, mouse, options.selection isnt false, (text) -> selected = text view.after = -> held.pointer.rest() shown = true keyboard = 'basic' selected = '' - out.write "\x1b[?25l" if terminal try - listen live, stdin, clock + if terminal + guard held + setup held live.app = App.new(options.props ?? {}) live.app.mount doc.body failed doc - probe live + probe held if terminal catch error close false throw error @@ -266,7 +344,6 @@ export run =! (App, options = {}) -> view.failed = (error) -> fail held, error doc.onOwe = -> view.book() out.on? 'resize', held.resized - process.on 'SIGINT', held.interrupted { app: held.app, done: held.done, quit, flush: (-> view.frame() if doc.stale) } # An app mounted off any terminal, for a test to drive: set its state @@ -283,6 +360,11 @@ class Mount get ansi: -> @held.view.front?.toString(true) ?? '' get bytes: -> @held.out.sent + # What was written above the frame so far, as it was written, and + # what stderr was sent. + get scrollback: -> @held.view.scrollback + get stderr: -> @held.out.err.sent + # The cells the last frame owed: those it painted and compared. get damage: -> @held.view.owed @@ -326,22 +408,27 @@ class Mount close!: -> close false if live is @held # Mount `App` once on a terminal of `cols` by `rows` that only keeps -# what it is sent. With no `rows` it is as tall as the frame. +# what it is sent, with a stderr of the same kind. With no `rows` it is +# as tall as the frame. export mount =! (App, options = {}) -> out = { columns: options.cols ?? 80, rows: options.rows ?? Infinity, sent: '', write: (text) -> @sent += text } + out.err = { sent: '', write: (text) -> @sent += text } Mount.new open(App, options, out, false, null, Clock.new()) -# Give the terminal and the process back: the cursor, the listeners, the -# `document` slot. `drawn` leaves the last frame in the scrollback; a -# failure leaves whatever reached the screen. Every step is taken -# whatever the others do, so a write that fails cannot leave stdin raw -# or the document installed, and `done` is settled here — with `error`, -# the first step that failed, or `result`. +# Give the terminal and the process back: the terminal as terminal.rip +# gives it back, the listeners, the `document` slot. `drawn` leaves the +# last frame in the scrollback; a failure leaves whatever reached the +# screen. Every step is taken whatever the others do, so a write that +# fails cannot leave stdin raw or the document installed, and `done` is +# settled here — with `error`, the first step that failed, or `result`. close =! (drawn, error = null, result = undefined) -> - { app, view, out, doc, restore, resized, interrupted, terminal, parser, stdin, read, settled, mouse, pointer, pushed } = live + held = live + { app, view, out, doc, restore, resized, pointer, settled } = held live = null + held.over = true keyboard = 'basic' doc.onOwe = null + view.booked = false step = (work) -> try work() @@ -350,21 +437,24 @@ close =! (drawn, error = null, result = undefined) -> # A selection is the screen's, never the scrollback's. step -> pointer.clear() if pointer selected = '' - if terminal - step -> out.write (if pushed then POP else '') + (if mouse then track(mouse, false) else '') + WITHDRAW if stdin - step -> if drawn then view.leave() else out.write view.unpark() + "\x1b[?25h" + if held.terminal + step -> + caught = teardown held, drawn + error ?= caught step -> app?.unmount() - step -> parser?.flush() - if stdin - step -> stdin.off 'data', read - step -> stdin.setRawMode false - step -> stdin.pause() - step -> stdin.unref?() + step -> held.parser.flush() step -> out.off? 'resize', resized - step -> process.off 'SIGINT', interrupted step restore + palette 3 if error then settled.reject error else settled.resolve result +# Hand the terminal to `fn` — an editor, a shell — and take it back +# once `fn` settles, with a whole frame: the road Ctrl-Z takes, without +# the signal. A key that arrives meanwhile is nobody's. +export suspend =! (fn) -> + throw Error.new 'rip/tui: no app is running — `suspend` hands over the terminal `run` holds' unless live + handover live, fn + # Unmount the mounted app, leave its last frame on screen, and give the # process its `document` slot back. The work waits for the turn to end, # so a `quit` called from inside an effect still draws what that same @@ -376,12 +466,21 @@ export quit =! (result) -> queueMicrotask -> close true, null, result if live is held -# The frame `App` draws in `cols` columns, as text — plain by default, -# with escape sequences when `ansi` is set: mount, one frame, close. +# Text with its escape sequences taken out, and each row's trailing +# blanks with them. +SGR =! /\x1b\[[0-9;]*m|\x1b\]8;;[^\x1b]*\x1b\\/g +bare =! (text) -> (line.replace(SGR, '').trimEnd() for line in text.split '\n').join '\n' + +# What `App` draws in `cols` columns, as text — plain by default, with +# escape sequences when `ansi` is set: mount, one frame, close. The +# rows its `Static` items wrote come first, then the frame. export renderToString =! (App, options = {}) -> view = mount App, options try text = view.frame() - if options.ansi then view.ansi else text + above = view.scrollback + above = above.slice 0, -1 if above.endsWith('\n') and not view.held.view.front.rows + above = bare above unless options.ansi + above + (if options.ansi then view.ansi else text) finally view.close()