init
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.cpcache/
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"]}}}
|
||||
@@ -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))))
|
||||
@@ -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)))))
|
||||
@@ -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))))))
|
||||
@@ -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."))
|
||||
@@ -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!"))))
|
||||
@@ -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."))
|
||||
@@ -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))) "…")))
|
||||
@@ -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)
|
||||
@@ -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)))))
|
||||
@@ -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)))
|
||||
@@ -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)
|
||||
@@ -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)))))))
|
||||
Reference in New Issue
Block a user