This commit is contained in:
2026-01-21 01:16:37 -05:00
commit a990076b03
17 changed files with 2713 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.cpcache/
+152
View File
@@ -0,0 +1,152 @@
# Clojure TUI
A Clojure TUI (Terminal User Interface) framework inspired by [Bubbletea](https://github.com/charmbracelet/bubbletea), using the Elm Architecture with Hiccup for views.
## Architecture
```
┌─────────────────────────────────────┐
│ Hiccup DSL (view returns hiccup) │ ← User-facing API
├─────────────────────────────────────┤
│ Layout Engine (calculates sizes) │ ← Constraint solving
├─────────────────────────────────────┤
│ Render (hiccup → ANSI string) │ ← Colors, styles
├─────────────────────────────────────┤
│ Terminal (raw mode, input, output) │ ← Platform abstraction
└─────────────────────────────────────┘
```
## The Elm Architecture
Every app has three parts:
```clojure
;; Model - your application state
(def initial-model {:count 0})
;; Update - handle messages, return [new-model command]
(defn update-model [model msg]
(cond
(tui/key= msg "q") [model tui/quit]
(tui/key= msg :up) [(update model :count inc) nil]
:else [model nil]))
;; View - render model as hiccup
(defn view [{:keys [count]}]
[:col
[:text {:bold true} "Counter"]
[:text (str "Count: " count)]
[:text {:fg :gray} "Press up to increment, q to quit"]])
```
## Hiccup Elements
| Element | Description | Attributes |
|---------|-------------|------------|
| `:text` | Styled text | `:fg` `:bg` `:bold` `:dim` `:italic` `:underline` `:inverse` |
| `:row` | Horizontal layout | `:gap` |
| `:col` | Vertical layout | `:gap` |
| `:box` | Bordered container | `:border` `:title` `:padding` |
| `:space` | Empty space | `:width` `:height` |
### Colors
`:fg` and `:bg` accept: `:black` `:red` `:green` `:yellow` `:blue` `:magenta` `:cyan` `:white` `:gray` and bright variants.
### Borders
`:border` accepts: `:rounded` `:single` `:double` `:heavy` `:ascii`
### Padding
`:padding` accepts: `n` (all sides), `[v h]` (vertical, horizontal), or `[top right bottom left]`
## Running Examples
### With Clojure CLI
```bash
# Counter - basic Elm architecture
clojure -A:dev -M -m examples.counter
# Timer - async commands (tick)
clojure -A:dev -M -m examples.timer
# List selection - cursor navigation, multi-select
clojure -A:dev -M -m examples.list-selection
# Spinner - animated loading
clojure -A:dev -M -m examples.spinner
# Views - multi-screen state machine
clojure -A:dev -M -m examples.views
# HTTP - async HTTP requests
clojure -A:dev -M -m examples.http
```
### With Babashka (limited)
The full async runtime requires `core.async`. For Babashka, use `tui.simple`:
```clojure
(require '[tui.simple :as tui])
;; Same API, but no async commands (tick, http, etc.)
(tui/run {:init initial-model
:update update-model
:view view})
```
## Built-in Commands
| Command | Description |
|---------|-------------|
| `tui/quit` | Exit the program |
| `(tui/tick ms)` | Send `:tick` message after ms |
| `(tui/batch cmd1 cmd2)` | Run commands in parallel |
| `(tui/sequentially cmd1 cmd2)` | Run commands in sequence |
| `(fn [] msg)` | Custom async command |
## Key Matching
```clojure
(tui/key= msg "q") ;; Character
(tui/key= msg :enter) ;; Special key
(tui/key= msg :up) ;; Arrow
(tui/key= msg [:ctrl \c]) ;; Control combo
(tui/key= msg [:alt \x]) ;; Alt combo
```
## Project Structure
```
src/
tui/
core.clj # Full async runtime (core.async)
simple.clj # Simple sync runtime (Babashka-compatible)
render.clj # Hiccup → ANSI
terminal.clj # Raw mode, input/output
input.clj # Key parsing
ansi.clj # ANSI codes, colors
examples/
counter.clj
timer.clj
list_selection.clj
spinner.clj
views.clj
http.clj
```
## Differences from Bubbletea
| Bubbletea (Go) | Clojure TUI |
|----------------|-------------|
| String views | Hiccup views |
| Lipgloss styling | Inline `:fg` `:bold` attrs |
| `tea.Cmd` functions | Vector commands `[:tick 100]` |
| Imperative builder | Declarative data |
## License
MIT
+20
View File
@@ -0,0 +1,20 @@
{:paths ["src" "examples"]
:tasks
{counter {:doc "Run counter example"
:task (do (require '[examples.counter])
((resolve 'examples.counter/-main)))}
timer {:doc "Run timer example"
:task (do (require '[examples.timer])
((resolve 'examples.timer/-main)))}
list {:doc "Run list selection example"
:task (do (require '[examples.list-selection])
((resolve 'examples.list-selection/-main)))}
spinner {:doc "Run spinner example"
:task (do (require '[examples.spinner])
((resolve 'examples.spinner/-main)))}
views {:doc "Run multi-view example"
:task (do (require '[examples.views])
((resolve 'examples.views/-main)))}
http {:doc "Run HTTP example"
:task (do (require '[examples.http])
((resolve 'examples.http/-main)))}}}
+1091
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
{:paths ["src"]
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
org.clojure/core.async {:mvn/version "1.6.681"}}
:aliases
{:dev {:extra-paths ["examples"]}
:counter {:main-opts ["-m" "examples.counter"]}
:timer {:main-opts ["-m" "examples.timer"]}
:list {:main-opts ["-m" "examples.list-selection"]}
:spinner {:main-opts ["-m" "examples.spinner"]}
:http {:main-opts ["-m" "examples.http"]}
:views {:main-opts ["-m" "examples.views"]}}}
+56
View File
@@ -0,0 +1,56 @@
(ns examples.counter
"Simple counter example - demonstrates basic Elm architecture.
Mirrors bubbletea's simple example."
(:require [tui.core :as tui]))
;; === Model ===
(def initial-model
{:count 0})
;; === Update ===
(defn update-model [model msg]
(cond
;; Quit on q or ctrl+c
(or (tui/key= msg "q")
(tui/key= msg [:ctrl \c]))
[model tui/quit]
;; Increment on up/k
(or (tui/key= msg :up)
(tui/key= msg "k"))
[(update model :count inc) nil]
;; Decrement on down/j
(or (tui/key= msg :down)
(tui/key= msg "j"))
[(update model :count dec) nil]
;; Reset on r
(tui/key= msg "r")
[(assoc model :count 0) nil]
:else
[model nil]))
;; === View ===
(defn view [{:keys [count]}]
[:col {:gap 1}
[:box {:border :rounded :padding [0 1]}
[:col
[:text {:bold true} "Counter"]
[:text ""]
[:text {:fg (cond
(pos? count) :green
(neg? count) :red
:else :default)}
(str "Count: " count)]]]
[:text {:fg :gray} "j/k or up/down: change value"]
[:text {:fg :gray} "r: reset q: quit"]])
;; === Main ===
(defn -main [& _args]
(println "Starting counter...")
(let [final-model (tui/run {:init initial-model
:update update-model
:view view})]
(println "Final count:" (:count final-model))))
+111
View File
@@ -0,0 +1,111 @@
(ns examples.http
"HTTP request example - demonstrates async commands.
Mirrors bubbletea's http example."
(:require [tui.core :as tui]
[clojure.java.io :as io])
(:import [java.net URL HttpURLConnection]))
;; === HTTP Helpers ===
(defn http-get
"Simple HTTP GET request. Returns {:status code :body string} or {:error msg}"
[url-str]
(try
(let [url (URL. url-str)
conn ^HttpURLConnection (.openConnection url)]
(.setRequestMethod conn "GET")
(.setConnectTimeout conn 5000)
(.setReadTimeout conn 5000)
(let [status (.getResponseCode conn)
body (with-open [r (io/reader (.getInputStream conn))]
(slurp r))]
{:status status :body body}))
(catch Exception e
{:error (.getMessage e)})))
;; === Model ===
(def initial-model
{:state :idle ; :idle, :loading, :success, :error
:status nil
:error nil
:url "https://httpstat.us/200"})
;; === Commands ===
(defn fetch-url [url]
(fn []
(let [result (http-get url)]
(if (:error result)
[:http-error (:error result)]
[:http-success (:status result)]))))
;; === Update ===
(defn update-model [{:keys [url] :as model} msg]
(cond
;; Quit
(or (tui/key= msg "q")
(tui/key= msg [:ctrl \c]))
[model tui/quit]
;; Enter - start request
(and (= (:state model) :idle)
(tui/key= msg :enter))
[(assoc model :state :loading) (fetch-url url)]
;; r - retry/reset
(tui/key= msg "r")
[(assoc model :state :idle :status nil :error nil) nil]
;; HTTP success
(= (first msg) :http-success)
[(assoc model :state :success :status (second msg)) nil]
;; HTTP error
(= (first msg) :http-error)
[(assoc model :state :error :error (second msg)) nil]
:else
[model nil]))
;; === View ===
(defn view [{:keys [state status error url]}]
[:col {:gap 1}
[:box {:border :rounded :padding [1 2]}
[:col {:gap 1}
[:text {:bold true} "HTTP Request Demo"]
[:text ""]
[:row {:gap 1}
[:text {:fg :gray} "URL:"]
[:text {:fg :cyan} url]]
[:text ""]
(case state
:idle
[:text {:fg :gray} "Press enter to fetch..."]
:loading
[:row {:gap 1}
[:text {:fg :yellow} "⠋"]
[:text "Fetching..."]]
:success
[:row {:gap 1}
[:text {:fg :green} "✓"]
[:text (str "Status: " status)]]
:error
[:col
[:row {:gap 1}
[:text {:fg :red} "✗"]
[:text {:fg :red} "Error:"]]
[:text {:fg :red} error]])]]
[:text {:fg :gray}
(if (= state :idle)
"enter: fetch q: quit"
"r: retry q: quit")]])
;; === Main ===
(defn -main [& _args]
(println "Starting HTTP demo...")
(let [final (tui/run {:init initial-model
:update update-model
:view view})]
(when (= (:state final) :success)
(println "Request completed with status:" (:status final)))))
+92
View File
@@ -0,0 +1,92 @@
(ns examples.list-selection
"List selection example - demonstrates cursor navigation and multi-select.
Mirrors bubbletea's list examples."
(:require [tui.core :as tui]
[clojure.string :as str]))
;; === Model ===
(def initial-model
{:cursor 0
:items ["Pizza" "Sushi" "Tacos" "Burger" "Pasta" "Salad" "Soup" "Steak"]
:selected #{}
:submitted false})
;; === Update ===
(defn update-model [{:keys [cursor items] :as model} msg]
(cond
;; Quit
(or (tui/key= msg "q")
(tui/key= msg [:ctrl \c]))
[model tui/quit]
;; Move up
(or (tui/key= msg :up)
(tui/key= msg "k"))
[(update model :cursor #(max 0 (dec %))) nil]
;; Move down
(or (tui/key= msg :down)
(tui/key= msg "j"))
[(update model :cursor #(min (dec (count items)) (inc %))) nil]
;; Toggle selection
(tui/key= msg " ")
[(update model :selected
#(if (contains? % cursor)
(disj % cursor)
(conj % cursor)))
nil]
;; Submit
(tui/key= msg :enter)
[(assoc model :submitted true) tui/quit]
:else
[model nil]))
;; === View ===
(defn view [{:keys [cursor items selected submitted]}]
(if submitted
[:col
[:text {:bold true :fg :green} "You selected:"]
[:text ""]
(if (empty? selected)
[:text {:fg :gray} "(nothing selected)"]
[:col
(for [idx (sort selected)]
[:text (str " - " (nth items idx))])])]
[:col {:gap 1}
[:box {:border :rounded :padding [0 1] :title "What's for lunch?"}
[:col
(for [[idx item] (map-indexed vector items)]
(let [is-cursor (= idx cursor)
is-selected (contains? selected idx)]
[:row {:gap 1}
[:text {:fg (when is-cursor :cyan)} (if is-cursor ">" " ")]
[:text (if is-selected "[x]" "[ ]")]
[:text {:bold is-cursor
:fg (cond
is-selected :green
is-cursor :cyan
:else :default)}
item]]))]]
[:row {:gap 2}
[:text {:fg :gray} "j/k: move"]
[:text {:fg :gray} "space: select"]
[:text {:fg :gray} "enter: confirm"]
[:text {:fg :gray} "q: quit"]]
[:text {:fg :cyan}
(str (count selected) " item" (when (not= 1 (count selected)) "s") " selected")]]))
;; === Main ===
(defn -main [& _args]
(println "Starting list selection...")
(let [{:keys [items selected submitted]} (tui/run {:init initial-model
:update update-model
:view view})]
(when submitted
(println)
(println "Your order:")
(doseq [idx (sort selected)]
(println " -" (nth items idx))))))
+90
View File
@@ -0,0 +1,90 @@
(ns examples.spinner
"Spinner example - demonstrates animated loading states.
Mirrors bubbletea's spinner example."
(:require [tui.core :as tui]))
;; === Spinner Frames ===
(def spinner-styles
{:dots ["⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"]
:line ["|" "/" "-" "\\"]
:circle ["◐" "◓" "◑" "◒"]
:square ["◰" "◳" "◲" "◱"]
:triangle ["◢" "◣" "◤" "◥"]
:bounce ["⠁" "⠂" "⠄" "⠂"]
:dots2 ["⣾" "⣽" "⣻" "⢿" "⡿" "⣟" "⣯" "⣷"]
:arc ["◜" "◠" "◝" "◞" "◡" "◟"]
:moon ["🌑" "🌒" "🌓" "🌔" "🌕" "🌖" "🌗" "🌘"]})
;; === Model ===
(def initial-model
{:frame 0
:style :dots
:loading true
:message "Loading..."
:styles (keys spinner-styles)
:style-idx 0})
;; === Update ===
(defn update-model [{:keys [styles style-idx] :as model} msg]
(cond
;; Quit
(or (tui/key= msg "q")
(tui/key= msg [:ctrl \c]))
[model tui/quit]
;; Tick - advance frame
(= (first msg) :tick)
(if (:loading model)
[(update model :frame inc) (tui/tick 80)]
[model nil])
;; Space - simulate completion
(tui/key= msg " ")
[(assoc model :loading false :message "Done!") nil]
;; Tab - change spinner style
(tui/key= msg :tab)
(let [new-idx (mod (inc style-idx) (count styles))]
[(assoc model
:style-idx new-idx
:style (nth styles new-idx))
nil])
;; r - restart
(tui/key= msg "r")
[(assoc model :loading true :frame 0 :message "Loading...")
(tui/tick 80)]
:else
[model nil]))
;; === View ===
(defn spinner-view [{:keys [frame style]}]
(let [frames (get spinner-styles style)
idx (mod frame (count frames))]
(nth frames idx)))
(defn view [{:keys [loading message style] :as model}]
[:col {:gap 1}
[:box {:border :rounded :padding [1 2]}
[:col {:gap 1}
[:text {:bold true} "Spinner Demo"]
[:text ""]
[:row {:gap 1}
(if loading
[:text {:fg :cyan} (spinner-view model)]
[:text {:fg :green} "✓"])
[:text message]]
[:text ""]
[:text {:fg :gray} (str "Style: " (name style))]]]
[:col
[:text {:fg :gray} "tab: change style space: complete r: restart q: quit"]]])
;; === Main ===
(defn -main [& _args]
(println "Starting spinner...")
(tui/run {:init initial-model
:update update-model
:view view
:init-cmd (tui/tick 80)})
(println "Spinner demo finished."))
+81
View File
@@ -0,0 +1,81 @@
(ns examples.timer
"Countdown timer example - demonstrates async commands.
Mirrors bubbletea's stopwatch/timer examples."
(:require [tui.core :as tui]))
;; === Model ===
(def initial-model
{:seconds 10
:running true
:done false})
;; === Update ===
(defn update-model [model msg]
(cond
;; Quit
(or (tui/key= msg "q")
(tui/key= msg [:ctrl \c]))
[model tui/quit]
;; Tick - decrement timer
(= (first msg) :tick)
(if (:running model)
(let [new-seconds (dec (:seconds model))]
(if (<= new-seconds 0)
;; Timer done
[(assoc model :seconds 0 :done true :running false) nil]
;; Continue countdown
[(assoc model :seconds new-seconds) (tui/tick 1000)]))
[model nil])
;; Space - pause/resume
(tui/key= msg " ")
(let [new-running (not (:running model))]
[(assoc model :running new-running)
(when new-running (tui/tick 1000))])
;; r - reset
(tui/key= msg "r")
[(assoc model :seconds 10 :done false :running true)
(tui/tick 1000)]
:else
[model nil]))
;; === View ===
(defn format-time [seconds]
(let [mins (quot seconds 60)
secs (mod seconds 60)]
(format "%02d:%02d" mins secs)))
(defn view [{:keys [seconds running done]}]
[:col {:gap 1}
[:box {:border :rounded :padding [1 2]}
[:col
[:text {:bold true} "Countdown Timer"]
[:text ""]
[:text {:fg (cond
done :green
(< seconds 5) :red
:else :cyan)
:bold true}
(if done
"Time's up!"
(format-time seconds))]
[:text ""]
[:text {:fg :gray}
(cond
done "Press r to restart"
running "Running..."
:else "Paused")]]]
[:text {:fg :gray} "space: pause/resume r: reset q: quit"]])
;; === Main ===
(defn -main [& _args]
(println "Starting timer...")
(let [final-model (tui/run {:init initial-model
:update update-model
:view view
:init-cmd (tui/tick 1000)})]
(when (:done final-model)
(println "Timer completed!"))))
+122
View File
@@ -0,0 +1,122 @@
(ns examples.views
"Multiple views example - demonstrates state machine pattern.
Mirrors bubbletea's views example."
(:require [tui.core :as tui]))
;; === Model ===
(def initial-model
{:view :menu ; :menu, :detail, :confirm
:cursor 0
:items [{:name "Profile" :desc "View and edit your profile settings"}
{:name "Settings" :desc "Configure application preferences"}
{:name "Help" :desc "Get help and documentation"}
{:name "About" :desc "About this application"}]
:selected nil})
;; === Update ===
(defn update-model [{:keys [view cursor items] :as model} msg]
(case view
;; Menu view
:menu
(cond
(or (tui/key= msg "q")
(tui/key= msg [:ctrl \c]))
[model tui/quit]
(or (tui/key= msg :up)
(tui/key= msg "k"))
[(update model :cursor #(max 0 (dec %))) nil]
(or (tui/key= msg :down)
(tui/key= msg "j"))
[(update model :cursor #(min (dec (count items)) (inc %))) nil]
(tui/key= msg :enter)
[(assoc model
:view :detail
:selected (nth items cursor))
nil]
:else
[model nil])
;; Detail view
:detail
(cond
(or (tui/key= msg "q")
(tui/key= msg [:ctrl \c]))
[(assoc model :view :confirm) nil]
(or (tui/key= msg :escape)
(tui/key= msg "b"))
[(assoc model :view :menu :selected nil) nil]
:else
[model nil])
;; Confirm quit dialog
:confirm
(cond
(tui/key= msg "y")
[model tui/quit]
(or (tui/key= msg "n")
(tui/key= msg :escape))
[(assoc model :view :detail) nil]
:else
[model nil])))
;; === Views ===
(defn menu-view [{:keys [cursor items]}]
[:col {:gap 1}
[:box {:border :rounded :padding [0 1] :title "Main Menu"}
[:col
(for [[idx item] (map-indexed vector items)]
(let [is-cursor (= idx cursor)]
[:row {:gap 1}
[:text {:fg (when is-cursor :cyan)} (if is-cursor ">" " ")]
[:text {:bold is-cursor
:fg (when is-cursor :cyan)}
(:name item)]]))]]
[:text {:fg :gray} "j/k: navigate enter: select q: quit"]])
(defn detail-view [{:keys [selected]}]
[:col {:gap 1}
[:box {:border :double :padding [1 2]}
[:col {:gap 1}
[:text {:bold true :fg :cyan} (:name selected)]
[:text ""]
[:text (:desc selected)]
[:text ""]
[:text {:fg :gray :italic true}
"This is a detailed view of the selected item."]
[:text {:fg :gray :italic true}
"You could show forms, settings, or other content here."]]]
[:text {:fg :gray} "b/esc: back q: quit"]])
(defn confirm-view [_model]
[:col {:gap 1}
[:box {:border :rounded :padding [1 2]}
[:col
[:text {:bold true :fg :yellow} "Quit?"]
[:text ""]
[:text "Are you sure you want to quit?"]
[:text ""]
[:row {:gap 2}
[:text {:fg :green} "[y] Yes"]
[:text {:fg :red} "[n] No"]]]]])
(defn view [{:keys [view] :as model}]
(case view
:menu (menu-view model)
:detail (detail-view model)
:confirm (confirm-view model)))
;; === Main ===
(defn -main [& _args]
(println "Starting views demo...")
(tui/run {:init initial-model
:update update-model
:view view})
(println "Views demo finished."))
+157
View File
@@ -0,0 +1,157 @@
(ns tui.ansi
"ANSI escape codes for terminal styling and control.")
;; === Escape Sequences ===
(def esc "\u001b")
(def csi (str esc "["))
;; === Screen Control ===
(def clear-screen (str csi "2J"))
(def clear-line (str csi "2K"))
(def clear-to-end (str csi "0J"))
(def cursor-home (str csi "H"))
(def hide-cursor (str csi "?25l"))
(def show-cursor (str csi "?25h"))
;; Alternate screen buffer
(def enter-alt-screen (str csi "?1049h"))
(def exit-alt-screen (str csi "?1049l"))
;; === Cursor Movement ===
(defn cursor-to [row col]
(str csi row ";" col "H"))
(defn cursor-up [n]
(str csi n "A"))
(defn cursor-down [n]
(str csi n "B"))
(defn cursor-forward [n]
(str csi n "C"))
(defn cursor-back [n]
(str csi n "D"))
(def cursor-save (str csi "s"))
(def cursor-restore (str csi "u"))
;; === Colors ===
(def reset (str csi "0m"))
;; Foreground colors
(def fg-colors
{:black 30 :red 31 :green 32 :yellow 33
:blue 34 :magenta 35 :cyan 36 :white 37
:default 39
;; Bright variants
:bright-black 90 :bright-red 91 :bright-green 92 :bright-yellow 93
:bright-blue 94 :bright-magenta 95 :bright-cyan 96 :bright-white 97
;; Aliases
:gray 90 :grey 90})
;; Background colors
(def bg-colors
{:black 40 :red 41 :green 42 :yellow 43
:blue 44 :magenta 45 :cyan 46 :white 47
:default 49
;; Bright variants
:bright-black 100 :bright-red 101 :bright-green 102 :bright-yellow 103
:bright-blue 104 :bright-magenta 105 :bright-cyan 106 :bright-white 107})
;; Text attributes
(def attrs
{:bold 1 :dim 2 :italic 3 :underline 4
:blink 5 :inverse 7 :hidden 8 :strike 9})
(defn sgr
"Generate SGR (Select Graphic Rendition) sequence."
[& codes]
(str csi (clojure.string/join ";" codes) "m"))
(defn style
"Apply style attributes to text.
Options: :fg :bg :bold :dim :italic :underline :inverse :strike"
[text & {:keys [fg bg bold dim italic underline inverse strike]}]
(let [codes (cond-> []
fg (conj (get fg-colors fg fg))
bg (conj (get bg-colors bg bg))
bold (conj 1)
dim (conj 2)
italic (conj 3)
underline (conj 4)
inverse (conj 7)
strike (conj 9))]
(if (empty? codes)
text
(str (apply sgr codes) text reset))))
(defn fg
"Set foreground color."
[color text]
(style text :fg color))
(defn bg
"Set background color."
[color text]
(style text :bg color))
;; 256-color support
(defn fg-256 [n text]
(str csi "38;5;" n "m" text reset))
(defn bg-256 [n text]
(str csi "48;5;" n "m" text reset))
;; True color (24-bit) support
(defn fg-rgb [r g b text]
(str csi "38;2;" r ";" g ";" b "m" text reset))
(defn bg-rgb [r g b text]
(str csi "48;2;" r ";" g ";" b "m" text reset))
;; === Box Drawing Characters ===
(def box-chars
{:rounded {:tl "╭" :tr "╮" :bl "╰" :br "╯" :h "─" :v "│"}
:single {:tl "┌" :tr "┐" :bl "└" :br "┘" :h "─" :v "│"}
:double {:tl "╔" :tr "╗" :bl "╚" :br "╝" :h "═" :v "║"}
:heavy {:tl "┏" :tr "┓" :bl "┗" :br "┛" :h "━" :v "┃"}
:ascii {:tl "+" :tr "+" :bl "+" :br "+" :h "-" :v "|"}})
;; === String Utilities ===
(defn visible-length
"Get visible length of string (excluding ANSI codes)."
[s]
(count (clojure.string/replace s #"\u001b\[[0-9;]*m" "")))
(defn pad-right
"Pad string to width with spaces."
[s width]
(let [vlen (visible-length s)
padding (max 0 (- width vlen))]
(str s (apply str (repeat padding " ")))))
(defn pad-left
"Pad string to width with spaces on left."
[s width]
(let [vlen (visible-length s)
padding (max 0 (- width vlen))]
(str (apply str (repeat padding " ")) s)))
(defn pad-center
"Center string within width."
[s width]
(let [vlen (visible-length s)
total-padding (max 0 (- width vlen))
left-padding (quot total-padding 2)
right-padding (- total-padding left-padding)]
(str (apply str (repeat left-padding " "))
s
(apply str (repeat right-padding " ")))))
(defn truncate
"Truncate string to max width, adding ellipsis if needed."
[s max-width]
(if (<= (visible-length s) max-width)
s
(str (subs s 0 (max 0 (- max-width 1))) "…")))
+197
View File
@@ -0,0 +1,197 @@
(ns tui.core
"Core TUI framework - Elm architecture runtime."
(:require [tui.terminal :as term]
[tui.input :as input]
[tui.render :as render]
[tui.ansi :as ansi]
[clojure.core.async :as async :refer [go go-loop chan <! >! >!! <!! put! close! timeout alt!]]))
;; === Command Types ===
;; nil - no-op
;; [:quit] - exit program
;; [:tick ms] - send :tick message after ms
;; [:batch cmd1 cmd2 ...] - run commands in parallel
;; [:seq cmd1 cmd2 ...] - run commands sequentially
;; (fn [] msg) - arbitrary async function returning message
;; === Built-in Commands ===
(def quit [:quit])
(defn tick
"Send a :tick message after ms milliseconds."
[ms]
[:tick ms])
(defn batch
"Run multiple commands in parallel."
[& cmds]
(into [:batch] (remove nil? cmds)))
(defn sequentially
"Run multiple commands sequentially."
[& cmds]
(into [:seq] (remove nil? cmds)))
(defn send-msg
"Create a command that sends a message."
[msg]
(fn [] msg))
;; === Internal Command Execution ===
(defn- execute-cmd!
"Execute a command, putting resulting messages on the channel."
[cmd msg-chan]
(when cmd
(cond
;; Quit command
(= cmd [:quit])
(put! msg-chan [:quit])
;; Tick command
(and (vector? cmd) (= (first cmd) :tick))
(let [ms (second cmd)]
(go
(<! (timeout ms))
(>! msg-chan [:tick (System/currentTimeMillis)])))
;; Batch - run all in parallel
(and (vector? cmd) (= (first cmd) :batch))
(doseq [c (rest cmd)]
(execute-cmd! c msg-chan))
;; Sequence - run one after another
(and (vector? cmd) (= (first cmd) :seq))
(go-loop [[c & rest-cmds] (rest cmd)]
(when c
(let [result-chan (chan 1)]
(execute-cmd! c result-chan)
(when-let [msg (<! result-chan)]
(>! msg-chan msg)
(recur rest-cmds)))))
;; Function - execute and send result
(fn? cmd)
(go
(let [msg (cmd)]
(when msg
(>! msg-chan msg))))
:else
nil)))
;; === Input Loop ===
(defn- start-input-loop!
"Start goroutine that reads input and puts messages on channel."
[msg-chan running?]
(go-loop []
(when @running?
(when-let [key-msg (input/read-key)]
(>! msg-chan key-msg))
(recur))))
;; === Main Run Loop ===
(defn run
"Run a TUI application.
Options:
- :init - Initial model (required)
- :update - (fn [model msg] [new-model cmd]) (required)
- :view - (fn [model] hiccup) (required)
- :init-cmd - Initial command to run
- :fps - Target frames per second (default 60)
- :alt-screen - Use alternate screen buffer (default false)
Returns the final model."
[{:keys [init update view init-cmd fps alt-screen]
:or {fps 60 alt-screen false}}]
(let [msg-chan (chan 256)
running? (atom true)
frame-time (/ 1000 fps)]
;; Setup terminal
(term/init-input!)
(term/raw-mode!)
(when alt-screen (term/alt-screen!))
(term/clear!)
(try
;; Start input loop
(start-input-loop! msg-chan running?)
;; Execute initial command
(when init-cmd
(execute-cmd! init-cmd msg-chan))
;; Initial render
(let [initial-view (render/render (view init))]
(term/render! initial-view))
;; Main loop
(loop [model init
last-render (System/currentTimeMillis)]
(let [;; Wait for message with timeout for frame limiting
remaining (max 1 (- frame-time (- (System/currentTimeMillis) last-render)))
msg (alt!
msg-chan ([v] v)
(timeout remaining) nil)]
(if (or (nil? msg) (not @running?))
;; No message, just continue
(recur model (System/currentTimeMillis))
;; Process message
(if (= msg [:quit])
;; Quit - return final model
model
;; Update model
(let [[new-model cmd] (update model msg)
new-view (render/render (view new-model))
now (System/currentTimeMillis)]
;; Execute command
(when cmd
(execute-cmd! cmd msg-chan))
;; Render
(term/render! new-view)
(recur new-model now))))))
(finally
;; Cleanup
(reset! running? false)
(close! msg-chan)
(when alt-screen (term/exit-alt-screen!))
(term/restore!)
(term/close-input!)
(println)))))
;; === Convenience Macros ===
(defmacro defapp
"Define a TUI application.
(defapp my-app
:init {:count 0}
:update (fn [model msg] ...)
:view (fn [model] ...))"
[name & {:keys [init update view init-cmd]}]
`(def ~name
{:init ~init
:update ~update
:view ~view
:init-cmd ~init-cmd}))
;; === Key Matching Helpers ===
(defn key=
"Check if message is a specific key."
[msg key-pattern]
(input/key-match? msg key-pattern))
(defn key-str
"Get string representation of key."
[msg]
(input/key->str msg))
;; Re-export render function
(def render render/render)
+152
View File
@@ -0,0 +1,152 @@
(ns tui.input
"Parse terminal input into key messages."
(:require [tui.terminal :as term]))
;; === Key Message Structure ===
;; [:key {:type :rune :char \a}]
;; [:key {:type :special :key :up}]
;; [:key {:type :special :key :enter}]
;; [:key {:type :ctrl :char \c}]
(def ^:private ctrl-keys
{0 [:ctrl \space] ; Ctrl+Space / Ctrl+@
1 [:ctrl \a] 2 [:ctrl \b] 3 [:ctrl \c]
4 [:ctrl \d] 5 [:ctrl \e] 6 [:ctrl \f]
7 [:ctrl \g] 8 :backspace 9 :tab
10 :enter 11 [:ctrl \k] 12 [:ctrl \l]
13 :enter 14 [:ctrl \n] 15 [:ctrl \o]
16 [:ctrl \p] 17 [:ctrl \q] 18 [:ctrl \r]
19 [:ctrl \s] 20 [:ctrl \t] 21 [:ctrl \u]
22 [:ctrl \v] 23 [:ctrl \w] 24 [:ctrl \x]
25 [:ctrl \y] 26 [:ctrl \z] 27 :escape
28 [:ctrl \\] 29 [:ctrl \]] 30 [:ctrl \^]
31 [:ctrl \_] 127 :backspace})
(def ^:private csi-sequences
{"A" :up "B" :down "C" :right "D" :left
"H" :home "F" :end "Z" :shift-tab
"1~" :home "2~" :insert "3~" :delete
"4~" :end "5~" :page-up "6~" :page-down
"7~" :home "8~" :end
;; Function keys
"11~" :f1 "12~" :f2 "13~" :f3 "14~" :f4
"15~" :f5 "17~" :f6 "18~" :f7 "19~" :f8
"20~" :f9 "21~" :f10 "23~" :f11 "24~" :f12
;; xterm-style function keys
"OP" :f1 "OQ" :f2 "OR" :f3 "OS" :f4})
(defn- read-escape-sequence
"Read and parse an escape sequence."
[]
(let [c2 (term/read-char-timeout 50)]
(cond
(nil? c2)
[:key :escape]
(= c2 \[)
;; CSI sequence
(loop [buf []]
(let [c (term/read-char-timeout 50)]
(cond
(nil? c)
[:key :escape]
;; Parameters and intermediates
(or (<= 0x30 (int c) 0x3F) ; 0-9:;<=>?
(<= 0x20 (int c) 0x2F)) ; space to /
(recur (conj buf c))
;; Final byte
(<= 0x40 (int c) 0x7E)
(let [seq-str (str (apply str buf) c)]
(if-let [key (get csi-sequences seq-str)]
[:key key]
[:key :unknown seq-str]))
:else
[:key :unknown (str "[" (apply str buf) c)])))
(= c2 \O)
;; SS3 sequence (F1-F4 on some terminals)
(let [c3 (term/read-char-timeout 50)]
(if c3
(if-let [key (get csi-sequences (str "O" c3))]
[:key key]
[:key :unknown (str "O" c3)])
[:key :escape]))
:else
;; Alt+key
[:key {:alt true :char c2}])))
(defn read-key
"Read a single key event. Returns [:key ...] message."
[]
(when-let [c (term/read-char)]
(let [code (int c)]
(cond
;; Escape sequence
(= code 27)
(read-escape-sequence)
;; Control characters
(<= 0 code 31)
(let [key (get ctrl-keys code)]
(if (vector? key)
[:key {:ctrl true :char (second key)}]
[:key key]))
;; DEL (Ctrl+Backspace on some terminals)
(= code 127)
[:key :backspace]
;; Normal character
:else
[:key {:char c}]))))
(defn key-match?
"Check if a key message matches a pattern.
Patterns: :enter, :up, \"q\", [:ctrl \\c], etc."
[msg pattern]
(when (= (first msg) :key)
(let [key (second msg)]
(cond
;; Simple keyword match
(keyword? pattern)
(or (= key pattern)
(= (:key key) pattern))
;; String match (single char)
(string? pattern)
(and (map? key)
(= (:char key) (first pattern))
(not (:ctrl key))
(not (:alt key)))
;; Vector pattern [:ctrl \c]
(vector? pattern)
(let [[mod ch] pattern]
(and (map? key)
(case mod
:ctrl (and (:ctrl key) (= (:char key) ch))
:alt (and (:alt key) (= (:char key) ch))
false)))
:else false))))
(defn key->str
"Convert key message to human-readable string."
[msg]
(when (= (first msg) :key)
(let [key (second msg)]
(cond
(keyword? key)
(name key)
(map? key)
(str (when (:ctrl key) "ctrl+")
(when (:alt key) "alt+")
(:char key))
:else
(str key)))))
+185
View File
@@ -0,0 +1,185 @@
(ns tui.render
"Render hiccup to ANSI strings."
(:require [tui.ansi :as ansi]
[clojure.string :as str]))
;; === Hiccup Parsing ===
(defn- parse-element
"Parse hiccup element into [tag attrs children]."
[elem]
(cond
(string? elem) [:text {} [elem]]
(number? elem) [:text {} [(str elem)]]
(nil? elem) [:text {} [""]]
(vector? elem)
(let [[tag & rest] elem
[attrs children] (if (map? (first rest))
[(first rest) (vec (next rest))]
[{} (vec rest)])]
[tag attrs children])
:else [:text {} [(str elem)]]))
;; === Text Rendering ===
(defn- apply-style
"Apply style attributes to text."
[text {:keys [fg bg bold dim italic underline inverse strike]}]
(if (or fg bg bold dim italic underline inverse strike)
(ansi/style text
:fg fg :bg bg
:bold bold :dim dim :italic italic
:underline underline :inverse inverse :strike strike)
text))
(defn- render-text
"Render :text element."
[attrs children]
(let [content (apply str (flatten children))]
(apply-style content attrs)))
;; === Layout Primitives ===
(declare render-element)
(defn- render-children
"Render all children and return list of rendered strings."
[children ctx]
(mapv #(render-element % ctx) children))
(defn- render-row
"Render :row - horizontal layout."
[{:keys [gap justify align] :or {gap 0}} children ctx]
(let [rendered (render-children children ctx)
separator (apply str (repeat gap " "))]
(str/join separator rendered)))
(defn- render-col
"Render :col - vertical layout."
[{:keys [gap] :or {gap 0}} children ctx]
(let [rendered (render-children children ctx)
separator (str/join (repeat gap "\n"))]
(str/join (str "\n" separator) rendered)))
(defn- render-box
"Render :box - bordered container."
[{:keys [border title padding width]
:or {border :rounded padding 0}}
children ctx]
(let [chars (get ansi/box-chars border (:rounded ansi/box-chars))
content (str/join "\n" (render-children children ctx))
lines (str/split content #"\n" -1)
;; Calculate padding
[pad-top pad-right pad-bottom pad-left]
(cond
(number? padding) [padding padding padding padding]
(vector? padding)
(case (count padding)
1 (let [p (first padding)] [p p p p])
2 (let [[v h] padding] [v h v h])
4 padding
[0 0 0 0])
:else [0 0 0 0])
;; Calculate content width
max-content-width (apply max 0 (map ansi/visible-length lines))
inner-width (+ max-content-width pad-left pad-right)
box-width (or width (+ inner-width 2))
content-width (- box-width 2)
;; Pad lines
padded-lines (for [line lines]
(str (apply str (repeat pad-left " "))
(ansi/pad-right line (- content-width pad-left pad-right))
(apply str (repeat pad-right " "))))
;; Add vertical padding
empty-line (apply str (repeat content-width " "))
all-lines (concat
(repeat pad-top empty-line)
padded-lines
(repeat pad-bottom empty-line))
;; Build box
top-line (str (:tl chars)
(if title
(str " " title " "
(apply str (repeat (- content-width (count title) 3) (:h chars))))
(apply str (repeat content-width (:h chars))))
(:tr chars))
bottom-line (str (:bl chars)
(apply str (repeat content-width (:h chars)))
(:br chars))
body-lines (for [line all-lines]
(str (:v chars)
(ansi/pad-right line content-width)
(:v chars)))]
(str/join "\n" (concat [top-line] body-lines [bottom-line]))))
(defn- render-space
"Render :space - empty space."
[{:keys [width height] :or {width 1 height 1}} _ _]
(let [line (apply str (repeat width " "))]
(str/join "\n" (repeat height line))))
;; === Main Render Function ===
(defn render-element
"Render a hiccup element to ANSI string."
[elem ctx]
(cond
;; Raw string - just return it
(string? elem) elem
;; Number - convert to string
(number? elem) (str elem)
;; Nil - empty string
(nil? elem) ""
;; Vector - hiccup element
(vector? elem)
(let [[tag attrs children] (parse-element elem)]
(case tag
:text (render-text attrs children)
:row (render-row attrs children ctx)
:col (render-col attrs children ctx)
:box (render-box attrs children ctx)
:space (render-space attrs children ctx)
;; Default: just render children
(apply str (render-children children ctx))))
;; Anything else - convert to string
:else (str elem)))
(defn render
"Render hiccup to ANSI string."
([hiccup] (render hiccup {}))
([hiccup ctx]
(render-element hiccup ctx)))
;; === Convenience Components ===
(defn text
"Create a text element."
[& args]
(if (map? (first args))
(into [:text (first args)] (rest args))
(into [:text {}] args)))
(defn row
"Create a row (horizontal) layout."
[& args]
(if (map? (first args))
(into [:row (first args)] (rest args))
(into [:row {}] args)))
(defn col
"Create a col (vertical) layout."
[& args]
(if (map? (first args))
(into [:col (first args)] (rest args))
(into [:col {}] args)))
(defn box
"Create a bordered box."
[& args]
(if (map? (first args))
(into [:box (first args)] (rest args))
(into [:box {}] args)))
+68
View File
@@ -0,0 +1,68 @@
(ns tui.simple
"Simplified TUI runtime - no core.async, works with Babashka.
Synchronous event loop, no timers/async commands."
(:require [tui.terminal :as term]
[tui.input :as input]
[tui.render :as render]
[tui.ansi :as ansi]))
;; === Commands ===
(def quit [:quit])
;; === Key Matching ===
(defn key=
"Check if message is a specific key."
[msg key-pattern]
(input/key-match? msg key-pattern))
(defn key-str
"Get string representation of key."
[msg]
(input/key->str msg))
;; === Simple Run Loop ===
(defn run
"Run a TUI application (synchronous, no async commands).
Options:
- :init - Initial model (required)
- :update - (fn [model msg] [new-model cmd]) (required)
- :view - (fn [model] hiccup) (required)
- :alt-screen - Use alternate screen buffer (default false)
Returns the final model."
[{:keys [init update view alt-screen]
:or {alt-screen false}}]
;; Setup terminal
(term/init-input!)
(term/raw-mode!)
(when alt-screen (term/alt-screen!))
(term/clear!)
(try
;; Initial render
(term/render! (render/render (view init)))
;; Main loop - simple synchronous
(loop [model init]
(if-let [key-msg (input/read-key)]
(let [[new-model cmd] (update model key-msg)]
;; Render
(term/render! (render/render (view new-model)))
;; Check for quit
(if (= cmd [:quit])
new-model
(recur new-model)))
(recur model)))
(finally
;; Cleanup
(when alt-screen (term/exit-alt-screen!))
(term/restore!)
(term/close-input!)
(println))))
;; Re-export render
(def render render/render)
+127
View File
@@ -0,0 +1,127 @@
(ns tui.terminal
"Terminal management: raw mode, size, input/output."
(:require [tui.ansi :as ansi]
[clojure.java.io :as io]
[clojure.java.shell :refer [sh]])
(:import [java.io BufferedReader InputStreamReader]))
;; === Terminal State ===
(def ^:private original-stty (atom nil))
(defn- stty [& args]
(let [result (apply sh "stty" (concat args [:in (io/file "/dev/tty")]))]
(when (zero? (:exit result))
(clojure.string/trim (:out result)))))
(defn get-terminal-size
"Get terminal dimensions as [width height]."
[]
(try
(let [result (stty "size")]
(when result
(let [[rows cols] (map parse-long (clojure.string/split result #"\s+"))]
{:width cols :height rows})))
(catch Exception _
{:width 80 :height 24})))
(defn raw-mode!
"Enter raw terminal mode (no echo, no line buffering)."
[]
(reset! original-stty (stty "-g"))
(stty "raw" "-echo" "-icanon" "min" "1")
(print ansi/hide-cursor)
(flush))
(defn restore!
"Restore terminal to original state."
[]
(when @original-stty
(stty @original-stty)
(reset! original-stty nil))
(print ansi/show-cursor)
(print ansi/reset)
(flush))
(defn alt-screen!
"Enter alternate screen buffer."
[]
(print ansi/enter-alt-screen)
(flush))
(defn exit-alt-screen!
"Exit alternate screen buffer."
[]
(print ansi/exit-alt-screen)
(flush))
(defn clear!
"Clear screen and move cursor home."
[]
(print ansi/clear-screen)
(print ansi/cursor-home)
(flush))
(defn render!
"Render string to terminal."
[s]
(print ansi/cursor-home)
(print ansi/clear-to-end)
(print s)
(flush))
;; === Input Handling ===
(def ^:private tty-reader (atom nil))
(defn init-input!
"Initialize input reader from /dev/tty."
[]
(reset! tty-reader
(BufferedReader.
(InputStreamReader.
(java.io.FileInputStream. "/dev/tty")))))
(defn close-input!
"Close input reader."
[]
(when-let [r @tty-reader]
(.close r)
(reset! tty-reader nil)))
(defn read-char
"Read a single character. Blocking."
[]
(when-let [r @tty-reader]
(let [c (.read r)]
(when (>= c 0)
(char c)))))
(defn read-available
"Read all available characters without blocking."
[]
(when-let [r @tty-reader]
(loop [chars []]
(if (.ready r)
(let [c (.read r)]
(if (>= c 0)
(recur (conj chars (char c)))
chars))
chars))))
(defn read-char-timeout
"Read char with timeout in ms. Returns nil on timeout."
[timeout-ms]
(when-let [r @tty-reader]
(let [deadline (+ (System/currentTimeMillis) timeout-ms)]
(loop []
(cond
(.ready r)
(let [c (.read r)]
(when (>= c 0) (char c)))
(> (System/currentTimeMillis) deadline)
nil
:else
(do
(Thread/sleep 1)
(recur)))))))