Files
clojure-tui/docs/examples.md
Adam Jeniski dab0a27e4d add comprehensive documentation for external users
Includes getting started guide, hiccup views reference,
full API documentation, and annotated example walkthroughs
with ASCII output examples.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 11:37:16 -05:00

23 KiB

Examples

Detailed walkthroughs of the example applications included with Clojure TUI.

Running Examples

# With Babashka (simple sync runtime)
bb counter
bb timer
bb list
bb spinner
bb views
bb http

# With Clojure (full async support)
clojure -A:dev -M -m examples.counter
clojure -A:dev -M -m examples.timer
clojure -A:dev -M -m examples.list-selection
clojure -A:dev -M -m examples.spinner
clojure -A:dev -M -m examples.views
clojure -A:dev -M -m examples.http

Counter

A simple counter demonstrating the basic Elm architecture.

Features:

  • Basic state management
  • Key handling (multiple keys for same action)
  • Conditional styling based on value
  • Box layout

Code

(ns examples.counter
  (:require [tui.core :as tui]))

(defn update-fn [model msg]
  (cond
    ;; Quit on 'q' or Ctrl+C
    (or (tui/key= msg "q")
        (tui/key= msg [:ctrl \c]))
    [model tui/quit]

    ;; Increment on 'k' or up arrow
    (or (tui/key= msg "k")
        (tui/key= msg :up))
    [(inc model) nil]

    ;; Decrement on 'j' or down arrow
    (or (tui/key= msg "j")
        (tui/key= msg :down))
    [(dec model) nil]

    ;; Reset on 'r'
    (tui/key= msg "r")
    [0 nil]

    :else
    [model nil]))

(defn view [count]
  [:col
   [:box {:border :rounded :padding [0 2]}
    [:text {:fg (cond
                  (pos? count) :green
                  (neg? count) :red
                  :else :yellow)
            :bold true}
     (str "Counter: " count)]]
   [:space {:height 1}]
   [:text {:fg :gray} "j/k or up/down: change value"]
   [:text {:fg :gray} "r: reset  q: quit"]])

(defn -main [& args]
  (tui/run {:init 0
            :update update-fn
            :view view}))

Output

Initial state:

╭────────────────╮
│  Counter: 0    │
╰────────────────╯

j/k or up/down: change value
r: reset  q: quit

After pressing k three times:

╭────────────────╮
│  Counter: 3    │
╰────────────────╯

j/k or up/down: change value
r: reset  q: quit

After pressing j five times (negative):

╭────────────────╮
│  Counter: -2   │
╰────────────────╯

j/k or up/down: change value
r: reset  q: quit

Key Concepts

  1. Multiple key bindings: Use or to bind multiple keys to the same action
  2. Conditional styling: Change colors based on state (pos?, neg?)
  3. Simple state: Model is just an integer

Timer

A countdown timer demonstrating async commands with tick.

Features:

  • Async tick command
  • State machine (running/paused/done)
  • Conditional rendering
  • Pause/resume functionality

Code

(ns examples.timer
  (:require [tui.core :as tui]))

(def initial-seconds 10)

(defn update-fn [model msg]
  (cond
    ;; Quit
    (tui/key= msg "q")
    [model tui/quit]

    ;; Toggle pause with space
    (tui/key= msg " ")
    (if (:done model)
      [model nil]
      (let [paused (not (:paused model))]
        [(assoc model :paused paused)
         (when-not paused (tui/tick 1000))]))

    ;; Reset with 'r'
    (tui/key= msg "r")
    [{:seconds initial-seconds :paused false :done false}
     (tui/tick 1000)]

    ;; Handle tick
    (= msg :tick)
    (if (:paused model)
      [model nil]
      (let [new-seconds (dec (:seconds model))]
        (if (pos? new-seconds)
          [{:seconds new-seconds :paused false :done false}
           (tui/tick 1000)]
          [{:seconds 0 :paused false :done true} nil])))

    :else
    [model nil]))

(defn format-time [seconds]
  (let [mins (quot seconds 60)
        secs (mod seconds 60)]
    (format "%02d:%02d" mins secs)))

(defn view [{:keys [seconds paused done]}]
  [:col
   [:box {:border :rounded :padding [0 2]}
    [:col
     [:text {:bold true} "Countdown Timer"]
     [:space {:height 1}]
     [:text {:fg (cond
                   done :green
                   paused :yellow
                   :else :cyan)
             :bold true}
      (if done
        "Time's up!"
        (format-time seconds))]
     (when paused
       [:text {:fg :yellow} "(paused)"])]]
   [:space {:height 1}]
   [:text {:fg :gray} "space: pause/resume  r: reset  q: quit"]])

(defn -main [& args]
  (tui/run {:init {:seconds initial-seconds :paused false :done false}
            :update update-fn
            :view view
            :init-cmd (tui/tick 1000)}))

Output

Running:

╭──────────────────────╮
│  Countdown Timer     │
│                      │
│  00:07               │
╰──────────────────────╯

space: pause/resume  r: reset  q: quit

Paused:

╭──────────────────────╮
│  Countdown Timer     │
│                      │
│  00:05               │
│  (paused)            │
╰──────────────────────╯

space: pause/resume  r: reset  q: quit

Done:

╭──────────────────────╮
│  Countdown Timer     │
│                      │
│  Time's up!          │
╰──────────────────────╯

space: pause/resume  r: reset  q: quit

Key Concepts

  1. Tick command: (tui/tick 1000) sends :tick after 1 second
  2. Initial command: :init-cmd starts the first tick
  3. State machine: Track paused and done states
  4. Conditional ticks: Only schedule next tick when not paused

List Selection

A multi-select list demonstrating cursor navigation and selection.

Features:

  • Cursor navigation with bounds checking
  • Multi-select using a set
  • Visual selection indicators
  • Enter to confirm

Code

(ns examples.list-selection
  (:require [tui.simple :as tui]))

(def items
  ["Apple" "Banana" "Cherry" "Date" "Elderberry"
   "Fig" "Grape" "Honeydew"])

(defn update-fn [model msg]
  (cond
    ;; Quit
    (tui/key= msg "q")
    [model tui/quit]

    ;; Move up
    (or (tui/key= msg "k")
        (tui/key= msg :up))
    [(update model :cursor #(max 0 (dec %))) nil]

    ;; Move down
    (or (tui/key= msg "j")
        (tui/key= msg :down))
    [(update model :cursor #(min (dec (count items)) (inc %))) nil]

    ;; Toggle selection
    (tui/key= msg " ")
    (let [item (nth items (:cursor model))]
      [(update model :selected
               #(if (contains? % item)
                  (disj % item)
                  (conj % item)))
       nil])

    ;; Confirm and quit
    (tui/key= msg :enter)
    [(assoc model :confirmed true) tui/quit]

    :else
    [model nil]))

(defn view [{:keys [cursor selected]}]
  [:col
   [:text {:bold true} "Select items:"]
   [:space {:height 1}]
   [:col
    (for [[idx item] (map-indexed vector items)]
      (let [is-cursor (= idx cursor)
            is-selected (contains? selected item)]
        [:row {:gap 1}
         [:text (if is-cursor ">" " ")]
         [:text (if is-selected "[x]" "[ ]")]
         [:text {:fg (cond
                       is-cursor :cyan
                       is-selected :green
                       :else :white)}
          item]]))]
   [:space {:height 1}]
   [:text {:fg :gray} "j/k: move  space: toggle  enter: confirm  q: quit"]])

(defn -main [& args]
  (let [result (tui/run {:init {:cursor 0 :selected #{}}
                         :update update-fn
                         :view view})]
    (when (:confirmed result)
      (println "Selected:" (:selected result)))))

Output

Initial state (cursor on Apple):

Select items:

> [ ] Apple
  [ ] Banana
  [ ] Cherry
  [ ] Date
  [ ] Elderberry
  [ ] Fig
  [ ] Grape
  [ ] Honeydew

j/k: move  space: toggle  enter: confirm  q: quit

After selecting some items:

Select items:

  [x] Apple
> [ ] Banana
  [x] Cherry
  [ ] Date
  [ ] Elderberry
  [ ] Fig
  [x] Grape
  [ ] Honeydew

j/k: move  space: toggle  enter: confirm  q: quit

Key Concepts

  1. Cursor bounds: Use max and min to keep cursor in range
  2. Set for selection: Using a set makes toggle logic simple
  3. Result after quit: Access final model after run returns
  4. Visual indicators: Different characters for cursor (>) and selection ([x])

Spinner

An animated loading spinner demonstrating fast tick-based animation.

Features:

  • Multiple spinner styles
  • Fast animation (80ms ticks)
  • Tab to cycle styles
  • State machine (loading/done)

Code

(ns examples.spinner
  (:require [tui.core :as tui]))

(def spinners
  {:dots     {:frames ["⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"] :interval 80}
   :line     {:frames ["-" "\\" "|" "/"] :interval 80}
   :circle   {:frames ["◐" "◓" "◑" "◒"] :interval 100}
   :square   {:frames ["◰" "◳" "◲" "◱"] :interval 100}
   :triangle {:frames ["◢" "◣" "◤" "◥"] :interval 80}
   :bounce   {:frames ["⠁" "⠂" "⠄" "⠂"] :interval 120}
   :dots2    {:frames ["⣾" "⣽" "⣻" "⢿" "⡿" "⣟" "⣯" "⣷"] :interval 80}
   :arc      {:frames ["◜" "◠" "◝" "◞" "◡" "◟"] :interval 100}
   :toggle   {:frames ["⊶" "⊷"] :interval 250}
   :arrow    {:frames ["←" "↖" "↑" "↗" "→" "↘" "↓" "↙"] :interval 100}})

(def spinner-order (vec (keys spinners)))

(defn update-fn [model msg]
  (cond
    ;; Quit
    (tui/key= msg "q")
    [model tui/quit]

    ;; Cycle spinner style with Tab
    (tui/key= msg :tab)
    (let [next-idx (mod (inc (:style-idx model)) (count spinner-order))]
      [(assoc model :style-idx next-idx :frame 0) nil])

    ;; Complete with space
    (tui/key= msg " ")
    [(assoc model :done true) nil]

    ;; Reset with 'r'
    (tui/key= msg "r")
    [{:style-idx (:style-idx model) :frame 0 :done false}
     (tui/tick 80)]

    ;; Handle tick
    (= msg :tick)
    (if (:done model)
      [model nil]
      (let [style-key (nth spinner-order (:style-idx model))
            frames (get-in spinners [style-key :frames])
            interval (get-in spinners [style-key :interval])
            next-frame (mod (inc (:frame model)) (count frames))]
        [(assoc model :frame next-frame)
         (tui/tick interval)]))

    :else
    [model nil]))

(defn view [{:keys [style-idx frame done]}]
  (let [style-key (nth spinner-order style-idx)
        spinner (get spinners style-key)
        char (nth (:frames spinner) frame)]
    [:col
     [:box {:border :rounded :padding [0 2]}
      [:col
       [:text {:bold true} (str "Spinner: " (name style-key))]
       [:space {:height 1}]
       (if done
         [:row
          [:text {:fg :green :bold true} "✓"]
          [:text " Done!"]]
         [:row
          [:text {:fg :cyan :bold true} char]
          [:text " Loading..."]])]]
     [:space {:height 1}]
     [:text {:fg :gray} "tab: change style  space: complete  r: restart  q: quit"]]))

(defn -main [& args]
  (tui/run {:init {:style-idx 0 :frame 0 :done false}
            :update update-fn
            :view view
            :init-cmd (tui/tick 80)}))

Output

Loading (with dots spinner):

╭─────────────────────────╮
│  Spinner: dots          │
│                         │
│  ⠹ Loading...           │
╰─────────────────────────╯

tab: change style  space: complete  r: restart  q: quit

Loading (with arrow spinner):

╭─────────────────────────╮
│  Spinner: arrow         │
│                         │
│  → Loading...           │
╰─────────────────────────╯

tab: change style  space: complete  r: restart  q: quit

Done:

╭─────────────────────────╮
│  Spinner: dots          │
│                         │
│  ✓ Done!                │
╰─────────────────────────╯

tab: change style  space: complete  r: restart  q: quit

Key Concepts

  1. Fast ticks: Use short intervals (80-100ms) for smooth animation
  2. Frame-based animation: Cycle through an array of frames
  3. Multiple styles: Store different spinner configurations
  4. Stop animation: Stop scheduling ticks when done

Views (Multi-Screen)

A multi-view application demonstrating state machine navigation.

Features:

  • Multiple views (menu, detail, confirm dialog)
  • Navigation between views
  • Confirmation dialog for quit
  • Different box styles per view

Code

(ns examples.views
  (:require [tui.simple :as tui]))

(def menu-items
  [{:id :profile :label "View Profile" :desc "See your user information"}
   {:id :settings :label "Settings" :desc "Configure preferences"}
   {:id :help :label "Help" :desc "Get assistance"}])

(defn handle-menu [model msg]
  (cond
    (tui/key= msg "q")
    [(assoc model :view :confirm) nil]

    (or (tui/key= msg "k") (tui/key= msg :up))
    [(update model :cursor #(max 0 (dec %))) nil]

    (or (tui/key= msg "j") (tui/key= msg :down))
    [(update model :cursor #(min (dec (count menu-items)) (inc %))) nil]

    (tui/key= msg :enter)
    (let [item (nth menu-items (:cursor model))]
      [(assoc model :view :detail :selected item) nil])

    :else
    [model nil]))

(defn handle-detail [model msg]
  (cond
    (or (tui/key= msg "b")
        (tui/key= msg :escape))
    [(assoc model :view :menu) nil]

    (tui/key= msg "q")
    [(assoc model :view :confirm) nil]

    :else
    [model nil]))

(defn handle-confirm [model msg]
  (cond
    (tui/key= msg "y")
    [model tui/quit]

    (or (tui/key= msg "n")
        (tui/key= msg :escape))
    [(assoc model :view :menu) nil]

    :else
    [model nil]))

(defn update-fn [model msg]
  (case (:view model)
    :menu    (handle-menu model msg)
    :detail  (handle-detail model msg)
    :confirm (handle-confirm model msg)))

(defn menu-view [{:keys [cursor]}]
  [:col
   [:box {:border :rounded :title "Main Menu" :padding 1}
    [:col
     (for [[idx item] (map-indexed vector menu-items)]
       (let [selected (= idx cursor)]
         [:row
          [:text (if selected "> " "  ")]
          [:text {:fg (if selected :cyan :white)
                  :bold selected}
           (:label item)]]))]]
   [:space {:height 1}]
   [:text {:fg :gray} "j/k: navigate  enter: select  q: quit"]])

(defn detail-view [{:keys [selected]}]
  [:col
   [:box {:border :double :title (:label selected) :padding 1}
    [:col
     [:text {:bold true} "Description:"]
     [:text (:desc selected)]
     [:space {:height 1}]
     [:text {:fg :gray :dim true} (str "ID: " (name (:id selected)))]]]
   [:space {:height 1}]
   [:text {:fg :gray} "b/esc: back  q: quit"]])

(defn confirm-view [model]
  [:col
   [:box {:border :rounded :title "Confirm" :padding 1}
    [:col
     [:text "Are you sure you want to quit?"]
     [:space {:height 1}]
     [:row {:gap 2}
      [:text {:fg :green} "[Y]es"]
      [:text {:fg :red} "[N]o"]]]]
   [:space {:height 1}]
   [:text {:fg :gray} "y: quit  n: cancel"]])

(defn view [model]
  (case (:view model)
    :menu    (menu-view model)
    :detail  (detail-view model)
    :confirm (confirm-view model)))

(defn -main [& args]
  (tui/run {:init {:view :menu :cursor 0 :selected nil}
            :update update-fn
            :view view}))

Output

Menu view:

╭─Main Menu─────────────────────╮
│                               │
│  > View Profile               │
│    Settings                   │
│    Help                       │
│                               │
╰───────────────────────────────╯

j/k: navigate  enter: select  q: quit

Detail view (Profile selected):

╔═View Profile══════════════════╗
║                               ║
║  Description:                 ║
║  See your user information    ║
║                               ║
║  ID: profile                  ║
║                               ║
╚═══════════════════════════════╝

b/esc: back  q: quit

Confirm dialog:

╭─Confirm───────────────────────╮
│                               │
│  Are you sure you want to     │
│  quit?                        │
│                               │
│  [Y]es  [N]o                  │
│                               │
╰───────────────────────────────╯

y: quit  n: cancel

Key Concepts

  1. State machine pattern: Use :view key to track current screen
  2. Separate handlers: Break update function into per-view handlers
  3. View dispatch: Use case in both update and view functions
  4. Confirmation dialog: Intercept quit to show confirmation

HTTP (Async Requests)

An async HTTP request example demonstrating custom commands.

Features:

  • Custom async command function
  • State machine (idle/loading/success/error)
  • Error handling
  • URL input display

Code

(ns examples.http
  (:require [tui.core :as tui]
            [clj-http.client :as http]))

(def api-url "https://httpbin.org/get")

(defn fetch-data []
  (fn []
    (try
      (let [response (http/get api-url {:as :json})]
        {:type :success :data (:body response)})
      (catch Exception e
        {:type :error :message (.getMessage e)}))))

(defn update-fn [model msg]
  (cond
    ;; Quit
    (tui/key= msg "q")
    [model tui/quit]

    ;; Start fetch
    (tui/key= msg :enter)
    (if (= (:status model) :loading)
      [model nil]
      [{:status :loading} (fetch-data)])

    ;; Reset
    (tui/key= msg "r")
    [{:status :idle} nil]

    ;; Handle success
    (= (:type msg) :success)
    [{:status :success :data (:data msg)} nil]

    ;; Handle error
    (= (:type msg) :error)
    [{:status :error :error (:message msg)} nil]

    :else
    [model nil]))

(defn view [{:keys [status data error]}]
  [:col
   [:box {:border :rounded :title "HTTP Example" :padding 1}
    [:col
     [:row
      [:text {:bold true} "URL: "]
      [:text {:fg :cyan} api-url]]
     [:space {:height 1}]
     [:row
      [:text {:bold true} "Status: "]
      [:text {:fg (case status
                    :idle :gray
                    :loading :yellow
                    :success :green
                    :error :red)}
       (name status)]]
     [:space {:height 1}]
     (case status
       :idle
       [:text {:fg :gray} "Press Enter to fetch data"]

       :loading
       [:text {:fg :yellow} "Fetching..."]

       :success
       [:col
        [:text {:fg :green} "Response received!"]
        [:text {:fg :gray :dim true}
         (str "Origin: " (get data "origin"))]]

       :error
       [:col
        [:text {:fg :red} "Request failed"]
        [:text {:fg :red :dim true} error]])]]
   [:space {:height 1}]
   [:text {:fg :gray} "enter: fetch  r: reset  q: quit"]])

(defn -main [& args]
  (tui/run {:init {:status :idle}
            :update update-fn
            :view view}))

Output

Idle state:

╭─HTTP Example──────────────────────────╮
│                                       │
│  URL: https://httpbin.org/get         │
│                                       │
│  Status: idle                         │
│                                       │
│  Press Enter to fetch data            │
│                                       │
╰───────────────────────────────────────╯

enter: fetch  r: reset  q: quit

Loading state:

╭─HTTP Example──────────────────────────╮
│                                       │
│  URL: https://httpbin.org/get         │
│                                       │
│  Status: loading                      │
│                                       │
│  Fetching...                          │
│                                       │
╰───────────────────────────────────────╯

enter: fetch  r: reset  q: quit

Success state:

╭─HTTP Example──────────────────────────╮
│                                       │
│  URL: https://httpbin.org/get         │
│                                       │
│  Status: success                      │
│                                       │
│  Response received!                   │
│  Origin: 203.0.113.42                 │
│                                       │
╰───────────────────────────────────────╯

enter: fetch  r: reset  q: quit

Error state:

╭─HTTP Example──────────────────────────╮
│                                       │
│  URL: https://httpbin.org/get         │
│                                       │
│  Status: error                        │
│                                       │
│  Request failed                       │
│  Connection refused                   │
│                                       │
╰───────────────────────────────────────╯

enter: fetch  r: reset  q: quit

Key Concepts

  1. Custom async command: Return a function (fn [] result)
  2. State machine: Track loading/success/error states
  3. Error handling: Wrap async operation in try/catch
  4. Message types: Use maps with :type key for complex messages

Common Patterns

Pattern: State Machine

(defn update-fn [model msg]
  (case (:state model)
    :menu    (handle-menu model msg)
    :game    (handle-game model msg)
    :paused  (handle-paused model msg)
    :gameover (handle-gameover model msg)))

Pattern: Cursor Navigation

(defn move-up [model]
  (update model :cursor #(max 0 (dec %))))

(defn move-down [model items]
  (update model :cursor #(min (dec (count items)) (inc %))))

Pattern: Toggle Selection

(defn toggle [model item]
  (update model :selected
          #(if (contains? % item)
             (disj % item)
             (conj % item))))

Pattern: Confirmation Dialog

(defn update-fn [model msg]
  (if (:confirming model)
    (cond
      (tui/key= msg "y") [model tui/quit]
      (tui/key= msg "n") [(assoc model :confirming false) nil]
      :else [model nil])
    (if (tui/key= msg "q")
      [(assoc model :confirming true) nil]
      ;; ... normal handling
      )))

Pattern: Loading States

(defn view [{:keys [loading error data]}]
  (cond
    loading [:text "Loading..."]
    error   [:text {:fg :red} error]
    data    [:text (str "Data: " data)]
    :else   [:text "Press Enter to load"]))