|
| 1 | +^:kindly/hide-code |
| 2 | +^{:clay {:title "I'll take a side of REPL with that" |
| 3 | + :quarto {:author [:timothypratley] |
| 4 | + :description "Selective interaction with a persistent REPL sidebar inside your Clay documents" |
| 5 | + :category :clojure |
| 6 | + :type :post |
| 7 | + :date "2025-08-05" |
| 8 | + :tags [:repl :clay] |
| 9 | + :page-layout :full}}} |
| 10 | +(ns civitas.repl |
| 11 | + (:require [scicloj.kindly.v4.kind :as kind])) |
| 12 | + |
| 13 | +;; > "Would you like to make that interactive?" |
| 14 | +;; > |
| 15 | +;; > -- The eternal question facing documentation writers. |
| 16 | + |
| 17 | +;; Most code documentation has either static code with fixed results |
| 18 | +;; or dynamic editor blocks with evaluated results. |
| 19 | +;; This post explores a third option, a sidebar REPL that coexists with static examples, |
| 20 | +;; letting you copy interesting snippets and build on them. |
| 21 | + |
| 22 | +;; ## Persistent Workspace Benefits |
| 23 | + |
| 24 | +;; The sidebar REPL mirrors the intuitive "one environment, one REPL" model, |
| 25 | +;; keeping state across interactions. |
| 26 | + |
| 27 | +(kind/hiccup |
| 28 | + '((require '[reagent.core :as r]) |
| 29 | + (defonce state (r/atom {:zaniness "moderate" |
| 30 | + :mood "curious"})))) |
| 31 | + |
| 32 | +;; We start with some initial mood and zaniness. |
| 33 | +;; Here's a suggestion to modify it: |
| 34 | + |
| 35 | +;; ```clojure |
| 36 | +;; (swap! state assoc :zaniness "maximum") |
| 37 | +;; ``` |
| 38 | + |
| 39 | +;; ::: {.callout-tip} |
| 40 | +;; Click the "copy" icon in the code example to transfer it to the REPL window. |
| 41 | +;; ::: |
| 42 | + |
| 43 | +;; And now we'll add to the post a mini-app for monitoring the state. |
| 44 | + |
| 45 | +(kind/hiccup |
| 46 | + [:div.card {:style {:margin "20px 0" |
| 47 | + :max-width "600px"}} |
| 48 | + [:div.card-header.bg-success.text-white |
| 49 | + [:h5.mb-0 "🔍 State Monitor"]] |
| 50 | + [:div.card-body |
| 51 | + [:dl.row.mb-0 |
| 52 | + [:dt.col-sm-3.text-end "Current state:"] |
| 53 | + [:dd.col-sm-9 |
| 54 | + [:div.p-2.bg-light.border.rounded.font-monospace.small |
| 55 | + ['(fn [] (pr-str @state))]]] |
| 56 | + [:dt.col-sm-3.text-end "Instructions:"] |
| 57 | + [:dd.col-sm-9.text-muted.small |
| 58 | + "Try: " [:div.sourceCode [:code "(swap! state assoc :key \"value\")"]] " in the REPL"]]]]) |
| 59 | + |
| 60 | +;; The state you create in the REPL affects page components. |
| 61 | + |
| 62 | +;; ## Curated and Open Exploration |
| 63 | + |
| 64 | +;; Authors can provide focused examples for specific learning goals, |
| 65 | +;; while readers can pursue tangents. |
| 66 | +;; Here's an example showcasing Clojure's `frequencies` function with some delicious data: |
| 67 | + |
| 68 | +(frequencies ["apple" |
| 69 | + "banana" |
| 70 | + "apple" |
| 71 | + "cherry" |
| 72 | + "banana" |
| 73 | + "apple"]) |
| 74 | + |
| 75 | +;; Try it with different data, maybe the letters in your name. |
| 76 | + |
| 77 | +;; ```clojure |
| 78 | +;; (frequencies "supercalifragilisticexpialidocious") |
| 79 | +;; ``` |
| 80 | + |
| 81 | +;; Or simulate random selections: |
| 82 | + |
| 83 | +;; ```clojure |
| 84 | +;; (frequencies (repeatedly 1000 |
| 85 | +;; #(rand-nth [:a :b :c :d :e :f]))) |
| 86 | +;; ``` |
| 87 | + |
| 88 | +;; ## Visual Playground |
| 89 | + |
| 90 | +;; The REPL can affect components on the page. |
| 91 | +;; This interactive canvas demonstrates how data can drive visual elements. |
| 92 | + |
| 93 | +(kind/hiccup |
| 94 | + [:div.card {:style {:margin "20px 0"}} |
| 95 | + [:div.card-header.bg-primary.text-white |
| 96 | + [:h5.mb-0 "🎨 Interactive Canvas"]] |
| 97 | + [:div.card-body |
| 98 | + [:p.mb-3 "Shapes controlled by " [:code "@state"] " — use the REPL to create and modify them."] |
| 99 | + ['(fn [] |
| 100 | + [:svg {:xmlns "http://www.w3.org/2000/svg" |
| 101 | + :width "100%" |
| 102 | + :height "250px" |
| 103 | + :viewBox "0 0 500 250" |
| 104 | + :style {:border "2px solid #dee2e6" :border-radius "4px" :background "#f8f9fa"}} |
| 105 | + ;; Grid pattern |
| 106 | + [:defs |
| 107 | + [:pattern {:id "grid" :width "50" :height "50" :patternUnits "userSpaceOnUse"} |
| 108 | + [:path {:d "M 50 0 L 0 0 0 50" :fill "none" :stroke "#e9ecef" :stroke-width "1"}]]] |
| 109 | + [:rect {:width "500" :height "250" :fill "url(#grid)"}] |
| 110 | + ;; Dynamic shapes from state |
| 111 | + (for [{:keys [type x y size color]} (:shapes @state)] |
| 112 | + (case type |
| 113 | + :circle [:circle {:cx x :cy y :r size :fill color :stroke "#333" :stroke-width 1}] |
| 114 | + :square [:rect {:x (- x size) :y (- y size) |
| 115 | + :width (* 2 size) :height (* 2 size) |
| 116 | + :fill color :stroke "#333" :stroke-width 1}] |
| 117 | + :triangle [:polygon {:points (str x "," (- y size) " " |
| 118 | + (- x size) "," (+ y size) " " |
| 119 | + (+ x size) "," (+ y size)) |
| 120 | + :fill color :stroke "#333" :stroke-width 1}] |
| 121 | + nil))])]]]) |
| 122 | + |
| 123 | +;; Add a single shape (maybe a tiny orange dot? 🟠): |
| 124 | + |
| 125 | +;; ```clojure |
| 126 | +;; (swap! state update :shapes conj |
| 127 | +;; {:type :circle :x 250 :y 200 :size 15 :color "#f39c12"}) |
| 128 | +;; ``` |
| 129 | + |
| 130 | +;; ::: {.callout-tip} |
| 131 | +;; Click the "copy" icon in the code example to transfer it to the REPL window, then press CTRL+Enter to eval. |
| 132 | +;; ::: |
| 133 | + |
| 134 | +;; Add more shapes by modifying the `:shapes` vector: |
| 135 | + |
| 136 | +;; ```clojure |
| 137 | +;; (swap! state update :shapes into |
| 138 | +;; [{:type :circle :x 100 :y 100 :size 30 :color "#e74c3c"} |
| 139 | +;; {:type :square :x 200 :y 150 :size 25 :color "#3498db"} |
| 140 | +;; {:type :triangle :x 350 :y 100 :size 40 :color "#2ecc71"}]) |
| 141 | +;; ``` |
| 142 | + |
| 143 | +;; Generate a ✨random constellation✨: |
| 144 | + |
| 145 | +;; ```clojure |
| 146 | +;; (swap! state assoc :shapes |
| 147 | +;; (repeatedly 15 |
| 148 | +;; #(hash-map :type (rand-nth [:circle :square :triangle]) |
| 149 | +;; :x (rand-int 500) |
| 150 | +;; :y (rand-int 250) |
| 151 | +;; :size (+ 8 (rand-int 25)) |
| 152 | +;; :color (rand-nth ["#e74c3c" "#3498db" "#2ecc71" |
| 153 | +;; "#f39c12" "#9b59b6" "#1abc9c"])))) |
| 154 | +;; ``` |
| 155 | + |
| 156 | +;; ## The Full Development Experience |
| 157 | + |
| 158 | +;; While the sidebar REPL provides convenient experimentation for simple examples, |
| 159 | +;; the real power of [Clay](https://github.com/scicloj/clay) is in working with the source code. |
| 160 | +;; Clone the [repository](https://github.com/clojurecivitas/clojurecivitas.github.io) |
| 161 | +;; and open it in your editor to get the full interactive notebook experience. |
| 162 | +;; You can modify examples, create new namespaces, and contribute ideas back to the community. |
| 163 | +;; Add your own namespace under `src/` and it is published as a new post. |
| 164 | + |
| 165 | +;; ## Sidebar REPL Usage |
| 166 | + |
| 167 | +;; ClojureCivitas authors can require this namespace and enable the REPL sidebar: |
| 168 | + |
| 169 | +;; ```clojure |
| 170 | +;; (require '[civitas.repl :as repl]) |
| 171 | +;; (repl/scittle-sidebar) |
| 172 | +;; ``` |
| 173 | + |
| 174 | +;; Set `:page-layout :full` in the `:quarto` metadata to make best use of the available page space. |
| 175 | + |
| 176 | +;;  |
| 177 | + |
| 178 | +;; ## Wrapping Up |
| 179 | + |
| 180 | +;; I hope you enjoyed exploring this sidebar REPL approach. |
| 181 | +;; The sidebar REPL complements Clay's strength of showing examples and results. |
| 182 | +;; Authors can intersperse static examples with outputs and interactive prompts for experimentation. |
| 183 | +;; The real magic happens when you clone this repository and work with the source code directly. |
| 184 | + |
| 185 | +;; **Ready to contribute?** Add your own namespace under `src/` and it becomes a new post automatically. |
| 186 | + |
| 187 | +;; **Join the conversation:** The best place to discuss this feature, share improvements, or ask questions is the [Zulip: clay-dev channel](https://clojurians.zulipchat.com/#narrow/channel/422115-clay-dev) |
| 188 | + |
| 189 | +;; Give it a try and let us know what you think! 🚀 |
| 190 | + |
| 191 | +^:kindly/hide-code |
| 192 | +(defn scittle-sidebar |
| 193 | + "A sidebar component that integrates a REPL with textarea editor and output display." |
| 194 | + [] |
| 195 | + (kind/hiccup |
| 196 | + '((require '[reagent.core :as r]) |
| 197 | + [(fn [] |
| 198 | + (let [output-ref (atom nil) |
| 199 | + editor-ref (atom nil) |
| 200 | + show-editor? (r/atom true) |
| 201 | + layout (r/atom :right) |
| 202 | + repl-output (r/atom []) |
| 203 | + scroll-to-bottom! #(when @output-ref |
| 204 | + (js/setTimeout |
| 205 | + (fn [] (set! (.-scrollTop @output-ref) (.-scrollHeight @output-ref))) |
| 206 | + 10)) |
| 207 | + eval-code! (fn [code] |
| 208 | + (let [captured-output (atom []) |
| 209 | + result (binding [*print-fn* #(swap! captured-output conj %) |
| 210 | + *print-err-fn* #(swap! captured-output conj (str "ERR: " %))] |
| 211 | + (try |
| 212 | + (pr-str (js/scittle.core.eval_string code)) |
| 213 | + (catch :default e |
| 214 | + (str "Error: " (.-message e))))) |
| 215 | + output-str (when (seq @captured-output) |
| 216 | + (clojure.string/join "" @captured-output))] |
| 217 | + (swap! repl-output into (cond-> [(str "> " code)] |
| 218 | + output-str (conj output-str) |
| 219 | + :always (conj result))) |
| 220 | + (scroll-to-bottom!)))] |
| 221 | + (.addEventListener js/document "click" |
| 222 | + (fn [e] |
| 223 | + (when @editor-ref |
| 224 | + (when-let [code-container (.. e -target (closest ".sourceCode"))] |
| 225 | + (when-let [code-element (.querySelector code-container "code")] |
| 226 | + (let [code-text (.-textContent code-element)] |
| 227 | + (set! (.-value @editor-ref) code-text) |
| 228 | + (.focus @editor-ref))))))) |
| 229 | + (fn [] |
| 230 | + (let [collapsed? (not @show-editor?) |
| 231 | + is-bottom? (= @layout :bottom) |
| 232 | + toggle! #(swap! show-editor? not) |
| 233 | + switch-layout! #(reset! layout (if (= @layout :right) :bottom :right)) |
| 234 | + collapsed-size "40px" |
| 235 | + full-size "350px"] |
| 236 | + [:<> |
| 237 | + ;; Editor container |
| 238 | + [:div#scittle-sidebar.card.shadow-sm |
| 239 | + {:style (merge |
| 240 | + {:position "fixed" |
| 241 | + :z-index "1200" |
| 242 | + :display "flex" |
| 243 | + :flex-direction "column"} |
| 244 | + (if is-bottom? |
| 245 | + {:left "0" |
| 246 | + :right "0" |
| 247 | + :bottom "0" |
| 248 | + :height (if collapsed? collapsed-size full-size)} |
| 249 | + {:top "0" |
| 250 | + :right "0" |
| 251 | + :width (if collapsed? collapsed-size full-size) |
| 252 | + :height "100vh" |
| 253 | + :padding-top "77px"}))} |
| 254 | + |
| 255 | + ;; Button bar |
| 256 | + (when (not collapsed?) |
| 257 | + [:div.d-flex.justify-content-between.align-items-center |
| 258 | + {:style {:flex "0 0 auto" |
| 259 | + :order "0"}} |
| 260 | + [:div.btn-group |
| 261 | + [:button.btn.btn-outline-primary.btn-sm |
| 262 | + {:on-click toggle!} |
| 263 | + (if is-bottom? |
| 264 | + [:i.bi.bi-chevron-bar-down] |
| 265 | + [:i.bi.bi-chevron-bar-right]) |
| 266 | + " Hide"] |
| 267 | + [:button.btn.btn-outline-secondary.btn-sm |
| 268 | + {:on-click switch-layout!} |
| 269 | + "↕ Switch Layout"]] |
| 270 | + [:small.text-muted "Ctrl+Enter to eval"]]) |
| 271 | + |
| 272 | + ;; Restore button |
| 273 | + (when collapsed? |
| 274 | + [:button.btn.btn-outline-primary.w-100.h-100 |
| 275 | + {:on-click toggle!} |
| 276 | + (if is-bottom? |
| 277 | + [:i.bi.bi-chevron-bar-up] |
| 278 | + [:i.bi.bi-chevron-bar-left])]) |
| 279 | + |
| 280 | + ;; Editor and Output container - flex direction changes based on layout |
| 281 | + (when (not collapsed?) |
| 282 | + [:div.card-body.p-0 |
| 283 | + {:style {:flex "1 1 auto" |
| 284 | + :order "1" |
| 285 | + :display "flex" |
| 286 | + :flex-direction (if is-bottom? "row" "column")}} |
| 287 | + |
| 288 | + ;; Editor |
| 289 | + [:div.position-relative |
| 290 | + {:style {:flex "1 1 0" |
| 291 | + :overflow "hidden"}} |
| 292 | + [:textarea.form-control.border-0 |
| 293 | + {:ref #(reset! editor-ref %) |
| 294 | + :on-key-down (fn [e] |
| 295 | + (when (and (.-ctrlKey e) (= (.-key e) "Enter")) |
| 296 | + (eval-code! (.. e -target -value)))) |
| 297 | + :placeholder "Type Clojure code here...\nCtrl+Enter to evaluate\nClick copy buttons in code blocks to paste here" |
| 298 | + :style {:resize "none" |
| 299 | + :width "100%" |
| 300 | + :height "100%" |
| 301 | + :box-sizing "border-box" |
| 302 | + :font-family "var(--bs-font-monospace)" |
| 303 | + :font-size "0.875rem" |
| 304 | + :border-radius "0" |
| 305 | + :overflow-y "auto"}}]] |
| 306 | + |
| 307 | + ;; REPL Output |
| 308 | + [:div.position-relative |
| 309 | + {:style {:flex "1 1 0" |
| 310 | + :overflow "hidden"}} |
| 311 | + |
| 312 | + ;; Floating label in top-right |
| 313 | + [:div.position-absolute.top-0.end-0.badge.bg-secondary.m-2 |
| 314 | + {:style {:font-size "0.65rem" |
| 315 | + :z-index "10"}} |
| 316 | + "Output"] |
| 317 | + |
| 318 | + [:div#repl-output.sourceCode.border-start |
| 319 | + {:ref #(reset! output-ref %) |
| 320 | + :class (if is-bottom? "border-start" "border-top") |
| 321 | + :style {:height "100%" |
| 322 | + :padding "0.75rem" |
| 323 | + :overflow-y "auto" |
| 324 | + :font-family "var(--bs-font-monospace)" |
| 325 | + :font-size "0.8125rem" |
| 326 | + :line-height "1.4"}} |
| 327 | + |
| 328 | + ;; Render output lines without rebuilding entire structure |
| 329 | + (for [[i output] (map-indexed vector @repl-output)] |
| 330 | + [:div.mb-1 {:key i} output])]]]) |
| 331 | + |
| 332 | + [:style |
| 333 | + (str |
| 334 | + ;; Dynamic content adjustments based on editor state |
| 335 | + (when @show-editor? |
| 336 | + (if is-bottom? |
| 337 | + (str ".content { margin-bottom: " full-size " !important; }") |
| 338 | + (str ".content { margin-right: " full-size " !important; }"))))]]]))))]))) |
| 339 | + |
| 340 | +^:kindly/hide-code |
| 341 | +(scittle-sidebar) |
0 commit comments