Hand the window to a REPL, so the UI is something to poke at
glimmer-vidya could be run and it could be tested, but there was no way
to hold a running window still and change it. `jolt counter` recompiles
the component every time you want a different one, which is the loop this
toolkit exists to avoid.
`jolt repl-ui` is that window. Its whole contents are one ratom and a
prompt that writes to it — nothing in the arrangement is special to
REPLs, only the cell is driven by what you type rather than by a handler.
Hiccup is fixed at the moment you type it; a zero-arg function is mounted
as a component and re-runs whenever a cell it derefs changes, so
`(show! (fn [] [:label {:label (str "hits " @hits)}]))` follows a later
`(swap! hits inc)`. Either way the reconciler patches what changed, and
an `:entry` keeps its text and cursor across a `show!`.
Two boundary rules shaped it, both the library's rather than glimmer's.
The frame loop must own the main thread — winit will not create an event
loop anywhere else — so `-main` paints on the main thread and reads stdin
on a second one. From an editor it is the other way around: `jolt
nrepl-server` parks the main thread in a pump, and `start!` posts the
loop to it through `jolt.host/call-on-main-thread`, leaving eval free. An
event loop cannot be recreated either, so `stop!` ends the window for the
life of that process and `start!` says so rather than opening an
invisible one.
Nodes belong to the loop thread. `show!` is safe from anywhere because a
ratom is not a node, but the library keeps its arena in thread-local
storage, so anything that reads or writes the tree has to hop — which is
what `gui` does with `glimmer.core/on-gui`, and why `dump!` returns the
tree rather than an empty root. It waits on a promise by polling, because
`glimmer.ratom` replaces `deref` with a cell-aware one-arity version and
the timeout arity is not there to call.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>757d8e7 parent: 089e6f7 modified
.gitignore +5 -0 | @@ -13,3 +13,8 @@ | ||
| 13 | 13 | |
| 14 | 14 | # Staged output of `just buck-build`. |
| 15 | 15 | /build/ |
| 16 | + | |
| 17 | +# jolt's dependency-resolution cache, and the port file its nREPL server | |
| 18 | +# writes for an editor to find. | |
| 19 | +**/.jolt/cpcache/ | |
| 20 | +**/.nrepl-port | |
| @@ -13,3 +13,8 @@ | |||
| 13 | 13 | ||
| 14 | # Staged output of `just buck-build`. | 14 | # Staged output of `just buck-build`. |
| 15 | /build/ | 15 | /build/ |
| 16 | + | ||
| 17 | +# jolt's dependency-resolution cache, and the port file its nREPL server | ||
| 18 | +# writes for an editor to find. | ||
| 19 | +**/.jolt/cpcache/ | ||
| 20 | +**/.nrepl-port | ||
modified
jolt/glimmer-vidya/README.md +51 -0 | @@ -93,8 +93,59 @@ jolt test # the suite, headless: no window, display or GPU needed | ||
| 93 | 93 | jolt counter # the counter above |
| 94 | 94 | jolt showcase # every tag, a keyed task list, an entry, a disabled subtree |
| 95 | 95 | jolt smoke # non-interactive: reconciles under paint, then quits |
| 96 | +jolt repl-ui # a window you edit from a prompt, live | |
| 96 | 97 | ``` |
| 97 | 98 | |
| 99 | +## Live from a REPL | |
| 100 | + | |
| 101 | +`examples/glimmer_vidya/repl.jolt` is a window whose whole contents are one | |
| 102 | +ratom, and a REPL that writes to it. Nothing in it is special to REPLs — it is | |
| 103 | +the ordinary arrangement with the cell driven by what you type rather than by a | |
| 104 | +handler — but it turns the toolkit into something you poke at rather than | |
| 105 | +restart: | |
| 106 | + | |
| 107 | +```sh | |
| 108 | +LD_LIBRARY_PATH=../build jolt repl-ui | |
| 109 | +``` | |
| 110 | + | |
| 111 | +```clojure | |
| 112 | +ui=> (show! [:card {} [:title {:label "live"}] [:label {:label "typed just now"}]]) | |
| 113 | +ui=> (show! (fn [] [:label {:label (str "hits " @hits)}])) ; a component, not a picture | |
| 114 | +ui=> (swap! hits inc) ; …so this re-renders it | |
| 115 | +ui=> (dump!) | |
| 116 | +ui=> :quit | |
| 117 | +``` | |
| 118 | + | |
| 119 | +Hiccup is fixed at the moment you type it; a zero-arg function is mounted as a | |
| 120 | +component and re-runs whenever a cell it derefs changes. Either way the | |
| 121 | +reconciler patches what changed, so the window does not blink and an `:entry` | |
| 122 | +keeps its text and cursor across a `show!`. | |
| 123 | + | |
| 124 | +Two boundary rules shape the example, and both are the library's rather than | |
| 125 | +glimmer's: | |
| 126 | + | |
| 127 | +* **The frame loop must own the main thread.** winit refuses to create an event | |
| 128 | + loop anywhere else, so `-main` paints on the main thread and reads stdin on a | |
| 129 | + second one. From an editor it is the other way around — `jolt nrepl-server` | |
| 130 | + parks the main thread in a pump, and `start!` posts the loop to it through | |
| 131 | + `jolt.host/call-on-main-thread`, so eval stays free: | |
| 132 | + | |
| 133 | + ```clojure | |
| 134 | + (require '[glimmer-vidya.repl :as live]) | |
| 135 | + (live/start!) | |
| 136 | + (live/show! [:label {:label "from the editor"}]) | |
| 137 | + ``` | |
| 138 | + | |
| 139 | + An event loop cannot be recreated either, so `stop!` ends the window for the | |
| 140 | + life of that process and `start!` says so rather than opening an invisible | |
| 141 | + one. Editing the UI never needs a restart; that is the point. | |
| 142 | + | |
| 143 | +* **Nodes belong to the loop thread.** A `show!` is safe from anywhere because | |
| 144 | + a ratom is not a node — glimmer marshals the re-render through the backend's | |
| 145 | + `schedule`. Anything that reads or writes the tree itself has to hop, which | |
| 146 | + is what the example's `gui` helper does with `glimmer.core/on-gui` and why | |
| 147 | + its `dump!` returns the tree rather than an empty root. | |
| 148 | + | |
| 98 | 149 | ## Seeing what was rendered |
| 99 | 150 | |
| 100 | 151 | `dump` reads the tree back out of the library as hiccup — what is actually |
| @@ -93,8 +93,59 @@ jolt test # the suite, headless: no window, display or GPU needed | |||
| 93 | jolt counter # the counter above | 93 | jolt counter # the counter above |
| 94 | jolt showcase # every tag, a keyed task list, an entry, a disabled subtree | 94 | jolt showcase # every tag, a keyed task list, an entry, a disabled subtree |
| 95 | jolt smoke # non-interactive: reconciles under paint, then quits | 95 | jolt smoke # non-interactive: reconciles under paint, then quits |
| 96 | +jolt repl-ui # a window you edit from a prompt, live | ||
| 96 | ``` | 97 | ``` |
| 97 | 98 | ||
| 99 | +## Live from a REPL | ||
| 100 | + | ||
| 101 | +`examples/glimmer_vidya/repl.jolt` is a window whose whole contents are one | ||
| 102 | +ratom, and a REPL that writes to it. Nothing in it is special to REPLs — it is | ||
| 103 | +the ordinary arrangement with the cell driven by what you type rather than by a | ||
| 104 | +handler — but it turns the toolkit into something you poke at rather than | ||
| 105 | +restart: | ||
| 106 | + | ||
| 107 | +```sh | ||
| 108 | +LD_LIBRARY_PATH=../build jolt repl-ui | ||
| 109 | +``` | ||
| 110 | + | ||
| 111 | +```clojure | ||
| 112 | +ui=> (show! [:card {} [:title {:label "live"}] [:label {:label "typed just now"}]]) | ||
| 113 | +ui=> (show! (fn [] [:label {:label (str "hits " @hits)}])) ; a component, not a picture | ||
| 114 | +ui=> (swap! hits inc) ; …so this re-renders it | ||
| 115 | +ui=> (dump!) | ||
| 116 | +ui=> :quit | ||
| 117 | +``` | ||
| 118 | + | ||
| 119 | +Hiccup is fixed at the moment you type it; a zero-arg function is mounted as a | ||
| 120 | +component and re-runs whenever a cell it derefs changes. Either way the | ||
| 121 | +reconciler patches what changed, so the window does not blink and an `:entry` | ||
| 122 | +keeps its text and cursor across a `show!`. | ||
| 123 | + | ||
| 124 | +Two boundary rules shape the example, and both are the library's rather than | ||
| 125 | +glimmer's: | ||
| 126 | + | ||
| 127 | +* **The frame loop must own the main thread.** winit refuses to create an event | ||
| 128 | + loop anywhere else, so `-main` paints on the main thread and reads stdin on a | ||
| 129 | + second one. From an editor it is the other way around — `jolt nrepl-server` | ||
| 130 | + parks the main thread in a pump, and `start!` posts the loop to it through | ||
| 131 | + `jolt.host/call-on-main-thread`, so eval stays free: | ||
| 132 | + | ||
| 133 | + ```clojure | ||
| 134 | + (require '[glimmer-vidya.repl :as live]) | ||
| 135 | + (live/start!) | ||
| 136 | + (live/show! [:label {:label "from the editor"}]) | ||
| 137 | + ``` | ||
| 138 | + | ||
| 139 | + An event loop cannot be recreated either, so `stop!` ends the window for the | ||
| 140 | + life of that process and `start!` says so rather than opening an invisible | ||
| 141 | + one. Editing the UI never needs a restart; that is the point. | ||
| 142 | + | ||
| 143 | +* **Nodes belong to the loop thread.** A `show!` is safe from anywhere because | ||
| 144 | + a ratom is not a node — glimmer marshals the re-render through the backend's | ||
| 145 | + `schedule`. Anything that reads or writes the tree itself has to hop, which | ||
| 146 | + is what the example's `gui` helper does with `glimmer.core/on-gui` and why | ||
| 147 | + its `dump!` returns the tree rather than an empty root. | ||
| 148 | + | ||
| 98 | ## Seeing what was rendered | 149 | ## Seeing what was rendered |
| 99 | 150 | ||
| 100 | `dump` reads the tree back out of the library as hiccup — what is actually | 151 | `dump` reads the tree back out of the library as hiccup — what is actually |
modified
jolt/glimmer-vidya/deps.edn +5 -2 | @@ -25,9 +25,12 @@ | ||
| 25 | 25 | :showcase {:extra-paths ["examples"] |
| 26 | 26 | :main-opts ["-m" "glimmer-vidya.showcase"]} |
| 27 | 27 | :smoke {:extra-paths ["examples"] |
| 28 | - :main-opts ["-m" "glimmer-vidya.smoke"]}} | |
| 28 | + :main-opts ["-m" "glimmer-vidya.smoke"]} | |
| 29 | + :repl-ui {:extra-paths ["examples"] | |
| 30 | + :main-opts ["-m" "glimmer-vidya.repl"]}} | |
| 29 | 31 | |
| 30 | 32 | :tasks {test "jolt -M:test" |
| 31 | 33 | counter "jolt -M:counter" |
| 32 | 34 | showcase "jolt -M:showcase" |
| 33 | - smoke "jolt -M:smoke"}} | |
| 35 | + smoke "jolt -M:smoke" | |
| 36 | + repl-ui "jolt -M:repl-ui"}} | |
| @@ -25,9 +25,12 @@ | |||
| 25 | :showcase {:extra-paths ["examples"] | 25 | :showcase {:extra-paths ["examples"] |
| 26 | :main-opts ["-m" "glimmer-vidya.showcase"]} | 26 | :main-opts ["-m" "glimmer-vidya.showcase"]} |
| 27 | :smoke {:extra-paths ["examples"] | 27 | :smoke {:extra-paths ["examples"] |
| 28 | - :main-opts ["-m" "glimmer-vidya.smoke"]}} | 28 | + :main-opts ["-m" "glimmer-vidya.smoke"]} |
| 29 | + :repl-ui {:extra-paths ["examples"] | ||
| 30 | + :main-opts ["-m" "glimmer-vidya.repl"]}} | ||
| 29 | 31 | ||
| 30 | :tasks {test "jolt -M:test" | 32 | :tasks {test "jolt -M:test" |
| 31 | counter "jolt -M:counter" | 33 | counter "jolt -M:counter" |
| 32 | showcase "jolt -M:showcase" | 34 | showcase "jolt -M:showcase" |
| 33 | - smoke "jolt -M:smoke"}} | 35 | + smoke "jolt -M:smoke" |
| 36 | + repl-ui "jolt -M:repl-ui"}} | ||
added
jolt/glimmer-vidya/examples/glimmer_vidya/repl.jolt +193 -0 | new file mode 100644 | ||
| @@ -0,0 +1,193 @@ | ||
| 1 | +(ns glimmer-vidya.repl | |
| 2 | + "A window you edit while it is open. | |
| 3 | + | |
| 4 | + The UI is one ratom holding hiccup, and a REPL that `reset!`s it. Nothing | |
| 5 | + here is special to REPLs — it is the ordinary glimmer arrangement, pointed at | |
| 6 | + a cell whose new value you type rather than one a handler computes. Every | |
| 7 | + `show!` re-renders through the reconciler, so only what changed is touched: | |
| 8 | + the window does not blink and an `:entry` keeps its text. | |
| 9 | + | |
| 10 | + Two ways in, both of them evaluating in this namespace. | |
| 11 | + | |
| 12 | + **A prompt in the terminal** — the loop owns the main thread, a reader thread | |
| 13 | + owns stdin: | |
| 14 | + | |
| 15 | + LD_LIBRARY_PATH=../build jolt repl-ui | |
| 16 | + | |
| 17 | + ui=> (show! [:card {} [:title {:label \"live\"}]]) | |
| 18 | + ui=> (swap! hits inc) | |
| 19 | + ui=> (dump!) | |
| 20 | + ui=> :quit | |
| 21 | + | |
| 22 | + **An editor, over nREPL** — no terminal prompt; `start!` hands the loop to | |
| 23 | + the parked main thread, so eval stays free: | |
| 24 | + | |
| 25 | + LD_LIBRARY_PATH=../build jolt nrepl-server | |
| 26 | + | |
| 27 | + (require '[glimmer-vidya.repl :as live]) | |
| 28 | + (live/start!) | |
| 29 | + (live/show! [:label {:label \"from the editor\"}]) | |
| 30 | + | |
| 31 | + Both are safe from any thread: `show!` only touches a ratom, and glimmer | |
| 32 | + marshals the re-render onto the loop thread through the backend's `schedule`. | |
| 33 | + Code that wants to touch nodes directly goes through `glimmer.core/on-gui`." | |
| 34 | + (:require [glimmer.ratom :as r :refer [atom]] | |
| 35 | + [glimmer.core :as ui] | |
| 36 | + [glimmer-vidya.core :as vidya] | |
| 37 | + [jolt.host :as host])) | |
| 38 | + | |
| 39 | +;; `defonce`, so re-evaluating this file from the editor keeps the window's | |
| 40 | +;; current contents rather than snapping it back to the greeting. | |
| 41 | +(defonce view | |
| 42 | + (atom [:page {:max-width 520} | |
| 43 | + [:card {} | |
| 44 | + [:title {:label "glimmer-vidya"}] | |
| 45 | + [:label {:label "Type hiccup at the prompt; this changes."}] | |
| 46 | + [:dim-label {:label "(show! [:label {:label \"hello\"}])"}]]])) | |
| 47 | + | |
| 48 | +;; A cell of your own to reach for: `(show! (fn [] ... @hits ...))` and then | |
| 49 | +;; `(swap! hits inc)` from the prompt, or from a handler you installed with it. | |
| 50 | +(defonce hits (atom 0)) | |
| 51 | + | |
| 52 | +(defn root | |
| 53 | + "The whole app: whatever `view` holds. A component that derefs one cell is | |
| 54 | + all it takes for the reconciler to follow along. | |
| 55 | + | |
| 56 | + A function in there is mounted as a component rather than returned as data, | |
| 57 | + which is the difference between a picture and a program: hiccup you typed is | |
| 58 | + fixed at the moment you typed it, while a function re-runs whenever a cell it | |
| 59 | + derefs changes." | |
| 60 | + [] | |
| 61 | + (let [v @view] | |
| 62 | + (if (fn? v) [v] v))) | |
| 63 | + | |
| 64 | +(defn show! | |
| 65 | + "Replace the window's contents. | |
| 66 | + | |
| 67 | + Give it hiccup for a one-off, or a zero-arg function for something that keeps | |
| 68 | + reacting after the form that defined it returned: | |
| 69 | + | |
| 70 | + (show! [:label {:label (str \"hits \" @hits)}]) ; frozen at that count | |
| 71 | + (show! (fn [] [:label {:label (str \"hits \" @hits)}])) ; follows it | |
| 72 | + | |
| 73 | + Returns what it was given, so the REPL prints the tree you asked for." | |
| 74 | + [hiccup-or-fn] | |
| 75 | + (reset! view hiccup-or-fn)) | |
| 76 | + | |
| 77 | +(defn gui | |
| 78 | + "Run `f` on the loop thread and return what it returned, waiting up to | |
| 79 | + `ms` (1s by default) for it. | |
| 80 | + | |
| 81 | + Reading or writing a node is the loop thread's business — the library keeps | |
| 82 | + its arena in thread-local storage, so the same call from here would find an | |
| 83 | + empty tree rather than fail. `show!` needs none of this, because a ratom is | |
| 84 | + not a node; anything in `glimmer-vidya.core` that names one does." | |
| 85 | + ([f] (gui f 1000)) | |
| 86 | + ([f ms] | |
| 87 | + (let [p (promise)] | |
| 88 | + (ui/on-gui #(deliver p (try {:ok (f)} (catch Exception e {:err e})))) | |
| 89 | + ;; Polled rather than `(deref p ms ...)`: glimmer.ratom replaces `deref` | |
| 90 | + ;; with a cell-aware one-arity version, so the timeout arity is not there | |
| 91 | + ;; to call in a namespace that has required it. | |
| 92 | + (loop [waited 0] | |
| 93 | + (cond | |
| 94 | + (realized? p) (let [{:keys [ok err]} @p] (if err (throw err) ok)) | |
| 95 | + (>= waited ms) (throw (ex-info "no window is running" {:waited-ms ms})) | |
| 96 | + :else (do (Thread/sleep 10) (recur (+ waited 10)))))))) | |
| 97 | + | |
| 98 | +(defn title! | |
| 99 | + "Rename the open window. Like everything that touches the window rather than | |
| 100 | + a ratom, it belongs to the loop thread — called straight from here it would | |
| 101 | + find no app in its thread-local slot and do nothing at all, quietly." | |
| 102 | + [t] | |
| 103 | + (gui #(vidya/set-title! t)) | |
| 104 | + t) | |
| 105 | + | |
| 106 | +(defn dump! | |
| 107 | + "Print what is actually mounted, after reconciliation — the answer to \"did | |
| 108 | + that render the way I meant?\". Hops to the loop thread to read it." | |
| 109 | + [] | |
| 110 | + (println (gui vidya/dump-str)) | |
| 111 | + nil) | |
| 112 | + | |
| 113 | +(defn dump | |
| 114 | + "`dump!` as hiccup data, for comparing against what you meant to send." | |
| 115 | + [] | |
| 116 | + (read-string (gui vidya/dump-str))) | |
| 117 | + | |
| 118 | +;; --- the editor entry point -------------------------------------------------- | |
| 119 | +(defonce ^:private window (atom nil)) | |
| 120 | + | |
| 121 | +(defn start! | |
| 122 | + "Open the window and return immediately, so an nREPL session stays free to | |
| 123 | + evaluate. Options are `ui/run`'s. | |
| 124 | + | |
| 125 | + The loop is handed to the **main** thread rather than started here. winit | |
| 126 | + refuses to create an event loop anywhere else, and an nREPL eval runs on a | |
| 127 | + worker — so the work is posted to the main thread, which | |
| 128 | + `jolt nrepl-server` has parked in a pump for exactly this. That also means | |
| 129 | + this belongs in an nREPL session and not in a `-main`, where the main thread | |
| 130 | + is yours already and `ui/run` is the plainer thing to call. | |
| 131 | + | |
| 132 | + Once per process, and this is winit's rule rather than a shortcut here: an | |
| 133 | + event loop cannot be recreated, so a window that has been closed stays | |
| 134 | + closed and `start!` says so instead of opening an invisible one. Editing the | |
| 135 | + UI needs no restart — that is what `show!` is for — but `stop!` does." | |
| 136 | + [& opts] | |
| 137 | + (let [w @window] | |
| 138 | + (cond | |
| 139 | + (and w (not (realized? w))) | |
| 140 | + (do (println "already running") nil) | |
| 141 | + | |
| 142 | + w | |
| 143 | + (throw (ex-info (str "this process has already run its window and winit " | |
| 144 | + "cannot recreate an event loop — restart the nREPL " | |
| 145 | + "server to get another") | |
| 146 | + {:result @w})) | |
| 147 | + | |
| 148 | + :else | |
| 149 | + (do (reset! window | |
| 150 | + (future (host/call-on-main-thread | |
| 151 | + #(apply ui/run root | |
| 152 | + :title "repl" :width 560 :height 420 opts)))) | |
| 153 | + ;; Long enough to catch an immediate failure — a window that cannot | |
| 154 | + ;; open should throw here, not sit unnoticed inside a future. | |
| 155 | + (Thread/sleep 300) | |
| 156 | + (when (realized? @window) @@window) | |
| 157 | + nil)))) | |
| 158 | + | |
| 159 | +(defn stop! | |
| 160 | + "Close the window, ending this process's one event loop." | |
| 161 | + [] | |
| 162 | + (vidya/quit!) | |
| 163 | + nil) | |
| 164 | + | |
| 165 | +;; --- the terminal entry point ------------------------------------------------ | |
| 166 | +(defn- prompt-loop! | |
| 167 | + "Read forms from stdin and evaluate them in this namespace, printing each | |
| 168 | + result. Runs off the main thread; the frame loop has that one. | |
| 169 | + | |
| 170 | + `:quit`, or EOF (Ctrl-D), stops the window and with it the process. A form | |
| 171 | + that throws prints its exception and the prompt comes back — the window is | |
| 172 | + never brought down by a typo." | |
| 173 | + [] | |
| 174 | + (let [ns' (the-ns 'glimmer-vidya.repl)] | |
| 175 | + (loop [] | |
| 176 | + (print "ui=> ") | |
| 177 | + (flush) | |
| 178 | + (let [form (try (read {:eof ::eof} *in*) | |
| 179 | + (catch Exception e (println "read:" (ex-message e)) nil))] | |
| 180 | + (cond | |
| 181 | + (or (= form ::eof) (= form :quit)) (vidya/quit!) | |
| 182 | + :else (do (when (some? form) | |
| 183 | + (try (prn (binding [*ns* ns'] (eval form))) | |
| 184 | + (catch Exception e (println "error:" (ex-message e))))) | |
| 185 | + (recur))))))) | |
| 186 | + | |
| 187 | +(defn -main [& _] | |
| 188 | + ;; The reader goes on the background thread rather than the loop, because on | |
| 189 | + ;; macOS the window has to be the main one and because a blocking `read` must | |
| 190 | + ;; never sit between two frames. | |
| 191 | + (future (prompt-loop!)) | |
| 192 | + (ui/run root :title "repl" :width 560 :height 420) | |
| 193 | + (println "\nrepl: window closed")) | |
| new file mode 100644 | |||
| @@ -0,0 +1,193 @@ | |||
| 1 | +(ns glimmer-vidya.repl | ||
| 2 | + "A window you edit while it is open. | ||
| 3 | + | ||
| 4 | + The UI is one ratom holding hiccup, and a REPL that `reset!`s it. Nothing | ||
| 5 | + here is special to REPLs — it is the ordinary glimmer arrangement, pointed at | ||
| 6 | + a cell whose new value you type rather than one a handler computes. Every | ||
| 7 | + `show!` re-renders through the reconciler, so only what changed is touched: | ||
| 8 | + the window does not blink and an `:entry` keeps its text. | ||
| 9 | + | ||
| 10 | + Two ways in, both of them evaluating in this namespace. | ||
| 11 | + | ||
| 12 | + **A prompt in the terminal** — the loop owns the main thread, a reader thread | ||
| 13 | + owns stdin: | ||
| 14 | + | ||
| 15 | + LD_LIBRARY_PATH=../build jolt repl-ui | ||
| 16 | + | ||
| 17 | + ui=> (show! [:card {} [:title {:label \"live\"}]]) | ||
| 18 | + ui=> (swap! hits inc) | ||
| 19 | + ui=> (dump!) | ||
| 20 | + ui=> :quit | ||
| 21 | + | ||
| 22 | + **An editor, over nREPL** — no terminal prompt; `start!` hands the loop to | ||
| 23 | + the parked main thread, so eval stays free: | ||
| 24 | + | ||
| 25 | + LD_LIBRARY_PATH=../build jolt nrepl-server | ||
| 26 | + | ||
| 27 | + (require '[glimmer-vidya.repl :as live]) | ||
| 28 | + (live/start!) | ||
| 29 | + (live/show! [:label {:label \"from the editor\"}]) | ||
| 30 | + | ||
| 31 | + Both are safe from any thread: `show!` only touches a ratom, and glimmer | ||
| 32 | + marshals the re-render onto the loop thread through the backend's `schedule`. | ||
| 33 | + Code that wants to touch nodes directly goes through `glimmer.core/on-gui`." | ||
| 34 | + (:require [glimmer.ratom :as r :refer [atom]] | ||
| 35 | + [glimmer.core :as ui] | ||
| 36 | + [glimmer-vidya.core :as vidya] | ||
| 37 | + [jolt.host :as host])) | ||
| 38 | + | ||
| 39 | +;; `defonce`, so re-evaluating this file from the editor keeps the window's | ||
| 40 | +;; current contents rather than snapping it back to the greeting. | ||
| 41 | +(defonce view | ||
| 42 | + (atom [:page {:max-width 520} | ||
| 43 | + [:card {} | ||
| 44 | + [:title {:label "glimmer-vidya"}] | ||
| 45 | + [:label {:label "Type hiccup at the prompt; this changes."}] | ||
| 46 | + [:dim-label {:label "(show! [:label {:label \"hello\"}])"}]]])) | ||
| 47 | + | ||
| 48 | +;; A cell of your own to reach for: `(show! (fn [] ... @hits ...))` and then | ||
| 49 | +;; `(swap! hits inc)` from the prompt, or from a handler you installed with it. | ||
| 50 | +(defonce hits (atom 0)) | ||
| 51 | + | ||
| 52 | +(defn root | ||
| 53 | + "The whole app: whatever `view` holds. A component that derefs one cell is | ||
| 54 | + all it takes for the reconciler to follow along. | ||
| 55 | + | ||
| 56 | + A function in there is mounted as a component rather than returned as data, | ||
| 57 | + which is the difference between a picture and a program: hiccup you typed is | ||
| 58 | + fixed at the moment you typed it, while a function re-runs whenever a cell it | ||
| 59 | + derefs changes." | ||
| 60 | + [] | ||
| 61 | + (let [v @view] | ||
| 62 | + (if (fn? v) [v] v))) | ||
| 63 | + | ||
| 64 | +(defn show! | ||
| 65 | + "Replace the window's contents. | ||
| 66 | + | ||
| 67 | + Give it hiccup for a one-off, or a zero-arg function for something that keeps | ||
| 68 | + reacting after the form that defined it returned: | ||
| 69 | + | ||
| 70 | + (show! [:label {:label (str \"hits \" @hits)}]) ; frozen at that count | ||
| 71 | + (show! (fn [] [:label {:label (str \"hits \" @hits)}])) ; follows it | ||
| 72 | + | ||
| 73 | + Returns what it was given, so the REPL prints the tree you asked for." | ||
| 74 | + [hiccup-or-fn] | ||
| 75 | + (reset! view hiccup-or-fn)) | ||
| 76 | + | ||
| 77 | +(defn gui | ||
| 78 | + "Run `f` on the loop thread and return what it returned, waiting up to | ||
| 79 | + `ms` (1s by default) for it. | ||
| 80 | + | ||
| 81 | + Reading or writing a node is the loop thread's business — the library keeps | ||
| 82 | + its arena in thread-local storage, so the same call from here would find an | ||
| 83 | + empty tree rather than fail. `show!` needs none of this, because a ratom is | ||
| 84 | + not a node; anything in `glimmer-vidya.core` that names one does." | ||
| 85 | + ([f] (gui f 1000)) | ||
| 86 | + ([f ms] | ||
| 87 | + (let [p (promise)] | ||
| 88 | + (ui/on-gui #(deliver p (try {:ok (f)} (catch Exception e {:err e})))) | ||
| 89 | + ;; Polled rather than `(deref p ms ...)`: glimmer.ratom replaces `deref` | ||
| 90 | + ;; with a cell-aware one-arity version, so the timeout arity is not there | ||
| 91 | + ;; to call in a namespace that has required it. | ||
| 92 | + (loop [waited 0] | ||
| 93 | + (cond | ||
| 94 | + (realized? p) (let [{:keys [ok err]} @p] (if err (throw err) ok)) | ||
| 95 | + (>= waited ms) (throw (ex-info "no window is running" {:waited-ms ms})) | ||
| 96 | + :else (do (Thread/sleep 10) (recur (+ waited 10)))))))) | ||
| 97 | + | ||
| 98 | +(defn title! | ||
| 99 | + "Rename the open window. Like everything that touches the window rather than | ||
| 100 | + a ratom, it belongs to the loop thread — called straight from here it would | ||
| 101 | + find no app in its thread-local slot and do nothing at all, quietly." | ||
| 102 | + [t] | ||
| 103 | + (gui #(vidya/set-title! t)) | ||
| 104 | + t) | ||
| 105 | + | ||
| 106 | +(defn dump! | ||
| 107 | + "Print what is actually mounted, after reconciliation — the answer to \"did | ||
| 108 | + that render the way I meant?\". Hops to the loop thread to read it." | ||
| 109 | + [] | ||
| 110 | + (println (gui vidya/dump-str)) | ||
| 111 | + nil) | ||
| 112 | + | ||
| 113 | +(defn dump | ||
| 114 | + "`dump!` as hiccup data, for comparing against what you meant to send." | ||
| 115 | + [] | ||
| 116 | + (read-string (gui vidya/dump-str))) | ||
| 117 | + | ||
| 118 | +;; --- the editor entry point -------------------------------------------------- | ||
| 119 | +(defonce ^:private window (atom nil)) | ||
| 120 | + | ||
| 121 | +(defn start! | ||
| 122 | + "Open the window and return immediately, so an nREPL session stays free to | ||
| 123 | + evaluate. Options are `ui/run`'s. | ||
| 124 | + | ||
| 125 | + The loop is handed to the **main** thread rather than started here. winit | ||
| 126 | + refuses to create an event loop anywhere else, and an nREPL eval runs on a | ||
| 127 | + worker — so the work is posted to the main thread, which | ||
| 128 | + `jolt nrepl-server` has parked in a pump for exactly this. That also means | ||
| 129 | + this belongs in an nREPL session and not in a `-main`, where the main thread | ||
| 130 | + is yours already and `ui/run` is the plainer thing to call. | ||
| 131 | + | ||
| 132 | + Once per process, and this is winit's rule rather than a shortcut here: an | ||
| 133 | + event loop cannot be recreated, so a window that has been closed stays | ||
| 134 | + closed and `start!` says so instead of opening an invisible one. Editing the | ||
| 135 | + UI needs no restart — that is what `show!` is for — but `stop!` does." | ||
| 136 | + [& opts] | ||
| 137 | + (let [w @window] | ||
| 138 | + (cond | ||
| 139 | + (and w (not (realized? w))) | ||
| 140 | + (do (println "already running") nil) | ||
| 141 | + | ||
| 142 | + w | ||
| 143 | + (throw (ex-info (str "this process has already run its window and winit " | ||
| 144 | + "cannot recreate an event loop — restart the nREPL " | ||
| 145 | + "server to get another") | ||
| 146 | + {:result @w})) | ||
| 147 | + | ||
| 148 | + :else | ||
| 149 | + (do (reset! window | ||
| 150 | + (future (host/call-on-main-thread | ||
| 151 | + #(apply ui/run root | ||
| 152 | + :title "repl" :width 560 :height 420 opts)))) | ||
| 153 | + ;; Long enough to catch an immediate failure — a window that cannot | ||
| 154 | + ;; open should throw here, not sit unnoticed inside a future. | ||
| 155 | + (Thread/sleep 300) | ||
| 156 | + (when (realized? @window) @@window) | ||
| 157 | + nil)))) | ||
| 158 | + | ||
| 159 | +(defn stop! | ||
| 160 | + "Close the window, ending this process's one event loop." | ||
| 161 | + [] | ||
| 162 | + (vidya/quit!) | ||
| 163 | + nil) | ||
| 164 | + | ||
| 165 | +;; --- the terminal entry point ------------------------------------------------ | ||
| 166 | +(defn- prompt-loop! | ||
| 167 | + "Read forms from stdin and evaluate them in this namespace, printing each | ||
| 168 | + result. Runs off the main thread; the frame loop has that one. | ||
| 169 | + | ||
| 170 | + `:quit`, or EOF (Ctrl-D), stops the window and with it the process. A form | ||
| 171 | + that throws prints its exception and the prompt comes back — the window is | ||
| 172 | + never brought down by a typo." | ||
| 173 | + [] | ||
| 174 | + (let [ns' (the-ns 'glimmer-vidya.repl)] | ||
| 175 | + (loop [] | ||
| 176 | + (print "ui=> ") | ||
| 177 | + (flush) | ||
| 178 | + (let [form (try (read {:eof ::eof} *in*) | ||
| 179 | + (catch Exception e (println "read:" (ex-message e)) nil))] | ||
| 180 | + (cond | ||
| 181 | + (or (= form ::eof) (= form :quit)) (vidya/quit!) | ||
| 182 | + :else (do (when (some? form) | ||
| 183 | + (try (prn (binding [*ns* ns'] (eval form))) | ||
| 184 | + (catch Exception e (println "error:" (ex-message e))))) | ||
| 185 | + (recur))))))) | ||
| 186 | + | ||
| 187 | +(defn -main [& _] | ||
| 188 | + ;; The reader goes on the background thread rather than the loop, because on | ||
| 189 | + ;; macOS the window has to be the main one and because a blocking `read` must | ||
| 190 | + ;; never sit between two frames. | ||
| 191 | + (future (prompt-loop!)) | ||
| 192 | + (ui/run root :title "repl" :width 560 :height 420) | ||
| 193 | + (println "\nrepl: window closed")) | ||