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>
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)))) | ||