| Did you know ... | Search Documentation: |
| Pack tui -- README.md |

A TUI framework for SWI-Prolog, inspired by TEA (The Elm Architecture): define your app with a model, a view, and an update. Inspired, not a port: components rewrite events as they bubble (possibly into several), and other Prolog-flavoured choices replace parts of TEA where they fit Prolog better.
?- pack_install(tui).
Requires SWI-Prolog 10.0 or later.
:- module(hello, []).
:- use_module(library(tui)).
:- use_module(library(tui/components)).
:- initialization(main, main).
main :-
app((1-3)-none, view, update, Pressed, [mouse(true)]),
format("You pressed ~w.~n", [Pressed]).
view(_, column(["Hello, TUI!",
"",
row([yes : button("Yes"), " ", no : button("No")])])).
update(yes-pressed, _, result('Yes')).
update(no-pressed, _, result('No')).
update(_, S, S).
Run with `swipl hello.pl`, and click a button (or move to it with the arrows and press space or return). The state is (CursorX-CursorY)-Model, here starting with the cursor on Yes and no model. Update returns a new state, or result(R) to end the app with result R (here Pressed). Each button is tagged with a key, so pressing it reaches update as Key-pressed.
View and update are called as by once/1. If either fails, the app ends with the error tui_callback_failed(Which, State) (Which is update(Event) or view), which a catch/3 around app can report, or carry on from State in another app. Hence the last clause of update/3 above, which leaves the state as it is for any other event.
app/5 takes options as a last argument: mouse(true) turns on mouse reporting (see below), and substrate(vcr(Script)) runs the app headless against a script (see Testing).
The pack installs a `swipl tui` command for its examples:
swipl tui examples # list them swipl tui run gallery # run one swipl tui source gallery # print its source
hello — the abovelayout — the core drawablesalign — zones, aligns and tablesevents — a custom component, and tagging instances with Key : Drawablecursor — the default cursor, and placing it from the view with cursor/1gallery — the ready-made components, with the mousemove — dragging with Shift+arrows or the mouse, and moved(Direction)image — an image, through the Kitty graphics protocolasync — events from other threads: a timer and a worker, as subscriptionsatto — a small text editor: `swipl tui run atto FILE`
The examples are in the pack's `examples/ directory. From a clone, use swipl app/tui.pl` in place of `swipl tui`; it runs the examples with the clone's library.The ready-made components drag with Shift+arrows: what is under the cursor moves in the arrow's direction. macOS Terminal.app sends Shift+↑ and Shift+↓ as plain ↑ and ↓ by default. To fix it, in Settings → Profiles → Keyboard add:
terminal_properties(Properties), holding program(Program) (from TERM_PROGRAM, e.g. 'Apple_Terminal', or unknown), extended_keys(Bool) (whether modified keys are unambiguous, via the Kitty keyboard protocol) and graphics(Bool) (whether images can be shown, via the Kitty graphics protocol). examples/move.pl uses it to show this caveat.A single event loop in the main thread reads input, runs update/3, and paints the view. Painting is incremental: the view is grouped into runs of same-styled text, and only runs that changed since the last paint are written.
The loop talks to a substrate: a real terminal, or a VCR that replays a scripted conversation (events in, expected clears and paints out) so apps can be tested without a terminal. Other threads send events to the app's port, a queue with a pipe that the loop waits on alongside the terminal.
Views are built from a term-based DSL:
literal(Term) for any term.> (horizontal), .< (horizontal, right-aligned first), .\/ (vertical), ./\ (vertical, bottom-aligned first)translate(DX-DY, D), X-Y-D, port(W-H, D), zone(W-H, D), align(Direction, D), style(Attrs, D)bold, faint, italic, underline, negative, crossed_out; colours fg(C), bg(C) and the bright hfg(C), hbg(C), C being black, red, green, yellow, blue, magenta, cyan, white, default or an index 0..255; and fg(R, G, B), bg(R, G, B)align directly in it (or in its list) is placed against its edges: up_left, up, up_right, left, center, right, down_left, down, down_right. The view is drawn in a zone the size of the terminal.table(Style, Rows) (empty, space, thin, thick, double; each cell is a zone), wrapped(Width, Chars), clear(W-H), line(Char, From, To), box(W-H, Chars) (thin, thick, double, or your own characters), hover(Style, D), cursor(D):- use_module(library(tui/components))): check_box/2, button/1, input/3, frame/2, titled_frame/3, scroll/3Key : Drawable — attaches an identity for event routing
With app(State, View, Update, Result, [mouse(true)]), the terminal reports the mouse. Events are (X-Y)-mouse(Action, Button) (Action press, release, drag or move; Button left, middle, right, ...) and (X-Y)-mouse(scroll, Dir), with modifiers as for keys (ctrl+mouse(press, left)). A mouse event bubbles at its own position, so components see it relative to themselves; a press also moves the cursor there. The ready-made components take clicks, the wheel, and drags on a scrollbar. While reporting is on, select text with shift held (option on macOS). Off by default.
In terminals with the Kitty graphics protocol (Kitty, Ghostty), image(Id, Source, W-H) shows an image in W×H cells. Source is png(Bytes), rgba(PW-PH, Bytes), rgb(PW-PH, Bytes) (Bytes being octets, as read from a binary stream) or file(Path). The image is uploaded once, and its cells behave like text: they can be clipped, overdrawn and erased. Draw images only once update/3 receives terminal_properties(Ps) with graphics(true) in Ps. See examples/image.pl.
An app declares what should be sending it events as subscriptions, a list computed from its state, like its view: app(State, View, Update, Result, [subscriptions(Subs)]) calls call(Subs, State, List) before each wait for events. Each subscription runs while it is in the list; the loop starts those the list gains and stops those it loses.
every(Ms, Event): Event every Ms millisecondsthread(Goal): call(Goal, Port) in a thread of its own, sending any number of events with port_send(Port, Event). If it ends by itself, update receives thread_ended(Goal, Status).
subscriptions(_-model(Ticking, _), Subs) :-
( Ticking == true
-> Subs = [every(1000, tick), thread(worker)]
; Subs = [thread(worker)]
).
worker(Port) :-
thread_self(Me),
port_send(Port, worker_ready(Me)), % update can now send it messages
...
Update may have side effects, such as sending a message to a thread, but must not block: an answer comes back as an event. See examples/async.pl.
Define components via multifile hooks:
tui:alias_drawable(my_widget(Label), style([bold], Label)). tui:at_event_is(my_widget(_), _X-_Y-return, my_widget-activated).
A component sees each event with where it happened, relative to the component. It may rewrite an event in several ways (for example a new value, and right to advance the cursor); each reaches update/3 in turn.
Each screen coordinate tracks which components produced it (the alias stack). When an event occurs, it bubbles through the stack — each component can rewrite it via at_event_is/3. The : tag operator namespaces events by component instance.
app(State, View, Update, Result, [substrate(vcr(Script))]) runs an app without a terminal, against a script of what it receives and what it should paint:
send(Events): the next batch of events the app receivesexpect(clear): the app clears the screen (it does on start and on every resize)receive(N): the next batch is the next N events sent by the app's subscriptions (waiting for them, at most 5 s each)expect(runs(Runs)): the app paints exactly Runs, or anything if Runs is unboundexpect(graphics(Actions)): the app sends exactly these image uploads and placements, before the runs of the same paint
Entries are consumed in order. A run is (X-Y)-(Attrs-Chars): 1-based screen coordinates, the style attributes, and the characters as a list of one-character atoms. Painting is incremental, so after the first paint Runs holds only the runs that changed (each whole), and erased cells appear as spaces. The cursor's cell is painted negative. Once the script is used up, every request for events gets [eos], which a test app typically answers with result(...). The terminal's properties are not reported unless the script sends them.
:- use_module(library(tui)).
:- use_module(library(plunit)).
view(_-N, row(["Count: ", literal(N)])).
update(up, C-N0, C-N) :- !, N is N0 + 1.
update(eos, S, result(S)) :- !.
update(_, S, S).
:- begin_tests(counter).
test(counts) :-
Script = [ send([terminal_size(20, 5)]),
expect(clear),
expect(runs([(1-1)-([]-['C', o, u, n, t, :, ' ', '0'])])),
send([up, up]),
expect(runs([(1-1)-([]-['C', o, u, n, t, :, ' ', '2'])]))
],
app(_-0, view, update, Result, [substrate(vcr(Script))]),
Result = _-2.
:- end_tests(counter).
Anything the app does that differs from the next entry throws error(vcr_mismatch(Expected, Actual), _), with Actual written as the entry that would have matched. So you can also write an expectation as a placeholder, read the actual one from the failure, and check it before pasting it in.
After loading the pack, browse the full API docs:
?- doc_server(4000). ?- use_module(library(tui)).
Then open http://localhost:4000.
MIT — see [./LICENSE](LICENSE).
I designed and prototyped tui's core: the drawable DSL and commands, alias_drawable/2 components, at_event_is/3 rewriting and event bubbling through alias stacks. Commit `30db48f`, "pre claude refactor", is that prototype. Since then, most of the code has been written by Claude Code (Anthropic) under my direction: I set the architecture and requirements, made the design decisions (often choosing among or amending designs Claude proposed), and gave specific implementation guidance. The work is interactive and incremental: one change at a time, design changes discussed before they are made, and every change reviewed before the next. Commits with an LLM contribution carry a Co-Authored-By: Claude trailer. The library has headless tests, run by CI on the stable and development releases of SWI-Prolog, and its terminal features have been checked by hand in real terminals.