TUI dialect for Rebol3
Find a file
Boleslav Březovský c7e00fd0cc
Some checks failed
Rebol3 Oldes Edition / rebol-run (push) Failing after 1m5s
Podman-in-Podman Chaos / check-sanity (push) Successful in 13s
FEAT: completion
2026-05-27 08:36:25 +02:00
.forgejo/workflows FEAT: Exports `set-size1 for setting UI size 2026-05-17 09:39:05 +02:00
bench-gfx.r3 Split gfx layer and add TUI event runtime 2026-05-20 10:28:30 +02:00
bench-tui.r3 Split gfx layer and add TUI event runtime 2026-05-20 10:28:30 +02:00
completion.r3 FEAT: completion 2026-05-27 08:36:25 +02:00
demo-tui-fields.r3 Add editable field demo 2026-05-20 10:28:30 +02:00
gfx.r3 Extract shared text and key helpers 2026-05-23 16:17:52 +02:00
README.md Extract shared text and key helpers 2026-05-23 16:17:52 +02:00
rebol-key-dispatch.r3 FEAT: completion 2026-05-27 08:36:25 +02:00
rebol-text.r3 Extract shared text and key helpers 2026-05-23 16:17:52 +02:00
smoke-tui-events.r3 Frame smoke event demo in labeled boxes 2026-05-23 06:34:00 +02:00
test-gfx.r3 Extract shared text and key helpers 2026-05-23 16:17:52 +02:00
test-tui.r3 FEAT: completion 2026-05-27 08:36:25 +02:00
text-edit.r3 Extract shared text and key helpers 2026-05-23 16:17:52 +02:00
tui.r3 FEAT: completion 2026-05-27 08:36:25 +02:00

Rebol terminal UI

This repo has two layers:

  • gfx.r3 is the low-level terminal graphics dialect. It compiles drawing commands into ANSI escape strings and does not print.
  • tui.r3 is the retained TUI layer. It keeps a tree of widgets, renders into cell buffers, and emits only changed terminal cells on update.

Graphics layer

do %gfx.r3

print gfx [
    bold red "Hello, " reset
    fg 255.128.0 "world!" reset
    newline
]

gfx.r3 still exports tui as a compatibility alias for older code, but new graphics code should call gfx.

do %gfx.r3

The file also sets a top-level size (a pair! of cols by rows), queried once from system/ports/output at load time. Re-load the file to refresh it after a terminal resize.

Retained TUI layer

do %tui.r3

view: make-view 80x24 [
    origin 2x2
    title: text "Status" font-color yellow
    return
    box 30x3 [
        status: text "Ready"
    ]
]

initial-output: render view

set-face view 'status [text "OK"]
delta-output: render view

The first render compares the empty front buffer with the rendered back buffer and returns the ANSI output needed to draw the view. Later calls return only dirty cell runs. Updating "Ready" to "OK" dirties the old and new face bounds and emits only the cells needed to draw OK and clear the trailing spaces.

Public retained APIs:

  • make-view <size> <layout-block> — build a retained widget tree
  • render <view> — return changed ANSI output and clear dirty state
  • set-face <view> <id-or-face> <facet-block> — update a named face
  • get-face <view> <id> — return a named face object
  • resize-view <view> <size> — rebuild buffers and dirty the screen
  • invalidate <view> <id-or-face-or-rect> — manually mark dirty state

Initial widgets are text, base, panel, box, button, and field. Layout words are origin, space, pad, at, and return. Facets include size, offset, color, font-color, visible?, enabled?, focusable?, flags, rate, data, actors, and pane. In layout blocks, text content is usually the bare string after text; in set-face, use text "...".

Event runtime

The retained renderer can also be driven by a minimal Red-like event runtime:

  • view <layout-block> — size from the terminal, render, and enter the loop
  • view/tight <layout-block> — same, with compact make-view spacing
  • show <view-or-face> — invalidate and render after direct face mutation
  • unview / unview/all — leave the current or all active view loops
  • dispatch-event <view> <event> — testable event entry point that returns render output instead of printing

Actor facets are on-key, on-time, on-click, on-change, and on-enter. Actor blocks run with view, face, event, and named faces from the view in scope. Keyboard dispatch sends on-key to the focused face first, then the root actor. tab and shift-tab cycle visible, enabled, focusable faces in tree order; button and field are focusable by default.

Keyboard events

dispatch-event accepts a word!, char!, none, or an event object. A plain key value is normalized into a key event; none is normalized into a time event.

dispatch-event view #"x"
dispatch-event view 'tab
dispatch-event view make object! [
    type: 'key
    key: 'enter
    face: none
    time: none
]

Common control characters are normalized before actor dispatch:

  • #"^-" -> tab
  • #"^M" and #"^/" -> enter
  • #"^[" -> escape
  • #"^H" and #"^(7F)" -> backspace

The interactive view loop also normalizes native read-key results and common terminal CSI sequences before calling dispatch-event. In practice, arrow keys, home, end, delete, backspace, tab, shift-tab, enter, and escape arrive as the same logical words used by tests:

left right home end delete backspace tab shift-tab enter escape

Modified arrow keys are normalized as shift-<dir>, ctrl-<dir>, and alt-<dir> (e.g. ctrl-left). They reach on-key actors unchanged; the built-in field editing does not interpret them.

Unknown multi-character terminal sequences are ignored by the live loop.

Fields

field is a single-line editable text widget. It is focusable by default and keeps two logical editing facets:

  • caret — 1-based insert position; defaults to the end of text plus 1
  • scroll — 1-based visible text start for the clipped field window

Fields render as a clipped, padded line. Padding is intentional: when text is deleted, the retained diff clears stale cells to the right of the shorter value.

When a field is focused inside the interactive view loop, the caret is shown with inverse video on the active cell. Plain render calls keep the logical caret hidden unless terminal mode is enabled on the view.

Focused, visible, enabled fields have built-in key handling:

  • printable char! inserts at caret
  • backspace deletes before caret
  • delete deletes at caret
  • left, right, home, and end move caret
  • enter does not edit text; it still dispatches on-enter
  • tab and shift-tab keep focus traversal behavior

For field keys, built-in editing runs before actor callbacks. If the text changes, on-change is dispatched with event/type = 'change; after that, normal focused on-key and root on-key dispatch continue. Caret movement and failed deletions do not fire on-change.

view/tight [
    on-key [if event/key = 'escape [unview]]

    name: field "Edit me" size 24x1
        on-change [
            status/text: rejoin ["Editing: " face/text]
        ]

    return
    status: text "Ready" size 32x1
]

Graphics dialect reference

Tokens are consumed left-to-right. Whitespace and grouping in the source block are not significant.

Cursor positioning

  • <col>x<row> (any pair!) — move cursor to that row/column. Negative components count from the far edge: -1x-1 is the bottom-right corner, -5x-3 is 5 from the right and 3 from the bottom. Same rule applies to the box size pair.
  • up / up N — move up by 1 (default) or N rows
  • down / down N
  • left / left N
  • right / right N
  • col N — move to absolute column N on the current row
  • save — save current cursor position
  • restore — restore the saved position

Styling

  • reset — clear all attributes
  • bold
  • dim
  • underline
  • invert — reverse video
  • <color> or fg <color> — foreground (fg is optional)
  • bg <color> — background (bg is required)

<color> may be:

  • A named color: black red green yellow blue magenta cyan white
  • An integer 0..255 for the 256-color palette
  • A tuple! like 255.128.0 for 24-bit truecolor

Boxes and lines

  • hline N — emit N - characters
  • vline N — draw a vertical run of N | characters
  • box <refinements...> <pair> — draw a box of the given size; cursor returns to the top-left of the box and styles are reset

box accepts any number of optional refinements between the keyword and the closing size pair, in any order:

  • Style — one of single (default), double, rounded, ascii:
    single   ┌──┐    double   ╔══╗    rounded  ╭──╮    ascii   +--+
             │  │             ║  ║             │  │            |  |
             └──┘             ╚══╝             ╰──╯            +--+
    
  • Centering — center (both axes), hcenter (horizontal only, preserves current row), vcenter (vertical only, preserves current column). Centering uses the file-level size pair.
  • outline <color> — color the outline. <color> accepts the same forms as fg / bg.
  • fill <color> — color the interior background. Outline is not tinted.
print gfx [
    cls
    box double outline red fill 17 center 30x10
]

Scrolling

  • scroll up / scroll up N
  • scroll down / scroll down N

Clearing

  • cls or clear screen
  • clear line
  • clear to end — from cursor to end of line

Text

  • "..." — emit a literal string
  • #"x" — emit a literal character
  • newline — emit ^/

Panels

  • panel <start-pos> <end-pos> [<content>] — create a rectangular zone in which all coordinates are relative to the panel's top-left corner, and text strings are word-wrapped to the panel width. The cursor starts at the panel's top-left; content can use any dialect token.
print gfx [
    cls
    panel 5x3 30x10 [
        bold "This long text is word-wrapped to the panel width."
        newline
        box outline blue center 20x6
    ]
]

Coordinates inside the panel are 1-based relative to its top-left. Negative pairs resolve against the panel size. box centering uses panel bounds instead of screen bounds. Panels nest: inner-panel coordinates are relative to the outer panel.

Word wrapping inside a panel preserves repeated and leading whitespace when it fits on a line. When a break is needed, it prefers the last space within the panel width and only drops one leading continuation space after a hard word break.

Terminals provide a single cursor save/restore slot. box and panel multi-line text both use it internally, so an explicit save … restore straddling a box or a wrapped panel string will be clobbered. Save and restore around contiguous code that doesn't itself draw boxes.

Tables

  • table <refinements...> <rows> — draw a bordered, column-aligned table. <rows> is a block of row blocks; each cell may be a string!, integer!, or char!. Column widths auto-fit to the widest cell. The cursor returns to the top-left of the table and styles are reset, same as box.
print gfx [
    cls
    table single center
        headers ["Name" "Age" "City"]
        align [left right left]
        [
            ["Alice"  30 "Praha"]
            ["Bob"    25 "Brno" ]
            ["Carol" 100 "Plzen"]
        ]
]

Refinements (any order, like box):

  • Style — single (default), double, rounded, ascii. Picks both the outer border and the inner T/cross junctions.
  • Centering — center / hcenter / vcenter. Resolved against the enclosing panel when inside one, otherwise against the file-level size.
  • outline <color> — color the border characters.
  • fill <color> — background color for cell interiors. The header row's bold reset clears the fill on header cells; data cells keep it.
  • headers <block> — header row; rendered bold with a junction separator line below.
  • widths <block> — explicit per-column widths. Cells wider than their column are truncated. Columns omitted from the block keep auto-fit.
  • align <block> — per-column alignment: left (default), right, center. Columns omitted from the block default to left.

Tables use the same single cursor save/restore slot as box and wrapped panel text — the caveat above applies.

Errors

If the input contains anything the graphics dialect cannot match, gfx raises a script/invalid-arg error rather than silently producing partial output.

Tests

do %test-gfx.r3
do %test-tui.r3

For a manual runtime smoke test:

do %smoke-tui-events.r3

For a focused editable-field demo:

do %demo-tui-fields.r3