init
This commit is contained in:
@@ -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