23 KiB
Examples
Detailed walkthroughs of the example applications included with Clojure TUI.
Running Examples
# With Babashka (recommended - fast startup)
bb counter
bb timer
bb list
bb spinner
bb views
bb http
# With full Clojure
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
- Multiple key bindings: Use
orto bind multiple keys to the same action - Conditional styling: Change colors based on state (
pos?,neg?) - Simple state: Model is just an integer
Timer
A countdown timer demonstrating async commands with after.
Features:
- Async timer 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/after 1000 :timer-tick))]))
;; Reset with 'r'
(tui/key= msg "r")
[{:seconds initial-seconds :paused false :done false}
(tui/after 1000 :timer-tick)]
;; Handle timer tick
(= msg :timer-tick)
(if (:paused model)
[model nil]
(let [new-seconds (dec (:seconds model))]
(if (pos? new-seconds)
[{:seconds new-seconds :paused false :done false}
(tui/after 1000 :timer-tick)]
[{: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/after 1000 :timer-tick)}))
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
- After command:
(tui/after 1000 :timer-tick)sends:timer-tickafter 1 second - Initial command:
:init-cmdstarts the first timer - State machine: Track
pausedanddonestates - Pause control: Only schedule next
afterwhen 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.core :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
- Cursor bounds: Use
maxandminto keep cursor in range - Set for selection: Using a set makes toggle logic simple
- Result after quit: Access final model after
runreturns - Visual indicators: Different characters for cursor (
>) and selection ([x])
Spinner
An animated loading spinner demonstrating fast timer-based animation.
Features:
- Multiple spinner styles
- Fast animation (80ms intervals)
- 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/after 80 :spinner-frame)]
;; Handle spinner frame
(= msg :spinner-frame)
(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/after interval :spinner-frame)]))
: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/after 80 :spinner-frame)}))
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
- Fast timers: Use short intervals (80-100ms) for smooth animation
- Frame-based animation: Cycle through an array of frames
- Multiple styles: Store different spinner configurations
- Stop animation: Stop scheduling timers 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.core :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
- State machine pattern: Use
:viewkey to track current screen - Separate handlers: Break update function into per-view handlers
- View dispatch: Use
casein both update and view functions - 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
- Custom async command: Return a function
(fn [] result) - State machine: Track loading/success/error states
- Error handling: Wrap async operation in try/catch
- Message types: Use maps with
:typekey 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"]))