The terminal backend for glimmer, the reactive GUI toolkit for jolt.
glimmer owns the portable half — reactive cells, the component model, the
reconciler — and knows nothing about any toolkit. This project supplies the other
half for a terminal: a widget set, box layout, painting through ncursesw, and an
input loop with keyboard focus, scrolling and mouse support. Requiring
glimmer-tui.core registers it, and the same components that render as GTK
widgets under glimmer-gtk render as
text.
(ns myapp
(:require [glimmer.ratom :as r :refer [atom]]
[glimmer.core :as ui]
[glimmer-tui.core :as tui]))
(defn counter []
(let [count (atom 0)]
(fn []
[:vbox {:spacing 1 :margin 2}
[:label {:label (str "Count: " @count) :bold true}]
[:hbox {:spacing 2}
[:button {:label "- 1" :on-click #(swap! count dec)}]
[:button {:label "+ 1" :on-click #(swap! count inc)}]
[:button {:label "quit" :on-click tui/quit!}]]])))
(defn -main [& _] (ui/run counter)) Count: 2
[ - 1 ] [ + 1 ] [ quit ]
Components, reactive state and reconciliation are documented in glimmer's README. What follows is the terminal-specific part.
None to install, in the usual case. The backend binds ncursesw and uses only
the ncurses 6.0 API, which macOS ships in the dyld shared cache (6.0.20150808)
and every Linux distribution ships as libncursesw.so.6. On a minimal container
you may need apt install libncursesw6, since the base system only guarantees
libtinfo6.
Nothing newer is bound on purpose: the 6.1 extended-colour entry points are
absent from Apple's build. That is not a limit on what you can write — a #ff6432
prop works fine (see Colour) — only on how it reaches the terminal.
jolt 0.7.24 or newer, though, because of how jolt is linked rather than
anything either project does at runtime. Chez's expression editor links ncurses
into the jolt binary, and until 0.7.24 those symbols were exported: the
executable is searched before any library loaded through the FFI, so the ncurses
this backend binds had its own internal calls bound back into the kernel's older
copy. The result is initscr failing with "Error opening terminal" on a terminal
that works everywhere else, or a segfault inside it — see
jolt#728. There is nothing to work
around here; the fix is the newer jolt.
jolt test # the suite, headless: no terminal, tty or display needed
jolt counter # the counter demo
jolt todo # a task board: entry, checkbuttons, framed keyed list
jolt showcase # every widget: list, table, scrolling text, dialog, gauges
jolt smoke # non-interactive check against a real terminalElements are [:tag props? & children], as everywhere in glimmer. Strings and
numbers become labels, nil children are skipped, seqs are spliced.
Containers
| tag | holds | notes |
|---|---|---|
:window |
one child | the root |
:box |
many | :orientation :horizontal|:vertical |
:hbox / :vbox |
many | the same box, orientation implied |
:frame |
one child | a border, with an optional :label set into the top edge |
:scroll |
one child | a viewport onto something bigger — see Scrolling |
:overlay |
one child | floats over the screen — see Overlays |
Widgets
| tag | shows |
|---|---|
:label |
text; a newline makes it taller |
:button |
[ label ], activated by Enter, Space or a click |
:entry |
a single-line text field |
:checkbutton |
[x] label |
:separator |
a rule along its :orientation |
:listbox |
a list with a cursor, scrolling itself to keep it visible |
:table |
columns sized to their contents, with a header and a row cursor |
:progress |
a progress bar |
:spinner |
one frame of an animation — see Timers |
:paginator |
○●○ or 2/3 |
:help |
the bindings of whatever is focused — see Keys and focus |
Common props (every widget)
:margin/:padding— a number,[vertical horizontal], or[top right bottom left]; or the per-edge:margin-start,:padding-top, …:halign/:valign—:fill(the default),:start,:center,:end:hexpand/:vexpand— boolean; expanders split the leftover space on that axis:width-request/:height-request— a floor on the natural size:color/:bg— see Colour:bold,:dim,:underline,:reverse,:blink— booleans:sensitive false— dims the widget and takes it out of the tab order:autofocus true— start with the focus here rather than on whatever is first
Per-tag props
- Label:
:label/:text,:align - Button:
:label,:brackets ["[ " " ]"] - Entry:
:text,:placeholder,:echo :normal|:password|:none,:echo-char,:char-limit,:width-request,:keys,:on-paste - Checkbutton:
:label,:active,:checked-mark,:unchecked-mark - Frame:
:label,:label-align,:border,:border-color - Separator:
:orientation,:char - Scroll:
:orientation :vertical|:horizontal|:both,:scrollbar,:keys - Overlay:
:anchor,:offset-x,:offset-y,:modal,:on-close - Listbox:
:items,:selected,:descriptions,:wrap,:cursor-prefix,:item-prefix,:keys - Table:
:columns,:rows,:selected,:header,:gap,:row-style,:keys - Progress:
:value(0.0–1.0),:bar,:bar-color,:show-percent - Spinner:
:tick,:frames,:label - Paginator:
:page,:total-pages(or:total-items+:per-page),:style - Help:
:bindings,:labels,:separator,:full,:ellipsis
A :columns entry is {:title "name" :key :name :width 12 :align :end :style {:fg :green}}; :width, :align, and :style are optional, and rows may
be maps or vectors. Table :row-style and column :style each accept a static
style map or a callback. The callback receives one context map and returns a
style map or nil.
A row callback receives :row, :index, :selected?, and :focused?. A cell
callback also receives :column, :column-index, and the original :value.
:index always refers to the complete :rows vector, including after scrolling.
Row keys such as :color have no built-in meaning; applications can explicitly
map such values to style attributes. Invalid style values and callback results
add no attributes.
Styles resolve in this order: common table style, row style, cell style, then the
selection modifier. A focused selected row adds :reverse true; an unfocused
selected row adds :bold true. Selection retains colors and other resolved
attributes.
Events
:on-click— button activated. No args.:on-change— entry text changed. Receives the new text.:on-activate— Enter pressed in an entry, list or table. No args.:on-toggled— checkbutton activated. No args.:on-select— list or table cursor moved. Receives the index and the item.:on-scroll— a scroll container moved. Receives{:x :y}.:on-close— Esc pressed while a modal overlay is up. No args.:on-key— on a container: a key nothing inside it wanted. Receives the event; return truthy to consume it.
As in glimmer-gtk, a handler owns the state: :on-toggled flips the cell the
component reads, and :active comes back down as a prop. Widgets never toggle
themselves, and a list whose :on-select is ignored still works — it falls back
to its own cursor.
A colour prop is any of
:red :bright-blue :default ; the sixteen ANSI names
208 ; an index into the xterm 256-colour palette
"#ff6432" "#f64" ; a hex triple
[255 100 50] ; r/g/bEverything is reduced to a palette index before ncurses sees it, and then folded
down again to what the terminal actually reports (tigetnum "colors"): the
closest entry in the 256-colour cube, then in the sixteen, then in the eight. So
a hex prop is not a portability decision — the UI keeps working on a 16-colour
tty, it just stops being exact. :default (and no colour at all) means the
terminal's own foreground or background, which is what keeps a glimmer UI
transparent over the user's theme instead of painting it black.
Frames take a border set: :normal (the default), :rounded, :thick,
:double, :block, :outer-half-block, :inner-half-block, :ascii,
:hidden, :none, or a map of the eight parts.
[:frame {:label "results" :border :rounded :border-color "#5f87af" :padding 1}
[:label {:label "…"}]]| Key | Effect |
|---|---|
Tab / Shift-Tab |
move focus forward / back, wrapping — unless a widget or an ancestor's :on-key claims the key first |
Enter / Space |
activate the focused widget |
arrows, j/k, pgup/pgdn, ctrl-u/ctrl-d, g/G |
navigate a list, table or scroll |
printable, Backspace, Delete, arrows, Home/End |
edit the focused entry |
ctrl-a/ctrl-e, ctrl-w, ctrl-u/ctrl-k, alt-b/alt-f |
readline editing in an entry |
| a paste | inserted into the focused entry in one piece, never as keystrokes |
Esc |
close the topmost modal overlay |
ctrl-c / ctrl-q |
quit (configurable with :quit-keys) |
| mouse button 1 press | focus and activate (or select the row) under the pointer |
| a drag (press, then motion, then release) | not acted on by the loop; it reaches :on-mouse |
| mouse wheel | scroll the container under the pointer |
Anything past ASCII arrives decoded: a multi-byte UTF-8 sequence is one
character, typed or pasted, so 한 is 한 and not í followed by two
strays.
Keys are named rather than numbered. glimmer-tui.keys/decode turns an ncurses
key code into an event — {:type :page-up}, {:type :ctrl :ch \u} — and
match? compares one against a binding written the way a person would say it:
(k/match? event "ctrl+u")
(k/match? event :page-up)
(k/match? event ["end" "G"])Every widget that answers to keys declares its bindings that way, so they can be
overridden per widget with :keys, and rendered:
[:listbox {:items rows :keys {:down ["down" "n"]}}]
[:help {}] ; renders whatever the focused widget answers toThe :help widget with no :bindings of its own reads them off the live tree,
which means the help bar cannot drift from what the keys actually do.
A key is offered to the focused widget first, then to each of its ancestors —
which is how Page Down reaches the scroll container a focused button happens to
be sitting in, and how an application binds a key of its own:
[:vbox {:on-key (fn [e] (when (k/match? e "d") (open-dialog!) true))}
...]Only what nobody wanted becomes a quit key, an Esc that closes a dialog, or an
Enter that presses a button. That ordering is deliberate: it means q can be a
quit key in an app that also has a text field, because the field sees it first.
Pasted text is not typing, and the backend does not pretend it is. The terminal
is put into bracketed-paste mode, so a paste arrives wrapped in CSI 200~ …
CSI 201~ and is decoded into a single event:
{:type :paste :text "at foo\nat bar"}It is dispatched like a key — the focused widget first, then its ancestors — so
an :entry inserts the whole thing at the caret, and a pasted newline is a
character rather than a Return that submits half a stack trace. A field that
wants to decide for itself takes :on-paste, which is given the text as pasted,
line breaks and all, instead of it being inserted:
[:entry {:text @draft
:on-change #(reset! draft %)
;; this app keeps the line breaks and shows the field a stand-in
:on-paste (fn [text]
(reset! attachment text)
(reset! draft (str @draft "<pasted>")))}]Without an :on-paste the control characters in a paste are flattened to
spaces, because a one-line field has nowhere to put a line break and ncurses
would act on it rather than draw it.
A big paste over a slow link can stall mid-stream — longer, sometimes, than the
100ms a read inside a paste waits before deciding the end marker is never
coming. When that happens the paste is split: the first half arrives as a
:paste that ended by timeout rather than its marker, and the rest arrives as
ordinary keys, its line breaks as Return. The event says which happened:
{:type :paste :text "half a stack tr" :cut? true}ui/run takes :paste-timeout-ms for exactly this. Raise it and a paste that
stalls rides the stall out instead of being cut:
(ui/run app :paste-timeout-ms 500)Or leave the timeout alone and watch :cut? in a widget's key handling: a
paste so marked is the first part of one whose rest is about to arrive as
keystrokes, so an app that submits on Return can hold the half-paste back until
something whole is in hand.
:autofocus true says which widget starts focused. It is worth more in a
terminal than it sounds: tree order gives the focus to whatever is highest on the
screen, usually a filter field, and a focused field swallows every letter — so an
application's single-key bindings would be dead until the user pressed Tab.
The focus ring is recomputed from the widget tree on every frame, in tree order, so a component that renders a new button gets a sensible tab position with no registration step. Focus follows the widget id rather than a position, which means a keyed list can reorder around the focused row and you stay on the same row. When the focused widget disappears, focus falls back to the first one left.
Focus lives in this project rather than in glimmer's core because it is a property of how a toolkit is driven, not of the component model — under GTK, focus comes from GTK.
:scroll gives its child the full height the child asked for and shows a window
onto it. Nothing is re-measured while scrolling, so a thousand-row list costs
what a ten-row one does.
[:scroll {:vexpand true}
(into [:vbox {}] (for [line lines] [:label {:label line}]))]A scroll takes keyboard focus only when nothing inside it can — a text viewport
is the thing you are driving, a scroll around a form is not. Either way its
bindings work, because a key the focused widget declines bubbles out to it, and
moving focus scrolls the focused widget into view: Tab into a field below
the fold brings it back on screen. Scrolling by hand does not drag the focus
along with it, so the wheel behaves the way a wheel should.
A scrollbar is drawn in the last column whenever there is more content than
viewport; :scrollbar false gives the column back.
Layout gives every node two sizes: :natural, what it would like, and :min,
what it can survive on. They are the same for most widgets — a label cannot be
shorter than its text — but a widget that handles its own overflow says so, and a
box short of room takes the shortfall from those first, in proportion to what
each has to give, before anything is clipped. That is what makes a scroll worth
having: without it the scroll's content height would come out of the box it sits
in and push the footer off the bottom, which is the problem it exists to solve.
:listbox and :table shrink the same way, down to a row (a table keeps its
header). A :height-request is a floor on both numbers, so asking for four rows
gets four rows even when space is short.
An :overlay is written where it belongs in the component that owns it, and laid
out against the screen: it takes no space at its declaration site and is not
clipped by the box it was declared in.
(when @confirming?
[:overlay {:anchor :center :on-close #(reset! confirming? false)}
[:frame {:label "confirm" :border :double :padding 1}
[:vbox {:spacing 1}
[:label {:label "Delete the branch?"}]
[:hbox {:spacing 2 :halign :center}
[:button {:label "yes" :on-click delete!}]
[:button {:label "no" :on-click #(reset! confirming? false)}]]]]])While a modal overlay is up (the default) the focus ring is restricted to it, so
Tab cannot wander back into the page underneath, and Esc calls :on-close.
Overlays paint last, in declaration order, so a later one sits on top.
Nothing animates itself. The loop wakes up every :tick-ms anyway, so a timer is
a due time and a thunk, and the thunk runs on the loop thread — the only
thread allowed to touch widgets:
(let [tick (atom 0)]
(tui/every! 80 #(swap! tick inc))
(fn [] [:spinner {:tick @tick :label "fetching"}]))after! fires once, every! repeats, cancel! stops one and the loop cancels
everything when it exits. A widget that ran its own timer would repaint whether
or not anything was looking at it, and would have to be told to stop.
One exception used to end the whole session: the loop had no catch between the
key handlers, the timers, the queued re-renders and the paint, so a single
throwing handler took an editor or a chat down with it. Pass :on-error to
ui/run and a fault is handed to you instead:
(ui/run app
:on-error (fn [throwable phase]
;; phase is :key, :timer, :render or :paint
(log/error throwable "fault in" phase)))The loop keeps going. A key handler that throws costs that one key; a throwing timer costs that one tick — the timers behind it still fire, and a repeating one keeps its schedule. A failed paint leaves the terminal on the last good frame (the new one is never presented) and is retried on the next change, so a view that throws on every render reports the same fault as often as the state changes, not thirty times a second.
Each queued re-render and each due timer is caught on its own, so a fault costs
only itself. Without :on-error a fault ends ui/run as it always has, so
nothing changes for an app that relies on that. The handler itself runs
unguarded, so an :on-error that throws ends ui/run with that exception;
keep it to logging or setting state. tui/attach! takes the same
handler as an optional third argument, which is how the fault paths are tested
headlessly.
The loop's own mouse handling is two gestures: a left press activates whatever
is under the pointer, and the wheel scrolls whatever is under it. A drag — press,
motion, release — and a release on its own are not acted on, because what they
mean is the application's to decide: text selection, click-on-release, drag
panels around. Every decoded mouse event is offered to :on-mouse before the
loop acts on it, and a truthy return claims it:
(ui/run app
:on-mouse (fn [{:keys [button action motion x y]}]
(when (and (= 0 button) (= action :release) (not motion))
(do-something-with x y))))Return nil (or leave the handler off) and the loop behaves as it always did. A
handler that throws reaches :on-error under the :mouse phase and the event
reads as unclaimed. Motion reports only arrive from a terminal with drag
reporting (mode 1002, enabled alongside the others); a terminal without it
sends no drags and nothing is lost.
Painting goes through glimmer-tui.screen, which is a map of functions with two
implementations: ncurses, and an in-memory grid. glimmer-tui.core/attach!
points a session at any screen, so a whole interactive app can be mounted,
painted, typed into and clicked in a plain unit test:
(let [root (w/node :window {})
screen (scr/buffer-screen 30 8)]
(tui/attach! root screen)
(ui/mount root :window [my-app])
(tui/frame!)
(tui/press! 9) ; Tab
(tui/press! 10) ; Enter
(tui/frame!)
(scr/lines screen)) ; => ["count 1" "" "[ add ]" ...]That is how this project's own suite works — the reconciler, layout, focus,
scrolling, overlays, entry editing and keyed reordering are all asserted on
rendered text, with no tty. jolt smoke covers the part that genuinely needs a
terminal.
run-async starts a UI on a background thread and hands back {:quit! :result},
for driving one from a REPL. The REPL and the UI then share a terminal, so use
tap> rather than println while it is up: anything printed lands in the middle
of the frame.
Widths are measured in grapheme clusters, not characters. 👍🏽 is one cluster
two cells wide rather than a thumb plus a stray skin tone, 🇯🇵 is one flag, and
truncate will never cut between the two. The table is a deliberate
approximation of wcwidth(3) — chosen over an FFI call in the layout path, and the
locale dependence that comes with it — with one departure: a cluster carrying
U+FE0F is two cells, because that is what a terminal draws even where wcwidth
reports one.
Text that is entirely printable ASCII skips all of that: it cannot hold a combining mark, a wide glyph or an emoji, so its width is its length and a cut is an index. That is worth measuring, because a repaint measures the same label several times — during layout, again while clipping, again while aligning — and on an English UI every one of those is this case. A full screen of text went from 19ms a frame to 1.7ms, against a 30ms tick.
glimmer-tui.ffi— ncursesw and libc bindings, key codes, attribute bits. No logic.glimmer-tui.text— grapheme clustering, display width, truncation.glimmer-tui.color— colour props to palette indices, and down to what the terminal has.glimmer-tui.border— the box-drawing sets.glimmer-tui.keys— key codes to named events, bracketed paste, SGR mouse reports, and matching against bindings.glimmer-tui.screen— the paint surface: ncurses, an in-memory grid, or either one clipped to a rectangle.glimmer-tui.widget— the widget registry and the node tree. Creating and patching widgets is pure data; nothing touches the terminal.glimmer-tui.widgetsand the namespaces under it — the built-in widgets, each one a spec in that registry. A consumer adds its own the same way.glimmer-tui.layout— measure and arrange, pure functions over snapshots.glimmer-tui.render— paint a laid-out tree onto a screen, clipping each node to its parent and hoisting overlays to the top.glimmer-tui.curses— terminal lifecycle, colour pair allocation, the private terminal modes (paste, mouse), and input.glimmer-tui.core— the backend map, the event loop, focus, hit testing, scrolling and timers.
Beta. The widget set covers what a terminal application usually needs and the
reconciler, layout, focus, scrolling, overlays and input paths are covered by the
headless suite; the ncurses path is covered by jolt smoke.
Known limits. The wheel is read from SGR mouse reports (the terminal's
1000/1002/1006 modes) rather than from ncurses, whose mouse ABI differs by
build and, in the version stock macOS ships, has no wheel-down at all. Turning
the modes on is unconditional, so a terminal that does not speak them simply
gets no mouse — the keyboard bindings are still the fallback. Colour is
indexed, never 24-bit on the wire.
On hostile environments. usable-terminal? checks the three conditions that
normally stop a UI from starting (no tty, no TERM, no terminfo entry), and
ui/run raises rather than proceeding when one of them fails. It cannot promise
more than that, because initscr reports failure by calling exit() rather than
returning — so a fourth condition nobody checked for looks exactly like a crash.
That is what the smoke step was hitting, and this README used to blame the CI
runner for it. It was not the runner. Every jolt binary before 0.7.24 exported
the ncurses its own kernel is linked against, which took priority over the one
this backend loads and left initscr unable to read a terminfo entry it should
have had no trouble with (see Requirements). CI reported the
step as skipped because "Error opening terminal" is indistinguishable from a
machine that genuinely cannot host a UI. On a jolt with the fix, jolt smoke
passes on a real terminal — timers, scrolling, borders, wide glyphs and key
dispatch included.
MIT (see LICENSE).