Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions packages/tui/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,9 @@ component effects ──► node setters ──► dirty marks ──► frame f
diff + emit (one write) ◄───────┘
```

One flush per reactive batch: a microtask, a minimum interval of about
8 ms. Each key of a
One flush per reactive batch: a microtask, and a minimum interval
between frames, 8 ms unless `run App, pace: ms` names another — every
change inside the interval goes into the frame that ends it. Each key of a
stdin read is a reactive turn of its own — the tree a key meets is the
tree the key before it left — and a read is still one frame, because a
change books a frame and does not draw one. Rip flushes effects
Expand Down Expand Up @@ -1109,16 +1110,18 @@ run App
```

- `run(App, {stdin, stdout, stderr, damage, mouse, keyboard, selection,
altScreen, console})` → `{app, done, quit, flush}`, and `suspend(fn)`
altScreen, console, pace})` → `{app, done, quit, flush}`, and `suspend(fn)`
hands the terminal to `fn` and takes it back (§8).
- `print(text)` writes text above the live frame, its line ended, and
- `print(text)` writes text above the live frame, its line ended, after
every `Static` item not yet written, the turn's own included; and
`print.err(text)` the same on stderr with the frame cleared on stdout
first; with no app mounted the text goes to the stream as it is.
- `mount(App, {cols, rows, props, damage, mouse, keyboard, selection})`
→ `{app, frame, ansi, bytes, damage, scrollback, stderr, resize,
- `mount(App, {cols, rows, props, damage, mouse, keyboard, selection,
pace, colors})` → `{app, frame, ansi, bytes, damage, scrollback, stderr, resize,
close, done}`, and for input `{press, type, paste, send, tick,
focused, cursor}`, is the test driver (§10), and
`renderToString(App, {cols, rows, props, ansi})` is a mount, one
focused, cursor}`, is the test driver (§10), with `colors` the depth
it draws at (0, 16, 256, or 16777216 by default), and
`renderToString(App, {cols, rows, props, colors, ansi})` is a mount, one
frame, and a close, answering the rows `Static` wrote and then the
frame.
- `screen` (`cols`, `rows`, `focused`, `keyboard`, and `selection`, the
Expand Down
55 changes: 49 additions & 6 deletions packages/tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,11 @@ back on every way out, by one road:

A key that arrives meanwhile is nobody's; a `quit` meanwhile closes
the app without taking the terminal back.
- **Pace.** A change books a frame and every change until it is drawn
goes into it, and two frames are at least 8 ms apart. `run App,
pace: 100` sets that interval: an app fed by a stream that changes
its state a thousand times a second binds the state directly and
draws ten frames a second.
- **Alternate screen.** `run App, altScreen: true` draws on the
terminal's alternate screen from its top-left; every way out leaves
it after the last frame, so the frame vanishes and the shell's own
Expand All @@ -230,7 +235,9 @@ back on every way out, by one road:
`truecolor` 24-bit, `TERM` `256color` 256, and any other terminal
16. A 24-bit color is drawn as the nearest of xterm's 256 — a color
on the cube as that point — and below that as the nearest of xterm's
16.
16. A mount is at full depth whatever the environment says, and
`mount App, colors: 16` (or `256`, `0`) draws it as `run` draws on a
terminal of that depth, so a test sees the app there.
- **Console.** While the app runs, every console method that writes
(`log`, `table`, `group`, `trace`, `assert`, `count`, `time*`, …)
clears the frame, writes where it always went, and draws the frame
Expand Down Expand Up @@ -261,6 +268,28 @@ scrollback (below).
| `overflow`, `overflowX`, `overflowY`: `'visible'` or `'hidden'` (clips to the padding box; a box that scrolls its children keeps to its parent's size with `flexShrink: 1`, since the default is 0) | |
| On `Text`: `wrap` — `'wrap'` (the default: words wrap, and a word longer than the line breaks), `'hard'`, `'truncate'` / `'truncate-end'`, `'truncate-start'`, `'truncate-middle'` (with `…`) There is no no-wrap mode: to keep a line on one row and scroll it, give the text a `width` wider than any line and shift it with `contentOffsetX` (`truncate` cuts at the box's width before the offset shifts it). | On `Text`: `link` — a URL; the words are a hyperlink (OSC 8) |

A row of runs in several styles — a mark, a name, a count — is one
`Text` with a `Text` nested for each run, and `wrap: 'truncate'` keeps
it to one row, cut with `…` where its box is too narrow for it:

```coffee
Row = component
render
Text wrap: 'truncate'
Text color: 'green', "✓ "
Text bold: true, "packages/time"
Text dimColor: true, " 548 tests 0.7s"
```

At 18 columns that is `✓ packages/time …`. A row `Box` of the same
three `Text`s is a flex row of three items instead: too narrow for them
it spills past its box, and with `flexShrink: 1` on each it wraps each
text in a column of its own. There is no `Line` widget for this: it
would be `Text wrap: 'truncate'` by another name, and the runs are
already the text's own children. `scripts/test-live.rip` draws every
row of its board this way, from runs it computes: a `for` of nested
`Text`s, each with the run's style as `style:`.

Text is measured by grapheme cluster — a flag, a family emoji, a letter
with its combining marks each take the cells a terminal gives them —
and control characters are stripped when the text is set: styling
Expand Down Expand Up @@ -403,7 +432,7 @@ back, and draws a frame when the test asks for one.
```coffee
import { mount } from 'rip/tui'

view = mount Counter, cols: 40, rows: 10, props: { count: 3 } # mouse:, keyboard:, selection: as `run` takes them
view = mount Counter, cols: 40, rows: 10, props: { count: 3 } # mouse:, keyboard:, selection:, pace: as `run` takes them; colors: 0, 16 or 256
view.frame() # "count 3" — lay out, paint, the frame as plain text
view.app.count.value = 7 # public state is set from outside
view.frame() # "count 7"
Expand All @@ -430,15 +459,25 @@ default actions — and draws nothing: ask for the frame.

Nothing is drawn until `frame` asks, so a frame that fails — a layout
that never settles — throws from `frame`, to the test that asked for
it. `bytes` is the difference from the frame
it. `mount App, pace: 100` draws as `run` does instead: the first frame
and every change after it are booked, no closer than the pace, on the
mount's clock, and drawn as `view.tick` moves the clock to them, so ten
changes within 100 ms of `tick` are one frame; a booked frame that
fails throws from `tick`, and `bytes` holds every write since the test
last asked for a `frame`.

`bytes` is the difference from the frame
before, exactly as `run` writes it; a frame that changes no cell sends
nothing. With `rows`, a frame taller than the terminal shows its
bottom, as it does on a terminal; without, the terminal is as tall as
the frame. `damage` counts the cells the frame owed, which is how a
test holds an update to a small repaint; `mount App, damage: false`
(and `run`) owes every cell of every frame, for a frame to compare
against. A `quit` from the app closes the mount and resolves
`view.done` with its value.
`view.done` with its value, and first draws the last frame, as `run`
does: what the quitting turn added to `Static` is in `view.scrollback`,
and what it changed is in `view.ansi`, with no `frame()` asked for. A
`close()` by hand draws nothing.

`bytes`, `damage` and `cursor` describe the last `frame()`: a frame that
draws nothing leaves `bytes` empty and `damage` 0, and the OSC 52 write
Expand All @@ -452,7 +491,7 @@ mounted at a time: a second `mount`, `run`, or `renderToString` is
refused by name until the first is closed — close in a `finally`.

`renderToString App, cols: 40` is a mount, one frame, and a close; it
takes `props`, and `ansi: true` keeps the escape sequences. The rows
takes `props` and `colors`, and `ansi: true` keeps the escape sequences. The rows
the app's `Static` items wrote come first, then the frame. A child that
fails to construct — at the mount, from a key, or from a state set by
the test — fails the mount, the key, or the next frame with the child's
Expand Down Expand Up @@ -802,7 +841,11 @@ an item is done: a change to its state or its removal from the list
changes nothing on the terminal. An item is an element: a bare text
under `Static` is refused by name as it is put there — wrap it in
`Text`. An item under a hidden ancestor waits
until it is shown. Off a terminal the rows go out as plain text as they
until it is shown. A `print`, or a console line, writes the items not
yet written first — those the same turn added included, even from an
effect that runs before the render block that adds them — so the
scrollback is in the order of the program. Off a terminal the rows go
out as plain text as they
arrive; on the alternate screen nothing is written above. `examples/log.rip`
is a build log this way, with a spinner and a progress bar for the
step under way.
Expand Down
39 changes: 27 additions & 12 deletions packages/tui/screen.rip
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@
# record of it: a frame first takes the cursor back to the top-left,
# then draws, then parks it again, all in the one write.

import { __batch } from '../../src/runtime/reactive.js'
import { tend, visible } from './focus.rip'
import { failed } from './document.rip'
import { layout } from './layout.rip'
import { Grid, paint, diff, overlay, still, rowsToString, clipped, clipOf } from './paint.rip'

PACE =! 8 # the least milliseconds between two frames
export PACE =! 8 # the least milliseconds between two frames, unless the app sets its own
LAYOUTS =! 32 # the most layouts one frame may take to settle

# Whether every node above `node`, up to `body`, is shown.
Expand Down Expand Up @@ -52,14 +53,18 @@ export class Screen
# watching (terminal.rip) — off one, frames are kept and only text is
# written; `alt` is the alternate screen, whose top-left the frame's
# is, where nothing is written above and `leave` moves nowhere;
# `origin` is the terminal row the frame's top-left is on. `after`
# and `failed` are hooks a caller installs: run after every frame,
# and told of a frame that fails with no caller to throw to.
constructor: (@doc, @out, { whole = false, interactive = true, alt = false, origin = 0 } = {}) ->
# `origin` is the terminal row the frame's top-left is on; `pace` is
# the least milliseconds between two frames, and `clock` a `{ now,
# after }` of a test's own that booked frames wait on. `after` and
# `failed` are hooks a caller installs: run after every frame, and
# told of a frame that fails with no caller to throw to.
constructor: (@doc, @out, { whole = false, interactive = true, alt = false, origin = 0, pace = PACE, clock = null } = {}) ->
@front = null
@spare = null
@booked = false
@last = 0
@last = -Infinity
@pace = pace
@clock = clock
@failed = null
@whole = whole
@owed = 0 # the cells the last frame owed
Expand Down Expand Up @@ -89,10 +94,15 @@ export class Screen
# and the text to `err`, and the frame follows on the next write. The
# frame's row moves down by the lines written. Off a terminal the
# text goes as it is; on the alternate screen nothing is written
# above.
# above. What `Static` holds goes first, the items this turn added
# included: an effect that writes here runs before the render blocks
# its own write queued (they are deeper), so those run first, and the
# scrollback is in the order of the program.
above!: (text, err = null) ->
return unless text
@still() unless @batching # what `Static` holds goes first
unless @batching
__batch -> # an empty batch: its end runs the effects the turn queued
@still()
@scrollback += text
unless @interactive
(err ?? @out).write text
Expand Down Expand Up @@ -170,7 +180,7 @@ export class Screen
frame!: ->
@still()
@booked = false
@last = performance.now()
@last = @now()
failed @doc
tend @doc
converge @doc, @cols
Expand Down Expand Up @@ -248,13 +258,18 @@ export class Screen
up = if spot.y then "\x1b[#{spot.y}A" else ''
"\x1b[?25l#{up}\r"

now: -> if @clock then @clock.now() else performance.now()

# Draw what is owed, once this turn's writes are in and no sooner than
# the pace allows.
# the pace allows: every change meanwhile goes into that one frame. On
# a test's clock the frame waits for the clock to reach it.
book!: ->
return if @booked
@booked = true
wait = PACE - (performance.now() - @last)
if wait > 0 then setTimeout (=> @drawBooked()), wait else queueMicrotask (=> @drawBooked())
wait = @pace - (@now() - @last)
if @clock then @clock.after Math.max(0, wait), (=> @drawBooked())
else if wait > 0 then setTimeout (=> @drawBooked()), wait
else queueMicrotask (=> @drawBooked())

# A booked frame has no caller to throw to, so its failure goes to
# whoever runs the app. One drawn by hand meanwhile, or a close, has
Expand Down
87 changes: 84 additions & 3 deletions packages/tui/test.rip
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,44 @@ test "a link wraps its words in OSC 8, nested text inherits it, and a plain fram
"go"
eq renderToString(Panel, cols: 4, ansi: true), "\x1b[44m#{OPEN 'https://example.com'}go\x1b[0;44m#{SHUT} \x1b[0m"

# The README's row of styled runs, as it is written there.
ROW =! '''
Row = component
render
Text wrap: 'truncate'
Text color: 'green', "✓ "
Text bold: true, "packages/time"
Text dimColor: true, " 548 tests 0.7s"
'''

test "a row of styled runs is one Text of nested runs, cut with … where it is too narrow, as the README writes it; a row Box of the same Texts spills past its box, or wraps each in a column", ->
ok readFileSync(join(import.meta.dir, 'README.md'), 'utf8').includes("```coffee\n#{ROW}\n```"), 'the README holds the pattern'
Row = component
render
Text wrap: 'truncate'
Text color: 'green', "✓ "
Text bold: true, "packages/time"
Text dimColor: true, " 548 tests 0.7s"
eq renderToString(Row, cols: 40), '✓ packages/time 548 tests 0.7s'
eq renderToString(Row, cols: 18), '✓ packages/time …'
eq renderToString(Row, cols: 18, ansi: true), '\x1b[32m✓ \x1b[0;1mpackages/time\x1b[0;2m …\x1b[0m', 'each run in its own style'
Spills = component
render
Box width: 18, borderStyle: 'round'
Box flexDirection: 'row'
Text color: 'green', "✓ "
Text bold: true, "packages/time"
Text dimColor: true, " 548 tests 0.7s"
eq renderToString(Spills, cols: 20).split('\n')[1], '│✓ packages/time 54', 'over its border, to the edge of the terminal'
Shrinks = component
render
Box width: 18, borderStyle: 'round'
Box flexDirection: 'row'
Text flexShrink: 1, color: 'green', "✓ "
Text flexShrink: 1, bold: true, "packages/time"
Text flexShrink: 1, dimColor: true, " 548 tests 0.7s"
eq renderToString(Shrinks, cols: 20).split('\n').length, 5, 'three rows inside the border'

test "a link that wraps is closed on every row and opened again on the next, its '…' inside it", ->
Long = component
render
Expand Down Expand Up @@ -1502,6 +1540,29 @@ test! "a quit from a mounted app closes the mount and resolves done", ->
eq typeof document, 'undefined'
throws (-> view.frame()), 'this mount is closed'

test! "a mount closed by quit draws its last frame, as run does: the quitting turn's Static items reach the scrollback and its change the frame; a close by hand draws nothing", ->
Finisher = component
@done := []
@over := false
~> quit() if @over
render
Box flexDirection: 'column'
Static
for name in @done
Text key: name, "✓ #{name}"
Text "#{if @over then 'all done' else 'working'}"
view = mount Finisher, cols: 20
eq view.frame(), 'working'
view.app.done.value = ['one']
view.app.over.value = true
await view.done
eq [view.scrollback, bare(view.ansi)], ["✓ one\n", 'all done']
view = mount Finisher, cols: 20
eq view.frame(), 'working'
view.app.done.value = ['two']
view.close()
eq [view.scrollback, bare(view.ansi)], ['', 'working'], 'a close by hand leaves the frame as the test last drew it'

# ==[ Damage: a frame paints and compares the cells a change owes ]==

console.log "\nDamage"
Expand Down Expand Up @@ -2227,6 +2288,26 @@ test "print writes above the frame, ending its line; print.err the same on stder
finally
view.close()

test "a print from the turn that appends a Static item writes the item's row first: the scrollback is in the order of the program", ->
Steps = component
@done := []
~> print "#{@done.length} done" if @done.length
render
Box flexDirection: 'column'
Static
for name in @done
Text key: name, "✓ #{name}"
Text "live"
view = mount Steps, cols: 20
try
view.frame()
view.app.done.value = ['one']
view.app.done.value = ['one', 'two']
view.frame()
eq view.scrollback, "✓ one\n1 done\n✓ two\n2 done\n"
finally
view.close()

test "rowsToString is the grid's serializer, plain and with escape sequences", ->
view = mount Journal, cols: 20, props: { live: 'here' }
try
Expand Down Expand Up @@ -2274,9 +2355,9 @@ test! "examples/log.rip runs headless: the steps scroll into the scrollback as t
view.tick 700 * 3
later = view.frame()
ok later.startsWith('⠧ link'), JSON.stringify later
# The warning's effect runs before the render block adds the step it
# is about, so it stands above that step.
eq bare(view.scrollback), "✓ resolve\n✓ fetch\nwarning: compile took the slow road\n✓ compile\n"
# The warning is printed in the turn that finishes the step it is
# about, and stands below that step.
eq bare(view.scrollback), "✓ resolve\n✓ fetch\n✓ compile\nwarning: compile took the slow road\n"
ok view.bytes.includes("\x1b]9;4;1;50\x1b\\"), 'the terminal knows how far along'
view.tick 700 * 3
eq await view.done, undefined
Expand Down
Loading
Loading