nandi/jolt-nativepublic Fork 0
3cfee15
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Bind the terminal backend from the side that renders into it

libjolttui exports the tree ABI libvidya does, and nothing on the jolt side
could reach it: glimmer had one backend registered, and it was the window.
So this is glimmer-vidya with a different shared object under it — the same
create/patch/append/remove, the same handler map, and the same rule that a
closure never crosses the FFI.

What differs is what a terminal forces. Keys arrive instead of a pointer, and
:on-key is the one event that bubbles, because the widget holding focus is
rarely the thing that knows what Esc meant. And a tree written for a window
carries its spacing in points, so :points-per-cell divides the props that are
a distance on the way across — a margin of twelve is one cell, not twelve
blank rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-01T22:56:05-07:00 Browse files
3cfee15 parent: a7f6202
added jolt/glimmer-tui/README.md +58 -0
new file mode 100644
@@ -0,0 +1,58 @@
1+# glimmer-tui
2+
3+glimmer's **terminal** backend, on the jolt side of the ABI — the namespace
4+that binds [`libjolttui`](../../crates/jolt-tui) and registers it with
5+`glimmer.backend`.
6+
7+It is [glimmer-vidya](../glimmer-vidya) with a different shared object under
8+it. The reconciler does not know what it is patching, so the same hiccup that
9+egui paints as a window paints here as cells:
10+
11+```clojure
12+(ns myapp
13+ (:require [glimmer.ratom :refer [atom]]
14+ [glimmer.core :as ui]
15+ [glimmer-tui.core])) ; installs this backend
16+
17+(defn -main [& _] (ui/run my-app))
18+```
19+
20+```bash
21+cargo build --release -p jolt-tui
22+LD_LIBRARY_PATH=../../target/release jolt -M:counter
23+```
24+
25+## What differs from the window
26+
27+Only what a terminal actually forces.
28+
29+* **No pictures, no title, no clipboard, no browser.** `glimmer-vidya.core`'s
30+ `frame-rgba!`, `set-title!`, `clipboard-image-png!`, `open-url!` and
31+ `pick-image!` have no answer here and are not defined.
32+* **Keys instead of a pointer.** `:on-key` is raised on whatever has focus, or
33+ on the window when nothing does — and it is the one event this backend
34+ *bubbles*, because the focused widget is rarely what knows the key's meaning.
35+ `:on-select` and `:on-scroll` join the list; `:on-hover` leaves it.
36+* **Cells instead of points.** `screen-size` answers columns and rows.
37+* **A scale for trees written in points.** `:points-per-cell` on `run` divides
38+ the props that are a distance — margins, padding, spacing, width and height
39+ requests — on the way across, so an app laid out for a window is legible in a
40+ terminal without rewriting its numbers. 8 is about right; the default is 1,
41+ for a tree that was written in cells to begin with.
42+
43+`after!`, `every!`, `cancel!`, `quit!` and the `dump` family are the same calls
44+with the same meanings.
45+
46+## Looking at what was painted
47+
48+A headless session is a session with the writer taken off the end — same
49+layout, same painting, same focus ring, no terminal:
50+
51+```clojure
52+(tui/after! 100 (fn [] (println (tui/screen-str)) (tui/quit!)))
53+(ui/run my-app :headless [100 34])
54+```
55+
56+`screen-line`, `screen-str` and `screen!` read the grid back; `feed-key!`,
57+`feed-click!` and `feed-wheel!` drive it. Those are the entry points a real
58+terminal's input arrives through, so a test types what a person types.
new file mode 100644
@@ -0,0 +1,58 @@
1+# glimmer-tui
2+
3+glimmer's **terminal** backend, on the jolt side of the ABI — the namespace
4+that binds [`libjolttui`](../../crates/jolt-tui) and registers it with
5+`glimmer.backend`.
6+
7+It is [glimmer-vidya](../glimmer-vidya) with a different shared object under
8+it. The reconciler does not know what it is patching, so the same hiccup that
9+egui paints as a window paints here as cells:
10+
11+```clojure
12+(ns myapp
13+ (:require [glimmer.ratom :refer [atom]]
14+ [glimmer.core :as ui]
15+ [glimmer-tui.core])) ; installs this backend
16+
17+(defn -main [& _] (ui/run my-app))
18+```
19+
20+```bash
21+cargo build --release -p jolt-tui
22+LD_LIBRARY_PATH=../../target/release jolt -M:counter
23+```
24+
25+## What differs from the window
26+
27+Only what a terminal actually forces.
28+
29+* **No pictures, no title, no clipboard, no browser.** `glimmer-vidya.core`'s
30+ `frame-rgba!`, `set-title!`, `clipboard-image-png!`, `open-url!` and
31+ `pick-image!` have no answer here and are not defined.
32+* **Keys instead of a pointer.** `:on-key` is raised on whatever has focus, or
33+ on the window when nothing does — and it is the one event this backend
34+ *bubbles*, because the focused widget is rarely what knows the key's meaning.
35+ `:on-select` and `:on-scroll` join the list; `:on-hover` leaves it.
36+* **Cells instead of points.** `screen-size` answers columns and rows.
37+* **A scale for trees written in points.** `:points-per-cell` on `run` divides
38+ the props that are a distance — margins, padding, spacing, width and height
39+ requests — on the way across, so an app laid out for a window is legible in a
40+ terminal without rewriting its numbers. 8 is about right; the default is 1,
41+ for a tree that was written in cells to begin with.
42+
43+`after!`, `every!`, `cancel!`, `quit!` and the `dump` family are the same calls
44+with the same meanings.
45+
46+## Looking at what was painted
47+
48+A headless session is a session with the writer taken off the end — same
49+layout, same painting, same focus ring, no terminal:
50+
51+```clojure
52+(tui/after! 100 (fn [] (println (tui/screen-str)) (tui/quit!)))
53+(ui/run my-app :headless [100 34])
54+```
55+
56+`screen-line`, `screen-str` and `screen!` read the grid back; `feed-key!`,
57+`feed-click!` and `feed-wheel!` drive it. Those are the entry points a real
58+terminal's input arrives through, so a test types what a person types.
added jolt/glimmer-tui/deps.edn +26 -0
new file mode 100644
@@ -0,0 +1,26 @@
1+{:paths ["src"]
2+
3+ ;; glimmer-tui is a backend for glimmer, beside glimmer-vidya and against the
4+ ;; same reconciler: glimmer owns the reactive core and knows about no toolkit,
5+ ;; and this project supplies widgets, painting and the frame loop — through
6+ ;; libjolttui's retained-tree C ABI (crates/jolt-tui/include/jolttui.h), which
7+ ;; is libvidya's tree ABI with a terminal under it instead of a GPU window.
8+ ;;
9+ ;; So an app that renders with glimmer-vidya renders here by requiring this
10+ ;; namespace instead. What it loses is what a terminal has not got: pictures,
11+ ;; a pointer, a window title, a clipboard.
12+ :deps {jolt-lang/glimmer {:git/url "https://github.com/jolt-lang/glimmer"
13+ :git/tag "v0.1.0"
14+ :git/sha "5581c331c51aff989259b9e8e92ec920fe5e6741"}}
15+
16+ ;; libjolttui, built from ../../crates/jolt-tui. Put it on the search path:
17+ ;; cargo build --release -p jolt-tui
18+ ;; LD_LIBRARY_PATH=../../target/release jolt -M:counter
19+ :jolt/native [{:name "jolttui"
20+ :darwin ["libjolttui.dylib"]
21+ :linux ["libjolttui.so"]}]
22+
23+ :aliases {:counter {:extra-paths ["examples"]
24+ :main-opts ["-m" "glimmer-tui.counter"]}}
25+
26+ :tasks {counter "jolt -M:counter"}}
new file mode 100644
@@ -0,0 +1,26 @@
1+{:paths ["src"]
2+
3+ ;; glimmer-tui is a backend for glimmer, beside glimmer-vidya and against the
4+ ;; same reconciler: glimmer owns the reactive core and knows about no toolkit,
5+ ;; and this project supplies widgets, painting and the frame loop — through
6+ ;; libjolttui's retained-tree C ABI (crates/jolt-tui/include/jolttui.h), which
7+ ;; is libvidya's tree ABI with a terminal under it instead of a GPU window.
8+ ;;
9+ ;; So an app that renders with glimmer-vidya renders here by requiring this
10+ ;; namespace instead. What it loses is what a terminal has not got: pictures,
11+ ;; a pointer, a window title, a clipboard.
12+ :deps {jolt-lang/glimmer {:git/url "https://github.com/jolt-lang/glimmer"
13+ :git/tag "v0.1.0"
14+ :git/sha "5581c331c51aff989259b9e8e92ec920fe5e6741"}}
15+
16+ ;; libjolttui, built from ../../crates/jolt-tui. Put it on the search path:
17+ ;; cargo build --release -p jolt-tui
18+ ;; LD_LIBRARY_PATH=../../target/release jolt -M:counter
19+ :jolt/native [{:name "jolttui"
20+ :darwin ["libjolttui.dylib"]
21+ :linux ["libjolttui.so"]}]
22+
23+ :aliases {:counter {:extra-paths ["examples"]
24+ :main-opts ["-m" "glimmer-tui.counter"]}}
25+
26+ :tasks {counter "jolt -M:counter"}}
added jolt/glimmer-tui/examples/glimmer_tui/counter.jolt +21 -0
new file mode 100644
@@ -0,0 +1,21 @@
1+(ns glimmer-tui.counter
2+ "The smallest thing that shows the backend works: a number and two buttons.
3+
4+ The same component renders under glimmer-vidya in a window. Nothing here
5+ names a terminal."
6+ (:require [glimmer.ratom :as r :refer [atom]]
7+ [glimmer.core :as ui]
8+ [glimmer-tui.core]))
9+
10+(defonce n (atom 0))
11+
12+(defn counter []
13+ [:vbox {:spacing 1 :margin 2}
14+ [:title {:label "counter"}]
15+ [:label {:label (str "n = " @n)}]
16+ [:hbox {:spacing 2}
17+ [:button {:label "-" :on-click #(swap! n dec)}]
18+ [:button {:label "+" :kind :primary :on-click #(swap! n inc)}]]
19+ [:dim-label {:label "tab moves, enter presses, ctrl-q quits"}]])
20+
21+(defn -main [& _] (ui/run counter))
new file mode 100644
@@ -0,0 +1,21 @@
1+(ns glimmer-tui.counter
2+ "The smallest thing that shows the backend works: a number and two buttons.
3+
4+ The same component renders under glimmer-vidya in a window. Nothing here
5+ names a terminal."
6+ (:require [glimmer.ratom :as r :refer [atom]]
7+ [glimmer.core :as ui]
8+ [glimmer-tui.core]))
9+
10+(defonce n (atom 0))
11+
12+(defn counter []
13+ [:vbox {:spacing 1 :margin 2}
14+ [:title {:label "counter"}]
15+ [:label {:label (str "n = " @n)}]
16+ [:hbox {:spacing 2}
17+ [:button {:label "-" :on-click #(swap! n dec)}]
18+ [:button {:label "+" :kind :primary :on-click #(swap! n inc)}]]
19+ [:dim-label {:label "tab moves, enter presses, ctrl-q quits"}]])
20+
21+(defn -main [& _] (ui/run counter))
added jolt/glimmer-tui/src/glimmer_tui/core.jolt +466 -0
new file mode 100644
@@ -0,0 +1,466 @@
1+(ns glimmer-tui.core
2+ "The terminal backend for glimmer. Requiring this namespace installs it,
3+ after which glimmer's portable reconciler renders the same hiccup into a
4+ terminal:
5+
6+ (ns myapp
7+ (:require [glimmer.ratom :refer [atom]]
8+ [glimmer.core :as ui]
9+ [glimmer-tui.core])) ; installs this backend
10+
11+ (defn -main [& _] (ui/run my-app))
12+
13+ It is glimmer-vidya with a different shared object under it. A terminal has
14+ no widgets to hand a reconciler only a grid you overwrite so the widget
15+ tree lives one layer down in libjolttui, and this namespace is the thin part:
16+ it turns glimmer's create/patch/append/remove into node mutations, runs the
17+ loop, and routes what comes back to the handlers the components declared.
18+
19+ **Handlers do not cross the FFI.** A jolt closure has no C representation, so
20+ identity travels instead: a node reports that it was clicked, and the handler
21+ map here says whose `:on-click` that was.
22+
23+ What is not here, because a terminal has not got it: pictures, a window
24+ title, a pointer that hovers, a clipboard. Keys are here instead see
25+ `:on-key`, which bubbles."
26+ (:require [clojure.string :as str]
27+ [glimmer.backend :as b]
28+ [glimmer-tui.ffi :as ffi]))
29+
30+;; Node id -> the :on-* props that node was last rendered with. Kept here
31+;; rather than sent across because a closure has no C representation.
32+;;
33+;; Ids are recycled by the arena, which is safe only because every id is
34+;; written here by `create!` before anything can raise an event against it a
35+;; reused id has its predecessor's handlers overwritten in the same breath.
36+(defonce ^:private handlers (atom {}))
37+
38+;; Work posted from other threads, run on the loop thread at the top of a tick.
39+(defonce ^:private pending (atom []))
40+
41+;; Set by quit!, read by the loop.
42+(defonce ^:private quit-requested (atom false))
43+
44+;; How many points a cell is worth, for the props that are a distance.
45+;;
46+;; A tree written for a window carries its spacing in points `:margin 12`,
47+;; `:width-request 260` and a terminal that takes those at face value paints
48+;; twelve blank rows and a column wider than the screen. The reconciler is not
49+;; the place to fix that and neither is the app: the numbers are right, and it
50+;; is the unit under them that changed. So the backend divides on the way
51+;; across, and a tree written for cells leaves the scale at 1.
52+(defonce ^:private scale (atom 1))
53+
54+;; The props that are a distance rather than a count, a flag or a name. A key
55+;; this list does not know crosses unscaled, which is the right way round: a
56+;; number that turns out to be a length paints a little large, where a scaled
57+;; `:value` or `:selected` would be silently wrong.
58+(def ^:private spatial-props
59+ #{:margin :margin-top :margin-bottom :margin-left :margin-right
60+ :padding :padding-top :padding-bottom :padding-left :padding-right
61+ :spacing :gap :size :reserve
62+ :width-request :height-request :max-width :min-width :max-height})
63+
64+(defn- scaled
65+ "`v` in cells, rounded away from zero so a margin that was asked for is at
66+ least one cell of one."
67+ [v]
68+ (let [n (/ (double v) @scale)]
69+ (cond
70+ (zero? n) 0
71+ (< (Math/abs n) 1.0) (if (pos? n) 1 -1)
72+ :else (Math/round n))))
73+
74+;; --- props -------------------------------------------------------------------
75+;; :hbox and :vbox are one node in the library; the tag only implies an
76+;; orientation, and an explicit :orientation prop still wins.
77+(def ^:private tag-orientation {:hbox "horizontal" :vbox "vertical"})
78+
79+(defn- handler-key?
80+ "True for a prop that names an event handler rather than a value."
81+ [k]
82+ (let [s (name k)]
83+ (and (> (count s) 3) (= "on-" (subs s 0 3)))))
84+
85+(defn- set-prop!
86+ "Write one prop to a node, in the ABI type that fits its value. nil clears
87+ nothing the prop was already dropped by the clear that precedes a write
88+ and an unrecognized value is stringified rather than refused, so a prop this
89+ backend has not learned yet still reaches the library."
90+ [node k v]
91+ (let [key (name k)]
92+ (cond
93+ (nil? v) nil
94+ (true? v) (ffi/node-set-bool! node key true)
95+ (false? v) (ffi/node-set-bool! node key false)
96+ (number? v) (ffi/node-set-num! node key
97+ (double (if (contains? spatial-props k)
98+ (scaled v)
99+ v)))
100+ (string? v) (ffi/node-set-str! node key v)
101+ (keyword? v) (ffi/node-set-str! node key (name v))
102+ :else (ffi/node-set-str! node key (str v)))))
103+
104+(defn- write-props!
105+ "Replace a node's props with `props`.
106+
107+ Cleared first, deliberately: a re-render that stops setting `:placeholder`
108+ means the placeholder is gone, and patching in place would leave the old one
109+ behind. It also discards the value the library wrote back when the reader
110+ typed into an entry or moved a list's cursor — which is the point. The
111+ component's state is the truth, and this is the frame where it says so."
112+ [node tag props]
113+ (ffi/node-clear-props! node)
114+ (when-let [orientation (tag-orientation tag)]
115+ (when-not (contains? props :orientation)
116+ (ffi/node-set-str! node "orientation" orientation)))
117+ (doseq [[k v] props]
118+ (when-not (handler-key? k)
119+ (set-prop! node k v)))
120+ (swap! handlers assoc node
121+ (reduce (fn [acc [k v]]
122+ (if (and (handler-key? k) (fn? v)) (assoc acc k v) acc))
123+ {}
124+ props))
125+ nil)
126+
127+(defn- forget-dead-handlers!
128+ "Drop handler entries for nodes the library has freed.
129+
130+ Removing a subtree frees every node under it, and only the library knows
131+ which those were so rather than mirror the tree here to walk it, the map is
132+ filtered against what still exists."
133+ []
134+ (swap! handlers
135+ (fn [m]
136+ (reduce (fn [acc [id hs]]
137+ (if (ffi/node-exists? id) (assoc acc id hs) acc))
138+ {}
139+ m)))
140+ nil)
141+
142+;; --- the backend operations --------------------------------------------------
143+(defn- create!
144+ "glimmer.backend's :create!. Children are appended by the reconciler, not
145+ here."
146+ [tag props]
147+ (let [node (ffi/node-new (name tag))]
148+ (when (zero? node)
149+ (throw (ex-info "jolttui could not allocate a node" {:tag tag})))
150+ (write-props! node tag props)
151+ node))
152+
153+(defn- apply-props! [tag node props] (write-props! node tag props))
154+
155+(defn- append-child! [_parent-tag parent child]
156+ (ffi/node-append! parent child)
157+ nil)
158+
159+(defn- remove-child! [_parent-tag parent child]
160+ ;; The library frees the subtree; glimmer never mentions it again.
161+ (ffi/node-remove! parent child)
162+ (forget-dead-handlers!)
163+ nil)
164+
165+(defn- replace-child! [_parent-tag parent old-child new-child]
166+ (ffi/node-replace! parent old-child new-child)
167+ (forget-dead-handlers!)
168+ nil)
169+
170+(defn- reorder-child! [_parent-tag parent child sibling]
171+ ;; nil sibling means "first"; the ABI spells that 0.
172+ (ffi/node-insert-after! parent child (or sibling 0))
173+ nil)
174+
175+;; --- the loop thread ---------------------------------------------------------
176+(defn- schedule
177+ "glimmer.backend's :schedule. Every node call belongs to the thread that
178+ opened the session, so a ratom mutated on a reader thread (or any future)
179+ queues its re-render here and the loop performs it on the next tick."
180+ [work]
181+ (swap! pending conj work)
182+ nil)
183+
184+(defn- drain!
185+ "Run everything `schedule` queued. compare-and-set! rather than reset!, so
186+ work posted while the queue is being taken is not dropped."
187+ []
188+ (loop []
189+ (let [q @pending]
190+ (when (seq q)
191+ (if (compare-and-set! pending q [])
192+ (doseq [f q] (f))
193+ (recur))))))
194+
195+;; --- timers ------------------------------------------------------------------
196+;; A spinner or a clock has to change with nothing being pressed. The loop
197+;; already wakes every tick, so a timer is a due time and a thunk. Both entry
198+;; points are safe to call from another thread, and both run their thunk ON the
199+;; loop thread, the only one allowed to touch nodes.
200+(defonce ^:private timers (atom {:next-id 0 :entries {}}))
201+
202+(defn- now-ms [] (System/currentTimeMillis))
203+
204+(defn- add-timer! [ms every? f]
205+ (let [id (:next-id (swap! timers update :next-id inc))]
206+ (swap! timers assoc-in [:entries id]
207+ {:due (+ (now-ms) ms) :every (when every? ms) :f f})
208+ id))
209+
210+(defn after!
211+ "Run `f` on the loop thread in about `ms` milliseconds. Returns an id for
212+ `cancel!`. Resolution is one tick."
213+ [ms f] (add-timer! ms false f))
214+
215+(defn every!
216+ "Run `f` on the loop thread about every `ms` milliseconds until cancelled."
217+ [ms f] (add-timer! ms true f))
218+
219+(defn cancel!
220+ "Stop the timer `id`."
221+ [id] (swap! timers update :entries dissoc id) nil)
222+
223+(defn cancel-all!
224+ "Stop every timer, so a repeating one does not outlive the UI it animated."
225+ [] (swap! timers assoc :entries {}) nil)
226+
227+(defn- pump-timers! []
228+ (let [t (now-ms)
229+ due (reduce (fn [acc [id e]] (if (<= (:due e) t) (conj acc [id e]) acc))
230+ []
231+ (:entries @timers))]
232+ (doseq [[id e] due]
233+ (if-let [period (:every e)]
234+ (swap! timers assoc-in [:entries id :due] (+ t period))
235+ (swap! timers update :entries dissoc id))
236+ ((:f e)))
237+ nil))
238+
239+;; --- events ------------------------------------------------------------------
240+(defn- bubble!
241+ "Walk from `node` up to the window looking for `k`, and call the first one
242+ found with `args`. True when something took it.
243+
244+ Only keys do this. Everything else here is raised on the widget it happened
245+ to, and a container has no business hearing about a click on a button inside
246+ it but a key nothing wanted is exactly the event a screen wants to answer,
247+ and the focused widget is rarely the thing that knows what Esc means."
248+ [node k & args]
249+ (loop [n node]
250+ (cond
251+ (zero? n) false
252+ (get-in @handlers [n k]) (do (apply (get-in @handlers [n k]) args) true)
253+ :else (recur (ffi/node-parent n)))))
254+
255+(defn- dispatch-events!
256+ "Drain the tick's interactions and call the handlers they belong to.
257+
258+ An event whose node has no handler for it is dropped, which is what makes a
259+ control that ignores its own event still work: the library wrote the new
260+ state into the node, and the next render either confirms it or overwrites it.
261+
262+ `:on-activate` is called with no arguments, as it is on the Vidya backend
263+ the entry's text has already been written back to the node, and a component
264+ that cares holds it in a ratom anyway. `:on-select` and `:on-scroll` are the
265+ two that carry what changed, because there is nowhere else to read it from."
266+ []
267+ (loop []
268+ (when (ffi/poll-event!)
269+ (let [node (ffi/event-node)
270+ kind (ffi/event-name)
271+ hs (get @handlers node)]
272+ (case kind
273+ "click" (when-let [f (:on-click hs)] (f))
274+ "toggled" (when-let [f (:on-toggled hs)] (f))
275+ ;; The text is read before anything else can overwrite the library's
276+ ;; scratch buffer for its family jolt copies it as it crosses.
277+ "change" (when-let [f (:on-change hs)] (f (ffi/event-text)))
278+ "activate" (when-let [f (:on-activate hs)] (f))
279+ "select" (when-let [f (:on-select hs)]
280+ (f (long (ffi/event-num)) (ffi/event-text)))
281+ "scroll" (when-let [f (:on-scroll hs)] (f (long (ffi/event-num))))
282+ "close" (when-let [f (:on-close hs)] (f))
283+ ;; The one that bubbles. It arrives on whatever has focus, which is
284+ ;; not usually the component that knows what the key meant.
285+ "key" (bubble! node :on-key (ffi/event-text))
286+ nil))
287+ (recur))))
288+
289+;; --- reading the screen ------------------------------------------------------
290+(defn screen-size
291+ "The terminal's size as `[columns rows]`. `[0 0]` before a session is open.
292+
293+ Cells, not points: this is what a layout has to divide up, and it changes
294+ when the window is dragged. Read it from a timer `every!` and hold it in
295+ a ratom, so the components that switch on it re-render only when it moves."
296+ []
297+ [(ffi/screen-width) (ffi/screen-height)])
298+
299+(defn screen-line
300+ "One painted row as text, trailing blanks trimmed."
301+ [y]
302+ (ffi/screen-line y))
303+
304+(defn screen-str
305+ "Everything painted, as one string of rows.
306+
307+ What a headless session is for: mount a tree, tick it once, and this is the
308+ answer a screenshot a test can assert on and a bug report can paste, with
309+ no terminal anywhere."
310+ []
311+ (let [h (ffi/screen-height)]
312+ (loop [y 0 acc []]
313+ (if (>= y h)
314+ (str/join "\n" acc)
315+ (recur (inc y) (conj acc (ffi/screen-line y)))))))
316+
317+(defn screen!
318+ "Print `screen-str`. The one you want from a handler or the REPL."
319+ []
320+ (println (screen-str))
321+ nil)
322+
323+;; --- driving it by hand ------------------------------------------------------
324+;; The same entry points a real terminal's input arrives through, so a test
325+;; types what a person types.
326+(defn feed-key!
327+ "Type one key by name — \"a\", \"enter\", \"shift+tab\", \"ctrl+u\", \"f5\".
328+ True when the backend acted on it, false when it went out as a `key` event."
329+ [name]
330+ (ffi/feed-key! name))
331+
332+(defn feed-click! [x y] (ffi/feed-click! x y))
333+(defn feed-wheel!
334+ "Turn the wheel at a cell; `by` is in rows, and negative is up."
335+ [x y by]
336+ (ffi/feed-wheel! x y by))
337+
338+(defn focus
339+ "The focused node, 0 for none."
340+ []
341+ (ffi/focus))
342+
343+;; --- the event loop ----------------------------------------------------------
344+(defn- clear-children!
345+ "Drop everything under `node`. Removing a child frees it, so this walks the
346+ first slot until there is nothing left rather than iterating an index."
347+ [node]
348+ (loop []
349+ (when (pos? (ffi/node-child-count node))
350+ (ffi/node-remove! node (ffi/node-child-at node 0))
351+ (recur)))
352+ (forget-dead-handlers!)
353+ nil)
354+
355+(defn quit!
356+ "Stop the running loop and give the terminal back."
357+ []
358+ (reset! quit-requested true)
359+ nil)
360+
361+(defn- run!
362+ "glimmer.backend's :run. Takes the terminal, mounts the root component into
363+ the library's root node, and paints until Ctrl-C, Ctrl-Q or `quit!`. Blocks,
364+ like every UI main loop.
365+
366+ Options (on top of glimmer's own):
367+ :mouse report clicks and the wheel (default true)
368+ :points-per-cell how many of the tree's own units go into one cell
369+ (default 1). 8 is about right for a tree written against
370+ a window: it is the width of a character in the size a
371+ desktop UI uses, which is what those numbers were laid out
372+ in.
373+ :fps how often the loop wakes when no input arrives (default 60)
374+ :headless [columns rows] a session with no terminal at all, for a
375+ test or a screenshot; input is fed by hand
376+ :auto-quit-ms stop after roughly this long, for a smoke test that has
377+ nobody to press a key
378+
379+ The session is closed in a finally, so a handler that throws does not leave a
380+ terminal in raw mode on the alternate screen which is the one failure here
381+ a reader cannot recover from without `reset`."
382+ [opts mount-root!]
383+ (let [{:keys [mouse fps headless auto-quit-ms points-per-cell]
384+ :or {mouse true fps 60 points-per-cell 1}} opts
385+ _ (reset! scale (max 1 points-per-cell))
386+ opened? (if headless
387+ (ffi/headless! (first headless) (second headless))
388+ (ffi/open! mouse))]
389+ (when-not opened?
390+ (throw (ex-info "jolttui could not open a session"
391+ {:headless headless})))
392+ (reset! quit-requested false)
393+ (let [started (now-ms)
394+ timeout (max 1 (quot 1000 (max 1 fps)))
395+ root (ffi/tree-root)]
396+ (try
397+ ;; The library's root outlives a run — it is process-wide, not per
398+ ;; session so a second `ui/run` in one process (a test, a REPL) would
399+ ;; otherwise mount its tree alongside the last one's.
400+ (clear-children! root)
401+ (mount-root! root :window)
402+ (reset! b/loop-running? true)
403+ (loop []
404+ (drain!)
405+ (pump-timers!)
406+ ;; Input first, then one call that lays out and paints the whole
407+ ;; tree, then the events both produced while the frame that caused
408+ ;; them is still the frame the components rendered.
409+ (ffi/tick timeout)
410+ (ffi/frame!)
411+ (dispatch-events!)
412+ (when-not (or @quit-requested
413+ (ffi/should-close?)
414+ (and auto-quit-ms (>= (- (now-ms) started) auto-quit-ms)))
415+ (recur)))
416+ (finally
417+ (reset! b/loop-running? false)
418+ (cancel-all!)
419+ (ffi/close!)
420+ (reset! handlers {}))))))
421+
422+;; --- looking at what was rendered --------------------------------------------
423+(defn dump-str
424+ "The rendered tree as hiccup text, read back out of the library.
425+
426+ With no argument, the whole window; with a node handle, that subtree. This is
427+ the tree as it *is* after the reconciler has run, not what a component
428+ returned. `:hbox` and `:vbox` are one node down there and both dump as
429+ `:box`, with the orientation in the props; no `:on-*` appears, because
430+ handlers are held on this side and never sent."
431+ ([] (dump-str 0))
432+ ([node] (ffi/tree-dump node)))
433+
434+(defn dump
435+ "`dump-str`, read back as hiccup data — vectors, keywords and maps."
436+ ([] (dump 0))
437+ ([node] (read-string (dump-str node))))
438+
439+(defn dump!
440+ "Print `dump-str` to stdout."
441+ ([] (dump! 0))
442+ ([node] (println (dump-str node)) nil))
443+
444+;; --- the backend -------------------------------------------------------------
445+(def backend
446+ "The terminal backend map handed to glimmer.backend/register!. See that
447+ namespace for the contract each key satisfies."
448+ {:name :tui
449+ :create! create!
450+ :apply-props! apply-props!
451+ :append-child! append-child!
452+ :remove-child! remove-child!
453+ :replace-child! replace-child!
454+ :reorder-child! reorder-child!
455+ :schedule schedule
456+ :run run!})
457+
458+(defn install!
459+ "Make the terminal the surface glimmer renders onto. Called on load, so
460+ requiring this namespace is enough; exposed for code that wants to be
461+ explicit, or to switch back after another backend was installed."
462+ []
463+ (b/register! backend)
464+ nil)
465+
466+(defonce ^:private installed (do (install!) true))
new file mode 100644
@@ -0,0 +1,466 @@
1+(ns glimmer-tui.core
2+ "The terminal backend for glimmer. Requiring this namespace installs it,
3+ after which glimmer's portable reconciler renders the same hiccup into a
4+ terminal:
5+
6+ (ns myapp
7+ (:require [glimmer.ratom :refer [atom]]
8+ [glimmer.core :as ui]
9+ [glimmer-tui.core])) ; installs this backend
10+
11+ (defn -main [& _] (ui/run my-app))
12+
13+ It is glimmer-vidya with a different shared object under it. A terminal has
14+ no widgets to hand a reconciler only a grid you overwrite so the widget
15+ tree lives one layer down in libjolttui, and this namespace is the thin part:
16+ it turns glimmer's create/patch/append/remove into node mutations, runs the
17+ loop, and routes what comes back to the handlers the components declared.
18+
19+ **Handlers do not cross the FFI.** A jolt closure has no C representation, so
20+ identity travels instead: a node reports that it was clicked, and the handler
21+ map here says whose `:on-click` that was.
22+
23+ What is not here, because a terminal has not got it: pictures, a window
24+ title, a pointer that hovers, a clipboard. Keys are here instead see
25+ `:on-key`, which bubbles."
26+ (:require [clojure.string :as str]
27+ [glimmer.backend :as b]
28+ [glimmer-tui.ffi :as ffi]))
29+
30+;; Node id -> the :on-* props that node was last rendered with. Kept here
31+;; rather than sent across because a closure has no C representation.
32+;;
33+;; Ids are recycled by the arena, which is safe only because every id is
34+;; written here by `create!` before anything can raise an event against it a
35+;; reused id has its predecessor's handlers overwritten in the same breath.
36+(defonce ^:private handlers (atom {}))
37+
38+;; Work posted from other threads, run on the loop thread at the top of a tick.
39+(defonce ^:private pending (atom []))
40+
41+;; Set by quit!, read by the loop.
42+(defonce ^:private quit-requested (atom false))
43+
44+;; How many points a cell is worth, for the props that are a distance.
45+;;
46+;; A tree written for a window carries its spacing in points `:margin 12`,
47+;; `:width-request 260` and a terminal that takes those at face value paints
48+;; twelve blank rows and a column wider than the screen. The reconciler is not
49+;; the place to fix that and neither is the app: the numbers are right, and it
50+;; is the unit under them that changed. So the backend divides on the way
51+;; across, and a tree written for cells leaves the scale at 1.
52+(defonce ^:private scale (atom 1))
53+
54+;; The props that are a distance rather than a count, a flag or a name. A key
55+;; this list does not know crosses unscaled, which is the right way round: a
56+;; number that turns out to be a length paints a little large, where a scaled
57+;; `:value` or `:selected` would be silently wrong.
58+(def ^:private spatial-props
59+ #{:margin :margin-top :margin-bottom :margin-left :margin-right
60+ :padding :padding-top :padding-bottom :padding-left :padding-right
61+ :spacing :gap :size :reserve
62+ :width-request :height-request :max-width :min-width :max-height})
63+
64+(defn- scaled
65+ "`v` in cells, rounded away from zero so a margin that was asked for is at
66+ least one cell of one."
67+ [v]
68+ (let [n (/ (double v) @scale)]
69+ (cond
70+ (zero? n) 0
71+ (< (Math/abs n) 1.0) (if (pos? n) 1 -1)
72+ :else (Math/round n))))
73+
74+;; --- props -------------------------------------------------------------------
75+;; :hbox and :vbox are one node in the library; the tag only implies an
76+;; orientation, and an explicit :orientation prop still wins.
77+(def ^:private tag-orientation {:hbox "horizontal" :vbox "vertical"})
78+
79+(defn- handler-key?
80+ "True for a prop that names an event handler rather than a value."
81+ [k]
82+ (let [s (name k)]
83+ (and (> (count s) 3) (= "on-" (subs s 0 3)))))
84+
85+(defn- set-prop!
86+ "Write one prop to a node, in the ABI type that fits its value. nil clears
87+ nothing the prop was already dropped by the clear that precedes a write
88+ and an unrecognized value is stringified rather than refused, so a prop this
89+ backend has not learned yet still reaches the library."
90+ [node k v]
91+ (let [key (name k)]
92+ (cond
93+ (nil? v) nil
94+ (true? v) (ffi/node-set-bool! node key true)
95+ (false? v) (ffi/node-set-bool! node key false)
96+ (number? v) (ffi/node-set-num! node key
97+ (double (if (contains? spatial-props k)
98+ (scaled v)
99+ v)))
100+ (string? v) (ffi/node-set-str! node key v)
101+ (keyword? v) (ffi/node-set-str! node key (name v))
102+ :else (ffi/node-set-str! node key (str v)))))
103+
104+(defn- write-props!
105+ "Replace a node's props with `props`.
106+
107+ Cleared first, deliberately: a re-render that stops setting `:placeholder`
108+ means the placeholder is gone, and patching in place would leave the old one
109+ behind. It also discards the value the library wrote back when the reader
110+ typed into an entry or moved a list's cursor — which is the point. The
111+ component's state is the truth, and this is the frame where it says so."
112+ [node tag props]
113+ (ffi/node-clear-props! node)
114+ (when-let [orientation (tag-orientation tag)]
115+ (when-not (contains? props :orientation)
116+ (ffi/node-set-str! node "orientation" orientation)))
117+ (doseq [[k v] props]
118+ (when-not (handler-key? k)
119+ (set-prop! node k v)))
120+ (swap! handlers assoc node
121+ (reduce (fn [acc [k v]]
122+ (if (and (handler-key? k) (fn? v)) (assoc acc k v) acc))
123+ {}
124+ props))
125+ nil)
126+
127+(defn- forget-dead-handlers!
128+ "Drop handler entries for nodes the library has freed.
129+
130+ Removing a subtree frees every node under it, and only the library knows
131+ which those were so rather than mirror the tree here to walk it, the map is
132+ filtered against what still exists."
133+ []
134+ (swap! handlers
135+ (fn [m]
136+ (reduce (fn [acc [id hs]]
137+ (if (ffi/node-exists? id) (assoc acc id hs) acc))
138+ {}
139+ m)))
140+ nil)
141+
142+;; --- the backend operations --------------------------------------------------
143+(defn- create!
144+ "glimmer.backend's :create!. Children are appended by the reconciler, not
145+ here."
146+ [tag props]
147+ (let [node (ffi/node-new (name tag))]
148+ (when (zero? node)
149+ (throw (ex-info "jolttui could not allocate a node" {:tag tag})))
150+ (write-props! node tag props)
151+ node))
152+
153+(defn- apply-props! [tag node props] (write-props! node tag props))
154+
155+(defn- append-child! [_parent-tag parent child]
156+ (ffi/node-append! parent child)
157+ nil)
158+
159+(defn- remove-child! [_parent-tag parent child]
160+ ;; The library frees the subtree; glimmer never mentions it again.
161+ (ffi/node-remove! parent child)
162+ (forget-dead-handlers!)
163+ nil)
164+
165+(defn- replace-child! [_parent-tag parent old-child new-child]
166+ (ffi/node-replace! parent old-child new-child)
167+ (forget-dead-handlers!)
168+ nil)
169+
170+(defn- reorder-child! [_parent-tag parent child sibling]
171+ ;; nil sibling means "first"; the ABI spells that 0.
172+ (ffi/node-insert-after! parent child (or sibling 0))
173+ nil)
174+
175+;; --- the loop thread ---------------------------------------------------------
176+(defn- schedule
177+ "glimmer.backend's :schedule. Every node call belongs to the thread that
178+ opened the session, so a ratom mutated on a reader thread (or any future)
179+ queues its re-render here and the loop performs it on the next tick."
180+ [work]
181+ (swap! pending conj work)
182+ nil)
183+
184+(defn- drain!
185+ "Run everything `schedule` queued. compare-and-set! rather than reset!, so
186+ work posted while the queue is being taken is not dropped."
187+ []
188+ (loop []
189+ (let [q @pending]
190+ (when (seq q)
191+ (if (compare-and-set! pending q [])
192+ (doseq [f q] (f))
193+ (recur))))))
194+
195+;; --- timers ------------------------------------------------------------------
196+;; A spinner or a clock has to change with nothing being pressed. The loop
197+;; already wakes every tick, so a timer is a due time and a thunk. Both entry
198+;; points are safe to call from another thread, and both run their thunk ON the
199+;; loop thread, the only one allowed to touch nodes.
200+(defonce ^:private timers (atom {:next-id 0 :entries {}}))
201+
202+(defn- now-ms [] (System/currentTimeMillis))
203+
204+(defn- add-timer! [ms every? f]
205+ (let [id (:next-id (swap! timers update :next-id inc))]
206+ (swap! timers assoc-in [:entries id]
207+ {:due (+ (now-ms) ms) :every (when every? ms) :f f})
208+ id))
209+
210+(defn after!
211+ "Run `f` on the loop thread in about `ms` milliseconds. Returns an id for
212+ `cancel!`. Resolution is one tick."
213+ [ms f] (add-timer! ms false f))
214+
215+(defn every!
216+ "Run `f` on the loop thread about every `ms` milliseconds until cancelled."
217+ [ms f] (add-timer! ms true f))
218+
219+(defn cancel!
220+ "Stop the timer `id`."
221+ [id] (swap! timers update :entries dissoc id) nil)
222+
223+(defn cancel-all!
224+ "Stop every timer, so a repeating one does not outlive the UI it animated."
225+ [] (swap! timers assoc :entries {}) nil)
226+
227+(defn- pump-timers! []
228+ (let [t (now-ms)
229+ due (reduce (fn [acc [id e]] (if (<= (:due e) t) (conj acc [id e]) acc))
230+ []
231+ (:entries @timers))]
232+ (doseq [[id e] due]
233+ (if-let [period (:every e)]
234+ (swap! timers assoc-in [:entries id :due] (+ t period))
235+ (swap! timers update :entries dissoc id))
236+ ((:f e)))
237+ nil))
238+
239+;; --- events ------------------------------------------------------------------
240+(defn- bubble!
241+ "Walk from `node` up to the window looking for `k`, and call the first one
242+ found with `args`. True when something took it.
243+
244+ Only keys do this. Everything else here is raised on the widget it happened
245+ to, and a container has no business hearing about a click on a button inside
246+ it but a key nothing wanted is exactly the event a screen wants to answer,
247+ and the focused widget is rarely the thing that knows what Esc means."
248+ [node k & args]
249+ (loop [n node]
250+ (cond
251+ (zero? n) false
252+ (get-in @handlers [n k]) (do (apply (get-in @handlers [n k]) args) true)
253+ :else (recur (ffi/node-parent n)))))
254+
255+(defn- dispatch-events!
256+ "Drain the tick's interactions and call the handlers they belong to.
257+
258+ An event whose node has no handler for it is dropped, which is what makes a
259+ control that ignores its own event still work: the library wrote the new
260+ state into the node, and the next render either confirms it or overwrites it.
261+
262+ `:on-activate` is called with no arguments, as it is on the Vidya backend
263+ the entry's text has already been written back to the node, and a component
264+ that cares holds it in a ratom anyway. `:on-select` and `:on-scroll` are the
265+ two that carry what changed, because there is nowhere else to read it from."
266+ []
267+ (loop []
268+ (when (ffi/poll-event!)
269+ (let [node (ffi/event-node)
270+ kind (ffi/event-name)
271+ hs (get @handlers node)]
272+ (case kind
273+ "click" (when-let [f (:on-click hs)] (f))
274+ "toggled" (when-let [f (:on-toggled hs)] (f))
275+ ;; The text is read before anything else can overwrite the library's
276+ ;; scratch buffer for its family jolt copies it as it crosses.
277+ "change" (when-let [f (:on-change hs)] (f (ffi/event-text)))
278+ "activate" (when-let [f (:on-activate hs)] (f))
279+ "select" (when-let [f (:on-select hs)]
280+ (f (long (ffi/event-num)) (ffi/event-text)))
281+ "scroll" (when-let [f (:on-scroll hs)] (f (long (ffi/event-num))))
282+ "close" (when-let [f (:on-close hs)] (f))
283+ ;; The one that bubbles. It arrives on whatever has focus, which is
284+ ;; not usually the component that knows what the key meant.
285+ "key" (bubble! node :on-key (ffi/event-text))
286+ nil))
287+ (recur))))
288+
289+;; --- reading the screen ------------------------------------------------------
290+(defn screen-size
291+ "The terminal's size as `[columns rows]`. `[0 0]` before a session is open.
292+
293+ Cells, not points: this is what a layout has to divide up, and it changes
294+ when the window is dragged. Read it from a timer `every!` and hold it in
295+ a ratom, so the components that switch on it re-render only when it moves."
296+ []
297+ [(ffi/screen-width) (ffi/screen-height)])
298+
299+(defn screen-line
300+ "One painted row as text, trailing blanks trimmed."
301+ [y]
302+ (ffi/screen-line y))
303+
304+(defn screen-str
305+ "Everything painted, as one string of rows.
306+
307+ What a headless session is for: mount a tree, tick it once, and this is the
308+ answer a screenshot a test can assert on and a bug report can paste, with
309+ no terminal anywhere."
310+ []
311+ (let [h (ffi/screen-height)]
312+ (loop [y 0 acc []]
313+ (if (>= y h)
314+ (str/join "\n" acc)
315+ (recur (inc y) (conj acc (ffi/screen-line y)))))))
316+
317+(defn screen!
318+ "Print `screen-str`. The one you want from a handler or the REPL."
319+ []
320+ (println (screen-str))
321+ nil)
322+
323+;; --- driving it by hand ------------------------------------------------------
324+;; The same entry points a real terminal's input arrives through, so a test
325+;; types what a person types.
326+(defn feed-key!
327+ "Type one key by name — \"a\", \"enter\", \"shift+tab\", \"ctrl+u\", \"f5\".
328+ True when the backend acted on it, false when it went out as a `key` event."
329+ [name]
330+ (ffi/feed-key! name))
331+
332+(defn feed-click! [x y] (ffi/feed-click! x y))
333+(defn feed-wheel!
334+ "Turn the wheel at a cell; `by` is in rows, and negative is up."
335+ [x y by]
336+ (ffi/feed-wheel! x y by))
337+
338+(defn focus
339+ "The focused node, 0 for none."
340+ []
341+ (ffi/focus))
342+
343+;; --- the event loop ----------------------------------------------------------
344+(defn- clear-children!
345+ "Drop everything under `node`. Removing a child frees it, so this walks the
346+ first slot until there is nothing left rather than iterating an index."
347+ [node]
348+ (loop []
349+ (when (pos? (ffi/node-child-count node))
350+ (ffi/node-remove! node (ffi/node-child-at node 0))
351+ (recur)))
352+ (forget-dead-handlers!)
353+ nil)
354+
355+(defn quit!
356+ "Stop the running loop and give the terminal back."
357+ []
358+ (reset! quit-requested true)
359+ nil)
360+
361+(defn- run!
362+ "glimmer.backend's :run. Takes the terminal, mounts the root component into
363+ the library's root node, and paints until Ctrl-C, Ctrl-Q or `quit!`. Blocks,
364+ like every UI main loop.
365+
366+ Options (on top of glimmer's own):
367+ :mouse report clicks and the wheel (default true)
368+ :points-per-cell how many of the tree's own units go into one cell
369+ (default 1). 8 is about right for a tree written against
370+ a window: it is the width of a character in the size a
371+ desktop UI uses, which is what those numbers were laid out
372+ in.
373+ :fps how often the loop wakes when no input arrives (default 60)
374+ :headless [columns rows] a session with no terminal at all, for a
375+ test or a screenshot; input is fed by hand
376+ :auto-quit-ms stop after roughly this long, for a smoke test that has
377+ nobody to press a key
378+
379+ The session is closed in a finally, so a handler that throws does not leave a
380+ terminal in raw mode on the alternate screen which is the one failure here
381+ a reader cannot recover from without `reset`."
382+ [opts mount-root!]
383+ (let [{:keys [mouse fps headless auto-quit-ms points-per-cell]
384+ :or {mouse true fps 60 points-per-cell 1}} opts
385+ _ (reset! scale (max 1 points-per-cell))
386+ opened? (if headless
387+ (ffi/headless! (first headless) (second headless))
388+ (ffi/open! mouse))]
389+ (when-not opened?
390+ (throw (ex-info "jolttui could not open a session"
391+ {:headless headless})))
392+ (reset! quit-requested false)
393+ (let [started (now-ms)
394+ timeout (max 1 (quot 1000 (max 1 fps)))
395+ root (ffi/tree-root)]
396+ (try
397+ ;; The library's root outlives a run — it is process-wide, not per
398+ ;; session so a second `ui/run` in one process (a test, a REPL) would
399+ ;; otherwise mount its tree alongside the last one's.
400+ (clear-children! root)
401+ (mount-root! root :window)
402+ (reset! b/loop-running? true)
403+ (loop []
404+ (drain!)
405+ (pump-timers!)
406+ ;; Input first, then one call that lays out and paints the whole
407+ ;; tree, then the events both produced while the frame that caused
408+ ;; them is still the frame the components rendered.
409+ (ffi/tick timeout)
410+ (ffi/frame!)
411+ (dispatch-events!)
412+ (when-not (or @quit-requested
413+ (ffi/should-close?)
414+ (and auto-quit-ms (>= (- (now-ms) started) auto-quit-ms)))
415+ (recur)))
416+ (finally
417+ (reset! b/loop-running? false)
418+ (cancel-all!)
419+ (ffi/close!)
420+ (reset! handlers {}))))))
421+
422+;; --- looking at what was rendered --------------------------------------------
423+(defn dump-str
424+ "The rendered tree as hiccup text, read back out of the library.
425+
426+ With no argument, the whole window; with a node handle, that subtree. This is
427+ the tree as it *is* after the reconciler has run, not what a component
428+ returned. `:hbox` and `:vbox` are one node down there and both dump as
429+ `:box`, with the orientation in the props; no `:on-*` appears, because
430+ handlers are held on this side and never sent."
431+ ([] (dump-str 0))
432+ ([node] (ffi/tree-dump node)))
433+
434+(defn dump
435+ "`dump-str`, read back as hiccup data — vectors, keywords and maps."
436+ ([] (dump 0))
437+ ([node] (read-string (dump-str node))))
438+
439+(defn dump!
440+ "Print `dump-str` to stdout."
441+ ([] (dump! 0))
442+ ([node] (println (dump-str node)) nil))
443+
444+;; --- the backend -------------------------------------------------------------
445+(def backend
446+ "The terminal backend map handed to glimmer.backend/register!. See that
447+ namespace for the contract each key satisfies."
448+ {:name :tui
449+ :create! create!
450+ :apply-props! apply-props!
451+ :append-child! append-child!
452+ :remove-child! remove-child!
453+ :replace-child! replace-child!
454+ :reorder-child! reorder-child!
455+ :schedule schedule
456+ :run run!})
457+
458+(defn install!
459+ "Make the terminal the surface glimmer renders onto. Called on load, so
460+ requiring this namespace is enough; exposed for code that wants to be
461+ explicit, or to switch back after another backend was installed."
462+ []
463+ (b/register! backend)
464+ nil)
465+
466+(defonce ^:private installed (do (install!) true))
added jolt/glimmer-tui/src/glimmer_tui/ffi.jolt +116 -0
new file mode 100644
@@ -0,0 +1,116 @@
1+(ns glimmer-tui.ffi
2+ "Raw bindings for libjolttui's retained-tree ABI
3+ (`crates/jolt-tui/include/jolttui.h`).
4+
5+ Nothing here interprets a prop or an event; that is `glimmer-tui.core`'s job.
6+ This namespace exists so the boundary is one readable list of symbols, and so
7+ the marshalling rules that come with it are stated once.
8+
9+ **Strings returned by this library are borrowed.** They come out of scratch
10+ buffers the next call of the same family overwrites — props, tags and screen
11+ lines share one, dumps have their own, and an event's name and its text have
12+ one each, so an event can be read whole. Jolt copies a `:string` return into a
13+ Scheme string as it crosses, so holding the value is safe.
14+
15+ **Every call belongs to the thread that opened the session.** The library
16+ keeps its state in thread-local storage, so a call from anywhere else is inert
17+ rather than unsound — still a bug. `glimmer-tui.core` routes off-thread work
18+ through glimmer's `schedule`."
19+ (:require [jolt.ffi :as ffi]))
20+
21+;; --- the session -------------------------------------------------------------
22+;; Two ways in, one code path out of them: `raw-open` takes the terminal, and
23+;; `raw-headless` opens a session of a fixed size with no terminal at all
24+;; same layout, same painting, same focus ring, read back with `screen-line`.
25+(ffi/defcfn raw-open "tui_open" [:int] :int)
26+(ffi/defcfn raw-headless "tui_headless" [:int :int] :int)
27+(ffi/defcfn close! "tui_close" [] :void)
28+(ffi/defcfn raw-should-close "tui_should_close" [] :int)
29+(ffi/defcfn quit-loop! "tui_quit" [] :void)
30+
31+;; --- the loop ----------------------------------------------------------------
32+;; `tick` waits up to timeout_ms for input and answers how much it handled;
33+;; `frame!` lays the tree out, paints it and sends only the cells that changed.
34+(ffi/defcfn tick "tui_tick" [:int] :int)
35+(ffi/defcfn frame! "tui_frame" [] :void)
36+
37+;; --- the screen, in cells ----------------------------------------------------
38+(ffi/defcfn screen-width "tui_screen_width" [] :int)
39+(ffi/defcfn screen-height "tui_screen_height" [] :int)
40+(ffi/defcfn screen-line "tui_screen_line" [:int] :string)
41+
42+;; --- input by hand -----------------------------------------------------------
43+;; The same entry points a real terminal's input arrives through, so a test
44+;; drives the UI exactly as a person does.
45+(ffi/defcfn raw-feed-key "tui_feed_key" [:string] :int)
46+(ffi/defcfn raw-feed-click "tui_feed_click" [:int :int] :int)
47+(ffi/defcfn raw-feed-wheel "tui_feed_wheel" [:int :int :int] :int)
48+(ffi/defcfn focus "tui_focus" [] :int)
49+
50+;; --- the tree ----------------------------------------------------------------
51+(ffi/defcfn tree-root "tui_tree_root" [] :int)
52+(ffi/defcfn node-new "tui_node_new" [:string] :int)
53+(ffi/defcfn node-free! "tui_node_free" [:int] :void)
54+(ffi/defcfn raw-node-exists "tui_node_exists" [:int] :int)
55+
56+(ffi/defcfn node-set-str! "tui_node_set_str" [:int :string :string] :void)
57+(ffi/defcfn node-set-num! "tui_node_set_num" [:int :string :double] :void)
58+(ffi/defcfn raw-node-set-bool "tui_node_set_bool" [:int :string :int] :void)
59+(ffi/defcfn node-clear-props! "tui_node_clear_props" [:int] :void)
60+
61+(ffi/defcfn node-get-str "tui_node_get_str" [:int :string] :string)
62+(ffi/defcfn node-get-num "tui_node_get_num" [:int :string] :double)
63+(ffi/defcfn raw-node-get-bool "tui_node_get_bool" [:int :string] :int)
64+
65+(ffi/defcfn node-tag "tui_node_tag" [:int] :string)
66+(ffi/defcfn node-parent "tui_node_parent" [:int] :int)
67+(ffi/defcfn node-child-count "tui_node_child_count" [:int] :int)
68+(ffi/defcfn node-child-at "tui_node_child_at" [:int :int] :int)
69+(ffi/defcfn tree-dump "tui_tree_dump" [:int] :string)
70+
71+(ffi/defcfn raw-node-append "tui_node_append" [:int :int] :int)
72+(ffi/defcfn node-remove! "tui_node_remove" [:int :int] :void)
73+(ffi/defcfn raw-node-insert-after "tui_node_insert_after" [:int :int :int] :int)
74+(ffi/defcfn raw-node-replace "tui_node_replace" [:int :int :int] :int)
75+
76+;; --- events ------------------------------------------------------------------
77+(ffi/defcfn raw-poll-event "tui_tree_poll_event" [] :int)
78+(ffi/defcfn event-node "tui_tree_event_node" [] :int)
79+(ffi/defcfn event-name "tui_tree_event_name" [] :string)
80+(ffi/defcfn event-text "tui_tree_event_text" [] :string)
81+(ffi/defcfn event-num "tui_tree_event_num" [] :double)
82+
83+;; --- the int/bool seam -------------------------------------------------------
84+;; C has no booleans; every predicate here crosses as 0 or 1. Converting at the
85+;; binding rather than at each call site keeps the rest of the backend written
86+;; in jolt's own truthiness.
87+
88+(defn open!
89+ "Take the terminal: raw mode, the alternate screen, the cursor hidden unless a
90+ focused entry asks for it, and mouse reporting when `mouse?`."
91+ [mouse?]
92+ (not (zero? (raw-open (if mouse? 1 0)))))
93+
94+(defn headless!
95+ "Open a session of `width` by `height` cells with no terminal at all."
96+ [width height]
97+ (not (zero? (raw-headless width height))))
98+
99+(defn should-close? [] (not (zero? (raw-should-close))))
100+(defn node-exists? [node] (not (zero? (raw-node-exists node))))
101+(defn node-set-bool! [node key value] (raw-node-set-bool node key (if value 1 0)))
102+(defn node-get-bool [node key] (not (zero? (raw-node-get-bool node key))))
103+(defn node-append! [parent child] (not (zero? (raw-node-append parent child))))
104+(defn node-insert-after! [parent child sibling]
105+ (not (zero? (raw-node-insert-after parent child sibling))))
106+(defn node-replace! [parent old new] (not (zero? (raw-node-replace parent old new))))
107+(defn poll-event! [] (not (zero? (raw-poll-event))))
108+
109+(defn feed-key!
110+ "Type one key, named as the terminal names it. True when the backend acted on
111+ it, false when it went out as a `key` event instead."
112+ [name]
113+ (not (zero? (raw-feed-key name))))
114+
115+(defn feed-click! [x y] (not (zero? (raw-feed-click x y))))
116+(defn feed-wheel! [x y by] (not (zero? (raw-feed-wheel x y by))))
new file mode 100644
@@ -0,0 +1,116 @@
1+(ns glimmer-tui.ffi
2+ "Raw bindings for libjolttui's retained-tree ABI
3+ (`crates/jolt-tui/include/jolttui.h`).
4+
5+ Nothing here interprets a prop or an event; that is `glimmer-tui.core`'s job.
6+ This namespace exists so the boundary is one readable list of symbols, and so
7+ the marshalling rules that come with it are stated once.
8+
9+ **Strings returned by this library are borrowed.** They come out of scratch
10+ buffers the next call of the same family overwrites — props, tags and screen
11+ lines share one, dumps have their own, and an event's name and its text have
12+ one each, so an event can be read whole. Jolt copies a `:string` return into a
13+ Scheme string as it crosses, so holding the value is safe.
14+
15+ **Every call belongs to the thread that opened the session.** The library
16+ keeps its state in thread-local storage, so a call from anywhere else is inert
17+ rather than unsound — still a bug. `glimmer-tui.core` routes off-thread work
18+ through glimmer's `schedule`."
19+ (:require [jolt.ffi :as ffi]))
20+
21+;; --- the session -------------------------------------------------------------
22+;; Two ways in, one code path out of them: `raw-open` takes the terminal, and
23+;; `raw-headless` opens a session of a fixed size with no terminal at all
24+;; same layout, same painting, same focus ring, read back with `screen-line`.
25+(ffi/defcfn raw-open "tui_open" [:int] :int)
26+(ffi/defcfn raw-headless "tui_headless" [:int :int] :int)
27+(ffi/defcfn close! "tui_close" [] :void)
28+(ffi/defcfn raw-should-close "tui_should_close" [] :int)
29+(ffi/defcfn quit-loop! "tui_quit" [] :void)
30+
31+;; --- the loop ----------------------------------------------------------------
32+;; `tick` waits up to timeout_ms for input and answers how much it handled;
33+;; `frame!` lays the tree out, paints it and sends only the cells that changed.
34+(ffi/defcfn tick "tui_tick" [:int] :int)
35+(ffi/defcfn frame! "tui_frame" [] :void)
36+
37+;; --- the screen, in cells ----------------------------------------------------
38+(ffi/defcfn screen-width "tui_screen_width" [] :int)
39+(ffi/defcfn screen-height "tui_screen_height" [] :int)
40+(ffi/defcfn screen-line "tui_screen_line" [:int] :string)
41+
42+;; --- input by hand -----------------------------------------------------------
43+;; The same entry points a real terminal's input arrives through, so a test
44+;; drives the UI exactly as a person does.
45+(ffi/defcfn raw-feed-key "tui_feed_key" [:string] :int)
46+(ffi/defcfn raw-feed-click "tui_feed_click" [:int :int] :int)
47+(ffi/defcfn raw-feed-wheel "tui_feed_wheel" [:int :int :int] :int)
48+(ffi/defcfn focus "tui_focus" [] :int)
49+
50+;; --- the tree ----------------------------------------------------------------
51+(ffi/defcfn tree-root "tui_tree_root" [] :int)
52+(ffi/defcfn node-new "tui_node_new" [:string] :int)
53+(ffi/defcfn node-free! "tui_node_free" [:int] :void)
54+(ffi/defcfn raw-node-exists "tui_node_exists" [:int] :int)
55+
56+(ffi/defcfn node-set-str! "tui_node_set_str" [:int :string :string] :void)
57+(ffi/defcfn node-set-num! "tui_node_set_num" [:int :string :double] :void)
58+(ffi/defcfn raw-node-set-bool "tui_node_set_bool" [:int :string :int] :void)
59+(ffi/defcfn node-clear-props! "tui_node_clear_props" [:int] :void)
60+
61+(ffi/defcfn node-get-str "tui_node_get_str" [:int :string] :string)
62+(ffi/defcfn node-get-num "tui_node_get_num" [:int :string] :double)
63+(ffi/defcfn raw-node-get-bool "tui_node_get_bool" [:int :string] :int)
64+
65+(ffi/defcfn node-tag "tui_node_tag" [:int] :string)
66+(ffi/defcfn node-parent "tui_node_parent" [:int] :int)
67+(ffi/defcfn node-child-count "tui_node_child_count" [:int] :int)
68+(ffi/defcfn node-child-at "tui_node_child_at" [:int :int] :int)
69+(ffi/defcfn tree-dump "tui_tree_dump" [:int] :string)
70+
71+(ffi/defcfn raw-node-append "tui_node_append" [:int :int] :int)
72+(ffi/defcfn node-remove! "tui_node_remove" [:int :int] :void)
73+(ffi/defcfn raw-node-insert-after "tui_node_insert_after" [:int :int :int] :int)
74+(ffi/defcfn raw-node-replace "tui_node_replace" [:int :int :int] :int)
75+
76+;; --- events ------------------------------------------------------------------
77+(ffi/defcfn raw-poll-event "tui_tree_poll_event" [] :int)
78+(ffi/defcfn event-node "tui_tree_event_node" [] :int)
79+(ffi/defcfn event-name "tui_tree_event_name" [] :string)
80+(ffi/defcfn event-text "tui_tree_event_text" [] :string)
81+(ffi/defcfn event-num "tui_tree_event_num" [] :double)
82+
83+;; --- the int/bool seam -------------------------------------------------------
84+;; C has no booleans; every predicate here crosses as 0 or 1. Converting at the
85+;; binding rather than at each call site keeps the rest of the backend written
86+;; in jolt's own truthiness.
87+
88+(defn open!
89+ "Take the terminal: raw mode, the alternate screen, the cursor hidden unless a
90+ focused entry asks for it, and mouse reporting when `mouse?`."
91+ [mouse?]
92+ (not (zero? (raw-open (if mouse? 1 0)))))
93+
94+(defn headless!
95+ "Open a session of `width` by `height` cells with no terminal at all."
96+ [width height]
97+ (not (zero? (raw-headless width height))))
98+
99+(defn should-close? [] (not (zero? (raw-should-close))))
100+(defn node-exists? [node] (not (zero? (raw-node-exists node))))
101+(defn node-set-bool! [node key value] (raw-node-set-bool node key (if value 1 0)))
102+(defn node-get-bool [node key] (not (zero? (raw-node-get-bool node key))))
103+(defn node-append! [parent child] (not (zero? (raw-node-append parent child))))
104+(defn node-insert-after! [parent child sibling]
105+ (not (zero? (raw-node-insert-after parent child sibling))))
106+(defn node-replace! [parent old new] (not (zero? (raw-node-replace parent old new))))
107+(defn poll-event! [] (not (zero? (raw-poll-event))))
108+
109+(defn feed-key!
110+ "Type one key, named as the terminal names it. True when the backend acted on
111+ it, false when it went out as a `key` event instead."
112+ [name]
113+ (not (zero? (raw-feed-key name))))
114+
115+(defn feed-click! [x y] (not (zero? (raw-feed-click x y))))
116+(defn feed-wheel! [x y by] (not (zero? (raw-feed-wheel x y by))))