A reagent-style API over FTXUI, the C++ terminal UI library, for jolt.
You write components as functions returning hiccup; ftxui-jolt renders them through FTXUI's own component tree, with FTXUI's focus handling, mouse support, input editing and event loop underneath.
(ns myapp
(:require [ftxui.core :as ui :refer [atom]]))
(defn counter []
(let [n (atom 0)]
(fn []
[:vbox {:border :rounded}
[:text {:bold true} " Count: " @n " "]
[:separator]
[:hbox
[:button {:label "-1" :on-click #(swap! n dec)}]
[:button {:label "+1" :on-click #(swap! n inc)}]
[:button {:label "quit" :on-click ui/exit!}]]])))
(defn -main [& _] (ui/run counter :mode :fit-component))╭──────────────────────╮
│ Count: 0 │
├──────────────────────┤
│┌──┐┌──┐┌─────┐┌────┐ │
││-1││+1││reset││quit│ │
│└──┘└──┘└─────┘└────┘ │
╰──────────────────────╯
FTXUI has no retained widget tree to patch. Its loop calls Render() on a
component tree after every event, and Render() rebuilds the DOM from scratch.
That is already reagent's model — a render function re-run whenever something
changes — so this library needs no reactive cells and no reconciler of its own:
- Elements (
:text,:vbox,:border,:gauge, …) are FTXUI DOM nodes, built fresh on every frame from whatever your component returned. - Widgets (
:button,:input,:menu, …) are FTXUI components. They hold state — focus, a cursor, a selection — so they are created once at their position in the tree and updated from their props after that. - Layout is the focus tree. A
:vboxor:hboxwith more than one focusable descendant becomes an FTXUI container, so arrows and Tab move through the UI the way it is laid out. You never wire containers by hand. - State is plain atoms. A handler that swaps one is followed by a redraw,
because every event ends in a draw. State changed from anywhere else — a
timer, another thread, the REPL — reaches the screen through
refresh!, or automatically when it lives in anftxui.core/atom.
Nothing here depends on glimmer; this is the same reagent shape over FTXUI's reactivity rather than glimmer's.
FTXUI is C++, so the FFI binds a small C shim (native/ftxui_jolt.cpp) with
FTXUI linked into it statically. Building it needs cmake (3.14+) and a
C++17 compiler; nothing else is required at runtime.
jolt native # builds native/libftxui_jolt.{dylib,so}; re-run after editing the shimFTXUI itself is fetched from GitHub at a pinned commit and built as part of
that step. To use a checkout instead, point FTXUI_SOURCE_DIR at it
(FTXUI_SOURCE_DIR=~/src/FTXUI jolt native); an installed FTXUI 7 found by
find_package is used as well. The test and example tasks depend on
native, so a fresh checkout only needs jolt test.
jolt test # the suite, headless: real FTXUI widgets, no terminal needed
jolt counter # the counter above
jolt todo # a task board: input, keyed checkbox list, derived counts
jolt showcase # every element and widget, a modal, a background ticker
jolt smoke # runs a real loop for a second, clicks a button, exitsTwo shapes, as in reagent:
- Form-1 — a function returning hiccup. Re-run on every frame.
- Form-2 — a function returning a render fn. The outer fn runs once when the component appears (so local atoms persist); the returned fn renders.
Components are invoked as [my-component arg ...]. Their identity is their
position in the tree (or their :key), so a component that disappears and
comes back starts fresh, and a keyed row keeps its widgets and local state
across reorders:
(for [t @tasks] ^{:key (:id t)} [task-row t])Elements are [:tag props? & children]. Strings, numbers and keywords become
text; nil children are skipped and seqs are spliced, so (when ...) and
(for ...) work as children. A component must return a single element (wrap
a seq in [:vbox ...]).
| tag | children | notes |
|---|---|---|
:text |
strings, concatenated | [:text "n=" @n] |
:vtext |
strings | vertical text |
:paragraph |
strings | wraps words; :align :left/:right/:center/:justify |
:wrapped |
strings | text as it is, whitespace kept, soft-wrapped at the width given: after a space where it can, inside a word too long for the row |
:separator |
— | :style (a border style) or :char "·"; orients itself |
:gauge |
— | :value 0–1, :direction :right/:left/:up/:down |
:spinner |
— | :charset (0–22), :index — one frame; advance :index yourself |
:filler :empty |
— | expandable blank / nothing |
:hbox :vbox :dbox |
elements | horizontal, vertical, stacked (:dbox draws later children over earlier) |
:stack |
elements | a :dbox whose layers are a stacked focus container — for :floating-windows |
:hflow :vflow |
elements | wrapping flows |
:flexbox |
elements | :direction :row/:column(-inversed), :wrap, :justify, :align-items, :align-content, :gap [x y] (CSS flexbox names) |
:gridbox |
— | :rows [[cell ...] ...], cells are hiccup |
:table |
— | :rows, :border style, :header true, :separators :vertical/:horizontal/:both |
:border |
one or more | :style :light/:dashed/:heavy/:double/:rounded/:empty, :color |
:window |
one or more | :title (string or hiccup), :style |
:canvas |
— | :width/:height in pixels, :draw (below), :style :braille/:block |
A wrapper given several children lays them out as an :hbox first.
:draw is a list of drawing ops, so a picture stays a plain function of the
state that produced it:
[:canvas {:width 40 :height 24
:draw [[:line 0 0 39 23 {:color :red}]
[:circle 20 12 8 {:filled true}]
[:ellipse 20 12 16 6]
[:point 3 4 {:value :toggle}]
[:text 0 0 "hi" {:color :blue}]]}]Ops are [:point x y], [:line x1 y1 x2 y2], [:circle x y r],
[:ellipse x y rx ry] and [:text x y s], each taking an optional trailing
props map: :color, :style (:braille, the default, or :block), :value
(true, false or :toggle, for points) and :filled (circles, ellipses).
Coordinates are pixels, not cells: a cell holds 2x4 braille pixels or 2x2
block ones, so a 40x24 canvas is 20 columns by 6 rows. :text is drawn in
whole cells, so its x is a multiple of 2 and its y a multiple of 4.
Every element and widget accepts these props; the matching tags
([:bold ...], [:center ...], …) are sugar for the same thing:
:bold :dim :italic :inverted :underlined :underlined-double :blink :strikethrough— booleans:color/:bg— see Color:border—true, a style keyword, or{:style ... :color ...}:width/:height—n,[:<= n],[:>= n]or[:= n]:flex—true,:grow,:shrink,:x,:y,:x-grow,:x-shrink,:y-grow,:y-shrink,:none:frame—true,:xor:y: a viewport that scrolls to keep the focused element visible:scroll-indicator—:vor:h:align—:center,:hcenter,:vcenteror:right:focus—trueor a cursor shape (:block,:bar,:underline, each also-blinking):clear-under,:automerge— booleans;:hyperlink— a URL
Decorators apply inner to outer in the order listed, so {:color :red :border true}
colors the text but not the border. Nest explicit tags for a different order:
[:color {:fg :red} [:border ...]].
| tag | props | events |
|---|---|---|
:button |
:label (or a string child), :style :simple/:ascii/:border/:animated |
:on-click |
:input |
:value, :placeholder, :password, :multiline, :wrap (soft-wrap long lines at the width given; the box is as tall as its rows, and focus shows as the cursor rather than reverse video) |
:on-change (text), :on-enter (text) |
:checkbox |
:label, :checked |
:on-change (boolean) |
:menu |
:entries, :selected, :direction :down/:up/:left/:right, :style :plain/:animated/:toggle |
:on-change (index), :on-enter (index) |
:toggle |
:entries, :selected |
as menu |
:radiobox |
:entries, :selected |
:on-change (index) |
:dropdown |
:entries, :selected, :open |
:on-change (index) |
:slider |
:value, :min, :max, :increment, :direction, :color, :color-inactive |
:on-change (value) |
:collapsible |
:label, :show; one child subtree |
:on-change (boolean) |
:modal |
:show; two children: main, dialog |
— |
:maybe |
:show; one child subtree |
— |
:resizable-split |
:direction :left/:right/:up/:down, :size (cells), :min, :max; two children |
:on-change (size) |
:hoverable |
one child subtree | :on-change (boolean) |
:scroll |
:top (first row shown; nil follows the bottom), one child subtree; the wheel over it scrolls three rows |
:on-change ({:top :max :rows}) |
:floating-window |
:title, :left, :top, :width, :height, :resize; one child subtree |
:on-change ({:left :top :width :height}) |
:catch-event |
:on-event; the children it guards |
:on-event (event map) |
Any widget also takes :autofocus true to start with the focus, and :key.
Floating windows. A :floating-window is dragged by its inside and
resized by its edges (:resize false, or a map of :left :right :top :down,
takes an edge out). Several of them belong in a [:stack ...], which is the
container FTXUI wants them in; they are drawn in the order they appear, the
last on top, and that is the one a click in an overlap reaches. Raise a window
by moving it in your own list, the way you would reorder any other children.
Controlled props. :value, :checked, :selected, :show follow the
reagent contract — as do :size on a split and a floating window's geometry.
What the prop says each frame is what the widget shows. The
widget fires :on-change with the value the user produced; if the handler does
not write it back, the next frame restores the prop's value. Leave the prop out
for an uncontrolled widget that keeps its own state.
Focus. Arrows move within a layout (Up/Down in a :vbox, Left/Right in an
:hbox), Tab and Shift-Tab cycle. A widget that uses a key itself keeps it — a
menu takes Up/Down until its ends, a radiobox takes Tab to cycle its entries —
exactly as in FTXUI.
An :on-event handler (on run, or a :catch-event) receives a map and
returns truthy to consume the event:
{:type :key :key :arrow-down :input "\e[B"} ; :return :tab :escape :f1 ... :ctrl-a :alt-x
{:type :character :char "q" :input "q"}
{:type :mouse :button :left :motion :pressed :x 3 :y 4 :shift false :meta false :control false}
{:type :custom}:red, :green, :yellow, :blue, :magenta, :cyan, :white, :black,
:gray-light, :gray-dark and the -light variants; a 0–255 palette index;
[:rgb r g b]; or "#ff8800" / "#f80". :default (or nil) is the
terminal's own.
A linear gradient stands in for a color wherever one is accepted:
[:text {:color [:gradient :red :blue]} "..."] ; evenly spread
[:text {:bg {:angle 45 :stops [[:red 0.0] :yellow [:blue 1.0]]}} "..."]The vector form takes colors only; the map form takes an :angle in degrees
and :stops, each a color or a [color position] pair with a 0–1 position.
Stops left unplaced are spread evenly between their neighbours.
(run component & opts)— mount and run the loop on the calling thread untilexit!or Ctrl-C. Options::mode(:fullscreendefault,:fit-component,:terminal-output,:fixedwith:width/:height,:fullscreen-alternate,:fullscreen-primary),:mouse false,:on-event,:auto-exit-ms, and:async trueto run on another thread and return a future.(exit!)— stop the loop, from any thread.(refresh!)— redraw after a state change made outside a handler (thread-safe).(atom x)— a clojure atom that callsrefresh!when it changes.(post-key! :return),(post-char! "abc")— inject events into the running loop.(reload!)— remount every component (after redefining them at the REPL).
Errors thrown by a handler or a render stop the loop and are rethrown by run.
The same machinery runs without a terminal, which is how the tests drive real FTXUI widgets:
(ui/with-screen [s counter]
(ui/render-text s 30 5) ; the next frame, as text
(ui/send-key! s :return) ; deliver a key; then prepare the next frame
(ui/send-char! s "abc")
(ui/send-mouse! s {:button :left :motion :pressed :x 2 :y 1})
(ui/refresh! s) ; sync state changed outside a handler
(ui/stats s)) ; {:components 3 :containers 1 ...}
(ui/render-text [:border "hi"] 6 3) ; bare hiccup renders too
(ui/render-ansi [:bold "x"] 1 1) ; with escape codesFTXUI picks its color depth from TERM / COLORTERM the first time it
renders, so what render-ansi returns depends on the terminal the test runs
under. (ftxui.ffi/set-color-support 3) pins it (0 monochrome, 1 the 16 ANSI
colors, 2 the 256 palette, 3 true color); the suite does this before its first
assertion on an escape sequence.
Under jolt nrepl-server, (ui/run app :async true) returns right away and
the UI runs on its own thread. Mutate an ftxui.core/atom and the screen
redraws; after redefining components, (ui/reload!) remounts them in place.
The TUI takes over the terminal the server was started from, so evaluate from
an editor connected to the nREPL port.
native/ftxui_jolt.cpp— the C ABI over FTXUI: a per-frame arena of element handles, component slots keyed by id, the app loop, and three callbacks into jolt (render, action, event).native/ftxui_jolt.hdocuments it.ftxui.ffi—defcfnbindings to the shim. No logic.ftxui.color,ftxui.keys— the color and key vocabularies.ftxui.dom— element tags and the universal decorators; builds the DOM from a prepared tree each frame.ftxui.widget— widget tags: how each is created, updated from its props, and which:on-*handlers it fires.ftxui.render— the frame: walks hiccup, keeps widgets and focus containers in step by tree position, sweeps what disappeared, and serves the callbacks.ftxui.core— the public API.
Animated button and menu colors: they build, but the animation needs a frame
source. The shim exposes what FTXUI has; adding a tag is a spec in
ftxui.widget or ftxui.dom plus, where needed, a shim function.