update examples. fix bugs

This commit is contained in:
2026-02-03 12:53:52 -05:00
parent 9150c90ad1
commit 426a0c4715
15 changed files with 867 additions and 1148 deletions
+16 -15
View File
@@ -1,36 +1,37 @@
(ns examples.counter (ns examples.counter
"Simple counter example - demonstrates basic Elm architecture. "Simple counter example - demonstrates basic Elm architecture.
Mirrors bubbletea's simple example." Mirrors bubbletea's simple example."
(:require [tui.core :as tui])) (:require [tui.core :as tui]
[tui.events :as ev]))
;; === Model === ;; === Model ===
(def initial-model (def initial-model
{:count 0}) {:count 0})
;; === Update === ;; === Update ===
(defn update-model [model msg] (defn update-fn [{:keys [model event]}]
(cond (cond
;; Quit on q or ctrl+c ;; Quit on q or ctrl+c
(or (tui/key= msg "q") (or (ev/key= event \q)
(tui/key= msg [:ctrl \c])) (ev/key= event \c #{:ctrl}))
[model tui/quit] {:model model :events [(ev/quit)]}
;; Increment on up/k ;; Increment on up/k
(or (tui/key= msg :up) (or (ev/key= event :up)
(tui/key= msg "k")) (ev/key= event \k))
[(update model :count inc) nil] {:model (update model :count inc)}
;; Decrement on down/j ;; Decrement on down/j
(or (tui/key= msg :down) (or (ev/key= event :down)
(tui/key= msg "j")) (ev/key= event \j))
[(update model :count dec) nil] {:model (update model :count dec)}
;; Reset on r ;; Reset on r
(tui/key= msg "r") (ev/key= event \r)
[(assoc model :count 0) nil] {:model (assoc model :count 0)}
:else :else
[model nil])) {:model model}))
;; === View === ;; === View ===
(defn view [{:keys [count]} _size] (defn view [{:keys [count]} _size]
@@ -51,6 +52,6 @@
(defn -main [& _args] (defn -main [& _args]
(println "Starting counter...") (println "Starting counter...")
(let [final-model (tui/run {:init initial-model (let [final-model (tui/run {:init initial-model
:update update-model :update update-fn
:view view})] :view view})]
(println "Final count:" (:count final-model)))) (println "Final count:" (:count final-model))))
+20 -25
View File
@@ -2,6 +2,7 @@
"HTTP request example - demonstrates async commands. "HTTP request example - demonstrates async commands.
Mirrors bubbletea's http example." Mirrors bubbletea's http example."
(:require [tui.core :as tui] (:require [tui.core :as tui]
[tui.events :as ev]
[clojure.java.io :as io]) [clojure.java.io :as io])
(:import [java.net URL HttpURLConnection])) (:import [java.net URL HttpURLConnection]))
@@ -29,41 +30,35 @@
:error nil :error nil
:url "https://httpstat.us/200"}) :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 === ;; === Update ===
(defn update-model [{:keys [url] :as model} msg] (defn update-fn [{:keys [model event]}]
(let [{:keys [url]} model]
(cond (cond
;; Quit ;; Quit
(or (tui/key= msg "q") (or (ev/key= event \q)
(tui/key= msg [:ctrl \c])) (ev/key= event \c #{:ctrl}))
[model tui/quit] {:model model :events [(ev/quit)]}
;; Enter - start request ;; Enter - start request
(and (= (:state model) :idle) (and (= (:state model) :idle)
(tui/key= msg :enter)) (ev/key= event :enter))
[(assoc model :state :loading) (fetch-url url)] {:model (assoc model :state :loading)
:events [(ev/shell ["curl" "-s" "-o" "/dev/null" "-w" "%{http_code}" url]
{:type :http-result})]}
;; r - retry/reset ;; r - retry/reset
(tui/key= msg "r") (ev/key= event \r)
[(assoc model :state :idle :status nil :error nil) nil] {:model (assoc model :state :idle :status nil :error nil)}
;; HTTP success ;; HTTP result
(= (first msg) :http-success) (= (:type event) :http-result)
[(assoc model :state :success :status (second msg)) nil] (let [{:keys [success out err]} (:result event)]
(if success
;; HTTP error {:model (assoc model :state :success :status (parse-long out))}
(= (first msg) :http-error) {:model (assoc model :state :error :error err)}))
[(assoc model :state :error :error (second msg)) nil]
:else :else
[model nil])) {:model model})))
;; === View === ;; === View ===
(defn view [{:keys [state status error url]} _size] (defn view [{:keys [state status error url]} _size]
@@ -105,7 +100,7 @@
(defn -main [& _args] (defn -main [& _args]
(println "Starting HTTP demo...") (println "Starting HTTP demo...")
(let [final (tui/run {:init initial-model (let [final (tui/run {:init initial-model
:update update-model :update update-fn
:view view})] :view view})]
(when (= (:state final) :success) (when (= (:state final) :success)
(println "Request completed with status:" (:status final))))) (println "Request completed with status:" (:status final)))))
+20 -18
View File
@@ -2,6 +2,7 @@
"List selection example - demonstrates cursor navigation and multi-select. "List selection example - demonstrates cursor navigation and multi-select.
Mirrors bubbletea's list examples." Mirrors bubbletea's list examples."
(:require [tui.core :as tui] (:require [tui.core :as tui]
[tui.events :as ev]
[clojure.string :as str])) [clojure.string :as str]))
;; === Model === ;; === Model ===
@@ -12,37 +13,38 @@
:submitted false}) :submitted false})
;; === Update === ;; === Update ===
(defn update-model [{:keys [cursor items] :as model} msg] (defn update-fn [{:keys [model event]}]
(let [{:keys [cursor items]} model]
(cond (cond
;; Quit ;; Quit
(or (tui/key= msg "q") (or (ev/key= event \q)
(tui/key= msg [:ctrl \c])) (ev/key= event \c #{:ctrl}))
[model tui/quit] {:model model :events [(ev/quit)]}
;; Move up ;; Move up
(or (tui/key= msg :up) (or (ev/key= event :up)
(tui/key= msg "k")) (ev/key= event \k))
[(update model :cursor #(max 0 (dec %))) nil] {:model (update model :cursor #(max 0 (dec %)))}
;; Move down ;; Move down
(or (tui/key= msg :down) (or (ev/key= event :down)
(tui/key= msg "j")) (ev/key= event \j))
[(update model :cursor #(min (dec (count items)) (inc %))) nil] {:model (update model :cursor #(min (dec (count items)) (inc %)))}
;; Toggle selection ;; Toggle selection
(tui/key= msg " ") (ev/key= event \space)
[(update model :selected {:model (update model :selected
#(if (contains? % cursor) #(if (contains? % cursor)
(disj % cursor) (disj % cursor)
(conj % cursor))) (conj % cursor)))}
nil]
;; Submit ;; Submit
(tui/key= msg :enter) (ev/key= event :enter)
[(assoc model :submitted true) tui/quit] {:model (assoc model :submitted true)
:events [(ev/quit)]}
:else :else
[model nil])) {:model model})))
;; === View === ;; === View ===
(defn view [{:keys [cursor items selected submitted]} _size] (defn view [{:keys [cursor items selected submitted]} _size]
@@ -83,7 +85,7 @@
(defn -main [& _args] (defn -main [& _args]
(println "Starting list selection...") (println "Starting list selection...")
(let [{:keys [items selected submitted]} (tui/run {:init initial-model (let [{:keys [items selected submitted]} (tui/run {:init initial-model
:update update-model :update update-fn
:view view})] :view view})]
(when submitted (when submitted
(println) (println)
+23 -21
View File
@@ -1,7 +1,8 @@
(ns examples.spinner (ns examples.spinner
"Spinner example - demonstrates animated loading states. "Spinner example - demonstrates animated loading states.
Mirrors bubbletea's spinner example." Mirrors bubbletea's spinner example."
(:require [tui.core :as tui])) (:require [tui.core :as tui]
[tui.events :as ev]))
;; === Spinner Frames === ;; === Spinner Frames ===
(def spinner-styles (def spinner-styles
@@ -21,42 +22,43 @@
:style :dots :style :dots
:loading true :loading true
:message "Loading..." :message "Loading..."
:styles (keys spinner-styles) :styles (vec (keys spinner-styles))
:style-idx 0}) :style-idx 0})
;; === Update === ;; === Update ===
(defn update-model [{:keys [styles style-idx] :as model} msg] (defn update-fn [{:keys [model event]}]
(let [{:keys [styles style-idx]} model]
(cond (cond
;; Quit ;; Quit
(or (tui/key= msg "q") (or (ev/key= event \q)
(tui/key= msg [:ctrl \c])) (ev/key= event \c #{:ctrl}))
[model tui/quit] {:model model :events [(ev/quit)]}
;; Spinner frame - advance animation ;; Spinner frame - advance animation
(= msg :spinner-frame) (= (:type event) :spinner-frame)
(if (:loading model) (if (:loading model)
[(update model :frame inc) (tui/after 80 :spinner-frame)] {:model (update model :frame inc)
[model nil]) :events [(ev/delayed-event 80 {:type :spinner-frame})]}
{:model model})
;; Space - simulate completion ;; Space - simulate completion
(tui/key= msg " ") (ev/key= event \space)
[(assoc model :loading false :message "Done!") nil] {:model (assoc model :loading false :message "Done!")}
;; Tab - change spinner style ;; Tab - change spinner style
(tui/key= msg :tab) (ev/key= event :tab)
(let [new-idx (mod (inc style-idx) (count styles))] (let [new-idx (mod (inc style-idx) (count styles))]
[(assoc model {:model (assoc model
:style-idx new-idx :style-idx new-idx
:style (nth styles new-idx)) :style (nth styles new-idx))})
nil])
;; r - restart ;; r - restart
(tui/key= msg "r") (ev/key= event \r)
[(assoc model :loading true :frame 0 :message "Loading...") {:model (assoc model :loading true :frame 0 :message "Loading...")
(tui/after 80 :spinner-frame)] :events [(ev/delayed-event 80 {:type :spinner-frame})]}
:else :else
[model nil])) {:model model})))
;; === View === ;; === View ===
(defn spinner-view [{:keys [frame style]}] (defn spinner-view [{:keys [frame style]}]
@@ -84,7 +86,7 @@
(defn -main [& _args] (defn -main [& _args]
(println "Starting spinner...") (println "Starting spinner...")
(tui/run {:init initial-model (tui/run {:init initial-model
:update update-model :update update-fn
:view view :view view
:init-cmd (tui/after 80 :spinner-frame)}) :init-events [(ev/delayed-event 80 {:type :spinner-frame})]})
(println "Spinner demo finished.")) (println "Spinner demo finished."))
+20 -18
View File
@@ -1,7 +1,8 @@
(ns examples.timer (ns examples.timer
"Countdown timer example - demonstrates async commands. "Countdown timer example - demonstrates async commands.
Mirrors bubbletea's stopwatch/timer examples." Mirrors bubbletea's stopwatch/timer examples."
(:require [tui.core :as tui])) (:require [tui.core :as tui]
[tui.events :as ev]))
;; === Model === ;; === Model ===
(def initial-model (def initial-model
@@ -10,37 +11,38 @@
:done false}) :done false})
;; === Update === ;; === Update ===
(defn update-model [model msg] (defn update-fn [{:keys [model event]}]
(cond (cond
;; Quit ;; Quit
(or (tui/key= msg "q") (or (ev/key= event \q)
(tui/key= msg [:ctrl \c])) (ev/key= event \c #{:ctrl}))
[model tui/quit] {:model model :events [(ev/quit)]}
;; Timer tick - decrement timer ;; Timer tick - decrement timer
(= msg :timer-tick) (= (:type event) :timer-tick)
(if (:running model) (if (:running model)
(let [new-seconds (dec (:seconds model))] (let [new-seconds (dec (:seconds model))]
(if (<= new-seconds 0) (if (<= new-seconds 0)
;; Timer done ;; Timer done
[(assoc model :seconds 0 :done true :running false) nil] {:model (assoc model :seconds 0 :done true :running false)}
;; Continue countdown ;; Continue countdown
[(assoc model :seconds new-seconds) (tui/after 1000 :timer-tick)])) {:model (assoc model :seconds new-seconds)
[model nil]) :events [(ev/delayed-event 1000 {:type :timer-tick})]}))
{:model model})
;; Space - pause/resume ;; Space - pause/resume
(tui/key= msg " ") (ev/key= event \space)
(let [new-running (not (:running model))] (let [new-running (not (:running model))]
[(assoc model :running new-running) {:model (assoc model :running new-running)
(when new-running (tui/after 1000 :timer-tick))]) :events (when new-running [(ev/delayed-event 1000 {:type :timer-tick})])})
;; r - reset ;; r - reset
(tui/key= msg "r") (ev/key= event \r)
[(assoc model :seconds 10 :done false :running true) {:model (assoc model :seconds 10 :done false :running true)
(tui/after 1000 :timer-tick)] :events [(ev/delayed-event 1000 {:type :timer-tick})]}
:else :else
[model nil])) {:model model}))
;; === View === ;; === View ===
(defn format-time [seconds] (defn format-time [seconds]
@@ -74,8 +76,8 @@
(defn -main [& _args] (defn -main [& _args]
(println "Starting timer...") (println "Starting timer...")
(let [final-model (tui/run {:init initial-model (let [final-model (tui/run {:init initial-model
:update update-model :update update-fn
:view view :view view
:init-cmd (tui/after 1000 :timer-tick)})] :init-events [(ev/delayed-event 1000 {:type :timer-tick})]})]
(when (:done final-model) (when (:done final-model)
(println "Timer completed!")))) (println "Timer completed!"))))
+31 -30
View File
@@ -1,7 +1,8 @@
(ns examples.views (ns examples.views
"Multiple views example - demonstrates state machine pattern. "Multiple views example - demonstrates state machine pattern.
Mirrors bubbletea's views example." Mirrors bubbletea's views example."
(:require [tui.core :as tui])) (:require [tui.core :as tui]
[tui.events :as ev]))
;; === Model === ;; === Model ===
(def initial-model (def initial-model
@@ -14,58 +15,58 @@
:selected nil}) :selected nil})
;; === Update === ;; === Update ===
(defn update-model [{:keys [view cursor items] :as model} msg] (defn update-fn [{:keys [model event]}]
(let [{:keys [view cursor items]} model]
(case view (case view
;; Menu view ;; Menu view
:menu :menu
(cond (cond
(or (tui/key= msg "q") (or (ev/key= event \q)
(tui/key= msg [:ctrl \c])) (ev/key= event \c #{:ctrl}))
[model tui/quit] {:model model :events [(ev/quit)]}
(or (tui/key= msg :up) (or (ev/key= event :up)
(tui/key= msg "k")) (ev/key= event \k))
[(update model :cursor #(max 0 (dec %))) nil] {:model (update model :cursor #(max 0 (dec %)))}
(or (tui/key= msg :down) (or (ev/key= event :down)
(tui/key= msg "j")) (ev/key= event \j))
[(update model :cursor #(min (dec (count items)) (inc %))) nil] {:model (update model :cursor #(min (dec (count items)) (inc %)))}
(tui/key= msg :enter) (ev/key= event :enter)
[(assoc model {:model (assoc model
:view :detail :view :detail
:selected (nth items cursor)) :selected (nth items cursor))}
nil]
:else :else
[model nil]) {:model model})
;; Detail view ;; Detail view
:detail :detail
(cond (cond
(or (tui/key= msg "q") (or (ev/key= event \q)
(tui/key= msg [:ctrl \c])) (ev/key= event \c #{:ctrl}))
[(assoc model :view :confirm) nil] {:model (assoc model :view :confirm)}
(or (tui/key= msg :escape) (or (ev/key= event :escape)
(tui/key= msg "b")) (ev/key= event \b))
[(assoc model :view :menu :selected nil) nil] {:model (assoc model :view :menu :selected nil)}
:else :else
[model nil]) {:model model})
;; Confirm quit dialog ;; Confirm quit dialog
:confirm :confirm
(cond (cond
(tui/key= msg "y") (ev/key= event \y)
[model tui/quit] {:model model :events [(ev/quit)]}
(or (tui/key= msg "n") (or (ev/key= event \n)
(tui/key= msg :escape)) (ev/key= event :escape))
[(assoc model :view :detail) nil] {:model (assoc model :view :detail)}
:else :else
[model nil]))) {:model model}))))
;; === Views === ;; === Views ===
(defn menu-view [{:keys [cursor items]}] (defn menu-view [{:keys [cursor items]}]
@@ -112,6 +113,6 @@
(defn -main [& _args] (defn -main [& _args]
(println "Starting views demo...") (println "Starting views demo...")
(tui/run {:init initial-model (tui/run {:init initial-model
:update update-model :update update-fn
:view view}) :view view})
(println "Views demo finished.")) (println "Views demo finished."))
+10
View File
@@ -236,3 +236,13 @@
true true
active-styles))))))))] active-styles))))))))]
(apply str result))))) (apply str result)))))
(defn fit-width
"Fit string to exactly the given width - truncate if too long, pad if too short.
Unlike truncate, this does not add ellipsis - it hard clips the content."
[s width]
(let [vlen (visible-length s)]
(cond
(= vlen width) s
(> vlen width) (visible-subs s 0 width)
:else (str s (apply str (repeat (- width vlen) " "))))))
+26 -163
View File
@@ -1,68 +1,29 @@
(ns tui.core (ns tui.core
"Core TUI framework - Elm architecture runtime. "Core TUI framework - Elm architecture runtime.
## New API (Recommended)
Update function signature: Update function signature:
(fn [{:keys [model event]}] (fn [{:keys [model event]}]
{:model new-model {:model new-model
:events [...]}) ; :events is optional :events [...]}) ; :events is optional
Events are maps with :type discriminator. See tui.events namespace. Events are maps with :type discriminator. See tui.events namespace."
## Legacy API (Deprecated)
For backward compatibility, the old signature is still supported:
(fn [model msg] [new-model cmd])
The runtime auto-detects which format is being used."
(:require [tui.terminal :as term] (:require [tui.terminal :as term]
[tui.input :as input] [tui.input :as input]
[tui.render :as render] [tui.render :as render]
[tui.ansi :as ansi] [tui.events :as ev]
[clojure.core.async :as async :refer [go go-loop chan <! >! >!! <!! put! close! timeout alt! alt!!]])) [clojure.core.async :as async :refer [go go-loop chan <! >! >!! <!! put! close! timeout alt! alt!!]]))
;; === Legacy Command Types (Deprecated) === ;; Re-export commonly used functions from tui.events for convenience
;; nil - no-op (def quit ev/quit)
;; [:quit] - exit program (def delayed-event ev/delayed-event)
;; [:batch cmd1 cmd2 ...] - run commands in parallel (def batch ev/batch)
;; [:seq cmd1 cmd2 ...] - run commands sequentially (def sequential ev/sequential)
;; (fn [] msg) - arbitrary async function returning message (def shell ev/shell)
(def debounce ev/debounce)
(def key= ev/key=)
;; === Legacy Built-in Commands (Deprecated) === ;; Re-export render function
(def quit (def render render/render)
"DEPRECATED: Use (tui.events/quit) instead."
[:quit])
(defn after
"DEPRECATED: Use (tui.events/delay ms event) instead.
Returns a command that sends msg after ms milliseconds."
[ms msg]
(fn []
(Thread/sleep ms)
msg))
(defn batch
"DEPRECATED: Use (tui.events/batch ...) instead.
Run multiple commands in parallel."
[& cmds]
(into [:batch] (remove nil? cmds)))
(defn sequentially
"DEPRECATED: Use (tui.events/sequential ...) instead.
Run multiple commands sequentially."
[& cmds]
(into [:seq] (remove nil? cmds)))
(defn send-msg
"DEPRECATED: Put event directly in :events vector instead.
Create a command that sends a message."
[msg]
(fn [] msg))
;; === Debounce State === ;; === Debounce State ===
(def ^:private debounce-timers (atom {})) (def ^:private debounce-timers (atom {}))
@@ -70,7 +31,7 @@
;; === Event Execution === ;; === Event Execution ===
(defn- execute-event! (defn- execute-event!
"Execute an event, putting resulting events on the channel. "Execute an event, putting resulting events on the channel.
Handles runtime events (:quit, :delay, :shell, :batch, :sequential, :debounce). Handles runtime events (:quit, :delayed-event, :shell, :batch, :sequential, :debounce).
Unknown event types are dispatched back to the update function." Unknown event types are dispatched back to the update function."
[event msg-chan] [event msg-chan]
(when event (when event
@@ -80,8 +41,8 @@
:quit :quit
(put! msg-chan {:type :quit}) (put! msg-chan {:type :quit})
;; Delay - wait then dispatch event ;; Delayed event - wait then dispatch event
:delay :delayed-event
(let [{:keys [ms event]} event] (let [{:keys [ms event]} event]
(go (go
(<! (timeout ms)) (<! (timeout ms))
@@ -148,41 +109,6 @@
;; Unknown type - dispatch to update function ;; Unknown type - dispatch to update function
(put! msg-chan event))))) (put! msg-chan event)))))
;; === Legacy Command Execution ===
(defn- execute-cmd!
"Execute a legacy command, putting resulting messages on the channel."
[cmd msg-chan]
(when cmd
(cond
;; Quit command
(= cmd [:quit])
(put! msg-chan {:type :quit})
;; 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 === ;; === Input Loop ===
(defn- start-input-loop! (defn- start-input-loop!
"Start thread that reads input and puts events on channel. "Start thread that reads input and puts events on channel.
@@ -198,61 +124,24 @@
(Thread/sleep 10)) (Thread/sleep 10))
(recur))))) (recur)))))
;; === Update Function Detection ===
(defn- detect-update-format
"Detect if update function uses new or legacy format by examining its signature.
Returns :new or :legacy."
[update-fn]
;; We can't easily detect at compile time, so we'll detect at runtime
;; by checking the result format
:unknown)
(defn- call-update
"Call update function, handling both new and legacy formats.
Returns {:model m :events [...]} in new format."
[update-fn model event legacy-mode?]
(if legacy-mode?
;; Legacy: (fn [model msg] [new-model cmd])
(let [[new-model cmd] (update-fn model event)]
{:model new-model
:legacy-cmd cmd})
;; New: (fn [{:keys [model event]}] {:model m :events [...]})
(let [result (update-fn {:model model :event event})]
(if (vector? result)
;; Got legacy format back, switch to legacy mode
{:model (first result)
:legacy-cmd (second result)
:switch-to-legacy true}
;; New format
result))))
;; === Main Run Loop === ;; === Main Run Loop ===
(defn run (defn run
"Run a TUI application. "Run a TUI application.
## New API (Recommended)
Options: Options:
- :init - Initial model (required) - :init - Initial model (required)
- :update - (fn [{:keys [model event]}] {:model m :events [...]}) (required) - :update - (fn [{:keys [model event]}] {:model m :events [...]}) (required)
- :view - (fn [model size] hiccup) where size is {:width w :height h} (required) - :view - (fn [model size] hiccup) where size is {:width w :height h} (required)
- :init-events - Vector of events to dispatch at startup
- :fps - Target frames per second (default 60) - :fps - Target frames per second (default 60)
- :alt-screen - Use alternate screen buffer (default true) - :alt-screen - Use alternate screen buffer (default true)
## Legacy API (Deprecated)
Also accepts:
- :update - (fn [model msg] [new-model cmd])
- :init-cmd - Initial command to run
Returns the final model." Returns the final model."
[{:keys [init update view init-cmd init-events fps alt-screen] [{:keys [init update view init-events fps alt-screen]
:or {fps 60 alt-screen true}}] :or {fps 60 alt-screen true}}]
(let [msg-chan (chan 256) (let [msg-chan (chan 256)
running? (atom true) running? (atom true)
frame-time (/ 1000 fps) frame-time (/ 1000 fps)]
;; Start in auto-detect mode, will switch to legacy if needed
legacy-mode? (atom false)]
;; Setup terminal ;; Setup terminal
(term/raw-mode!) (term/raw-mode!)
@@ -264,12 +153,10 @@
;; Start input loop ;; Start input loop
(start-input-loop! msg-chan running?) (start-input-loop! msg-chan running?)
;; Execute initial events/command ;; Execute initial events
(when init-events (when init-events
(doseq [event init-events] (doseq [event init-events]
(execute-event! event msg-chan))) (execute-event! event msg-chan)))
(when init-cmd
(execute-cmd! init-cmd msg-chan))
;; Initial render ;; Initial render
(let [size (term/get-terminal-size) (let [size (term/get-terminal-size)
@@ -291,27 +178,22 @@
(recur model (System/currentTimeMillis)) (recur model (System/currentTimeMillis))
;; Check for quit ;; Check for quit
(if (or (= event {:type :quit}) (if (= (:type event) :quit)
(= event [:quit])) ; legacy
;; Quit - return final model ;; Quit - return final model
model model
;; Update model ;; Update model
(let [result (call-update update model event @legacy-mode?) (let [result (update {:model model :event event})
_ (when (:switch-to-legacy result)
(reset! legacy-mode? true))
new-model (:model result) new-model (:model result)
size (term/get-terminal-size) size (term/get-terminal-size)
ctx {:available-height (:height size) ctx {:available-height (:height size)
:available-width (:width size)} :available-width (:width size)}
now (System/currentTimeMillis)] now (System/currentTimeMillis)]
;; Execute events (new API) or command (legacy) ;; Execute events
(if-let [events (:events result)] (when-let [events (:events result)]
(doseq [e events] (doseq [e events]
(execute-event! e msg-chan)) (execute-event! e msg-chan)))
(when-let [cmd (:legacy-cmd result)]
(execute-cmd! cmd msg-chan)))
;; Render with context for flex layouts ;; Render with context for flex layouts
(term/render! (render/render (view new-model size) ctx)) (term/render! (render/render (view new-model size) ctx))
@@ -335,30 +217,11 @@
(defapp my-app (defapp my-app
:init {:count 0} :init {:count 0}
:update (fn [ctx] ...) :update (fn [{:keys [model event]}] ...)
:view (fn [model size] ...))" :view (fn [model size] ...))"
[name & {:keys [init update view init-cmd init-events]}] [name & {:keys [init update view init-events]}]
`(def ~name `(def ~name
{:init ~init {:init ~init
:update ~update :update ~update
:view ~view :view ~view
:init-cmd ~init-cmd
:init-events ~init-events})) :init-events ~init-events}))
;; === Legacy Key Matching Helpers (Deprecated) ===
(defn key=
"DEPRECATED: Use tui.events/key= instead.
Check if message is a specific key."
[msg key-pattern]
(input/key-match? msg key-pattern))
(defn key-str
"DEPRECATED: Use tui.input/key->str instead.
Get string representation of key."
[msg]
(input/key->str msg))
;; Re-export render function
(def render render/render)
+13 -10
View File
@@ -15,7 +15,7 @@
{:type :key, :key :enter} ; special key {:type :key, :key :enter} ; special key
{:type :key, :key \\c, :modifiers #{:ctrl}} ; with modifiers {:type :key, :key \\c, :modifiers #{:ctrl}} ; with modifiers
{:type :quit} ; quit app {:type :quit} ; quit app
{:type :delay, :ms 2000, :event {...}} ; delayed event {:type :delayed-event, :ms 2000, :event {...}} ; delayed event
## Update Function Contract ## Update Function Contract
@@ -126,18 +126,21 @@
[] []
{:type :quit}) {:type :quit})
(defn delay (defn delayed-event
"Create an event that dispatches another event after a delay. "Create an event that dispatches another event after a delay.
The nested event is dispatched after the specified milliseconds elapse. Like JavaScript's setTimeout - schedules an event to be dispatched
Useful for transient messages, animations, debouncing, or timeouts. after the specified milliseconds elapse. Useful for transient messages,
animations, debouncing, or timeouts.
Note: Named delayed-event to avoid conflict with clojure.core/delay.
Arguments: Arguments:
ms - Delay in milliseconds before dispatching ms - Delay in milliseconds before dispatching
event - Event map to dispatch after the delay event - Event map to dispatch after the delay
Returns: Returns:
{:type :delay, :ms <ms>, :event <event>} {:type :delayed-event, :ms <ms>, :event <event>}
Examples: Examples:
;; Show a message that auto-clears after 3 seconds ;; Show a message that auto-clears after 3 seconds
@@ -145,7 +148,7 @@
(case (:type event) (case (:type event)
:show-message :show-message
{:model (assoc model :message (:text event)) {:model (assoc model :message (:text event))
:events [(delay 3000 {:type :clear-message})]} :events [(delayed-event 3000 {:type :clear-message})]}
:clear-message :clear-message
{:model (dissoc model :message)} {:model (dissoc model :message)}
@@ -158,13 +161,13 @@
{:model (-> model {:model (-> model
(update :buffer conj event) (update :buffer conj event)
(assoc :dirty true)) (assoc :dirty true))
:events [(delay 5000 {:type :auto-save})]} :events [(delayed-event 5000 {:type :auto-save})]}
{:model model})) {:model model}))
;; Simple animation frame ;; Simple animation frame
{:events [(delay 16 {:type :animation-tick})]}" {:events [(delayed-event 16 {:type :animation-tick})]}"
[ms event] [ms event]
{:type :delay, :ms ms, :event event}) {:type :delayed-event, :ms ms, :event event})
(defn shell (defn shell
"Create an event that runs a shell command asynchronously. "Create an event that runs a shell command asynchronously.
@@ -274,7 +277,7 @@
;; Show message, wait, then clear ;; Show message, wait, then clear
(sequential (sequential
{:type :show-message, :text \"Saved!\"} {:type :show-message, :text \"Saved!\"}
(delay 2000 {:type :clear-message})) (delayed-event 2000 {:type :clear-message}))
Note: Note:
For complex workflows, consider handling each step explicitly For complex workflows, consider handling each step explicitly
+3 -3
View File
@@ -286,10 +286,10 @@
box-width (or target-width (+ (max inner-width title-width) 2)) box-width (or target-width (+ (max inner-width title-width) 2))
content-width (- box-width 2) content-width (- box-width 2)
;; Pad lines ;; Pad lines (and truncate if too long)
padded-lines (for [line lines] padded-lines (for [line lines]
(str (apply str (repeat pad-left " ")) (str (apply str (repeat pad-left " "))
(ansi/pad-right line (- content-width pad-left pad-right)) (ansi/fit-width line (- content-width pad-left pad-right))
(apply str (repeat pad-right " ")))) (apply str (repeat pad-right " "))))
;; Add vertical padding ;; Add vertical padding
@@ -322,7 +322,7 @@
(:br chars)) (:br chars))
body-lines (for [line all-lines] body-lines (for [line all-lines]
(str (:v chars) (str (:v chars)
(ansi/pad-right line content-width) (ansi/fit-width line content-width)
(:v chars)))] (:v chars)))]
(str/join "\n" (concat [top-line] body-lines [bottom-line])))) (str/join "\n" (concat [top-line] body-lines [bottom-line]))))
+232 -269
View File
@@ -4,237 +4,221 @@
(:require [clojure.test :refer [deftest testing is are]] (:require [clojure.test :refer [deftest testing is are]]
[clojure.string :as str] [clojure.string :as str]
[tui.core :as tui] [tui.core :as tui]
[tui.events :as ev]
[tui.render :as render] [tui.render :as render]
[tui.input :as input])) [tui.input :as input]))
;; ============================================================================= ;; =============================================================================
;; KEY MATCHING TESTS (tui/key=) ;; KEY MATCHING TESTS (ev/key=)
;; Patterns from: counter, timer, list-selection, spinner, views, http ;; Patterns from: counter, timer, list-selection, spinner, views, http
;; ============================================================================= ;; =============================================================================
(deftest key=-character-keys-test (deftest key=-character-keys-test
(testing "from counter: matching q for quit" (testing "from counter: matching q for quit"
(is (tui/key= [:key {:char \q}] "q")) (is (ev/key= {:type :key :key \q} \q))
(is (not (tui/key= [:key {:char \a}] "q")))) (is (not (ev/key= {:type :key :key \a} \q))))
(testing "from counter: matching k/j for navigation" (testing "from counter: matching k/j for navigation"
(is (tui/key= [:key {:char \k}] "k")) (is (ev/key= {:type :key :key \k} \k))
(is (tui/key= [:key {:char \j}] "j"))) (is (ev/key= {:type :key :key \j} \j)))
(testing "from counter: matching r for reset" (testing "from counter: matching r for reset"
(is (tui/key= [:key {:char \r}] "r"))) (is (ev/key= {:type :key :key \r} \r)))
(testing "from timer: matching space for pause/resume" (testing "from timer: matching space for pause/resume"
(is (tui/key= [:key {:char \space}] " ")) (is (ev/key= {:type :key :key \space} \space)))
(is (tui/key= [:key {:char \space}] " ")))
(testing "from views: matching b for back, y/n for confirm" (testing "from views: matching b for back, y/n for confirm"
(is (tui/key= [:key {:char \b}] "b")) (is (ev/key= {:type :key :key \b} \b))
(is (tui/key= [:key {:char \y}] "y")) (is (ev/key= {:type :key :key \y} \y))
(is (tui/key= [:key {:char \n}] "n")))) (is (ev/key= {:type :key :key \n} \n))))
(deftest key=-arrow-keys-test (deftest key=-arrow-keys-test
(testing "from counter/list-selection: up/down arrows" (testing "from counter/list-selection: up/down arrows"
(is (tui/key= [:key :up] :up)) (is (ev/key= {:type :key :key :up} :up))
(is (tui/key= [:key :down] :down)) (is (ev/key= {:type :key :key :down} :down))
(is (not (tui/key= [:key :up] :down))) (is (not (ev/key= {:type :key :key :up} :down)))
(is (not (tui/key= [:key :left] :up)))) (is (not (ev/key= {:type :key :key :left} :up))))
(testing "left/right arrows" (testing "left/right arrows"
(is (tui/key= [:key :left] :left)) (is (ev/key= {:type :key :key :left} :left))
(is (tui/key= [:key :right] :right)))) (is (ev/key= {:type :key :key :right} :right))))
(deftest key=-special-keys-test (deftest key=-special-keys-test
(testing "from list-selection/http: enter key" (testing "from list-selection/http: enter key"
(is (tui/key= [:key :enter] :enter))) (is (ev/key= {:type :key :key :enter} :enter)))
(testing "from views: escape key" (testing "from views: escape key"
(is (tui/key= [:key :escape] :escape))) (is (ev/key= {:type :key :key :escape} :escape)))
(testing "from spinner: tab key" (testing "from spinner: tab key"
(is (tui/key= [:key :tab] :tab))) (is (ev/key= {:type :key :key :tab} :tab)))
(testing "backspace key" (testing "backspace key"
(is (tui/key= [:key :backspace] :backspace)))) (is (ev/key= {:type :key :key :backspace} :backspace))))
(deftest key=-ctrl-combos-test (deftest key=-ctrl-combos-test
(testing "from all examples: ctrl+c for quit" (testing "from all examples: ctrl+c for quit"
(is (tui/key= [:key {:ctrl true :char \c}] [:ctrl \c]))) (is (ev/key= {:type :key :key \c :modifiers #{:ctrl}} \c #{:ctrl})))
(testing "other ctrl combinations" (testing "other ctrl combinations"
(is (tui/key= [:key {:ctrl true :char \x}] [:ctrl \x])) (is (ev/key= {:type :key :key \x :modifiers #{:ctrl}} \x #{:ctrl}))
(is (tui/key= [:key {:ctrl true :char \z}] [:ctrl \z])) (is (ev/key= {:type :key :key \z :modifiers #{:ctrl}} \z #{:ctrl}))
(is (tui/key= [:key {:ctrl true :char \a}] [:ctrl \a]))) (is (ev/key= {:type :key :key \a :modifiers #{:ctrl}} \a #{:ctrl})))
(testing "ctrl combo does not match plain char" (testing "ctrl combo does not match plain char"
(is (not (tui/key= [:key {:ctrl true :char \c}] "c"))) (is (not (ev/key= {:type :key :key \c :modifiers #{:ctrl}} \c)))
(is (not (tui/key= [:key {:char \c}] [:ctrl \c]))))) (is (not (ev/key= {:type :key :key \c} \c #{:ctrl})))))
(deftest key=-alt-combos-test (deftest key=-alt-combos-test
(testing "alt+char combinations" (testing "alt+char combinations"
(is (tui/key= [:key {:alt true :char \x}] [:alt \x])) (is (ev/key= {:type :key :key \x :modifiers #{:alt}} \x #{:alt}))
(is (tui/key= [:key {:alt true :char \a}] [:alt \a]))) (is (ev/key= {:type :key :key \a :modifiers #{:alt}} \a #{:alt})))
(testing "alt combo does not match plain char" (testing "alt combo does not match plain char"
(is (not (tui/key= [:key {:alt true :char \x}] "x"))) (is (not (ev/key= {:type :key :key \x :modifiers #{:alt}} \x)))
(is (not (tui/key= [:key {:char \x}] [:alt \x]))))) (is (not (ev/key= {:type :key :key \x} \x #{:alt})))))
(deftest key=-non-key-messages-test (deftest key=-non-key-events-test
(testing "from timer/spinner: tick messages are not keys" (testing "from timer/spinner: custom events are not keys"
(is (not (tui/key= [:tick 123456789] "q"))) (is (not (ev/key= {:type :timer-tick} \q)))
(is (not (tui/key= [:tick 123456789] :enter)))) (is (not (ev/key= {:type :timer-tick} :enter))))
(testing "from http: custom messages are not keys" (testing "from http: custom events are not keys"
(is (not (tui/key= [:http-success 200] "q"))) (is (not (ev/key= {:type :http-result :status 200} \q)))
(is (not (tui/key= [:http-error "timeout"] :enter)))) (is (not (ev/key= {:type :http-error :error "timeout"} :enter))))
(testing "quit command is not a key" (testing "quit event is not a key"
(is (not (tui/key= [:quit] "q"))))) (is (not (ev/key= {:type :quit} \q)))))
;; ============================================================================= ;; =============================================================================
;; COMMAND TESTS ;; EVENT CONSTRUCTOR TESTS
;; Patterns from: timer, spinner, http ;; Patterns from: timer, spinner, http
;; ============================================================================= ;; =============================================================================
(deftest quit-command-test (deftest quit-event-test
(testing "from all examples: tui/quit is [:quit]" (testing "from all examples: ev/quit creates quit event"
(is (= [:quit] tui/quit)) (is (= {:type :quit} (ev/quit)))
(is (vector? tui/quit)) (is (map? (ev/quit)))
(is (= :quit (first tui/quit))))) (is (= :quit (:type (ev/quit))))))
(deftest after-command-test (deftest delayed-event-test
(testing "from timer: after creates function" (testing "from timer: delayed-event creates delayed-event event"
(let [cmd (tui/after 1000 :timer-tick)] (let [event (ev/delayed-event 1000 {:type :timer-tick})]
(is (fn? cmd)))) (is (= :delayed-event (:type event)))
(is (= 1000 (:ms event)))
(is (= {:type :timer-tick} (:event event)))))
(testing "from spinner: after creates function" (testing "from spinner: delayed-event with short interval"
(let [cmd (tui/after 80 :spinner-frame)] (let [event (ev/delayed-event 80 {:type :spinner-frame})]
(is (fn? cmd)))) (is (= :delayed-event (:type event)))
(is (= 80 (:ms event))))))
(testing "after with zero delay returns message immediately" (deftest batch-event-test
(is (= :timer-tick ((tui/after 0 :timer-tick)))) (testing "batch multiple events"
(is (= [:my-tick {:id 1}] ((tui/after 0 [:my-tick {:id 1}])))))) (let [event (ev/batch {:type :msg1} {:type :msg2})]
(is (= :batch (:type event)))
(deftest batch-command-test (is (= 2 (count (:events event))))
(testing "batch two commands" (is (= [{:type :msg1} {:type :msg2}] (:events event)))))
(let [cmd (tui/batch (tui/after 100 :tick1) tui/quit)]
(is (= :batch (first cmd)))
(is (= 3 (count cmd)))
(is (fn? (second cmd)))
(is (= [:quit] (last cmd)))))
(testing "batch three commands"
(let [cmd (tui/batch (tui/after 50 :t1) (tui/after 100 :t2) tui/quit)]
(is (= :batch (first cmd)))
(is (= 4 (count cmd)))))
(testing "batch filters nil" (testing "batch filters nil"
(let [cmd (tui/batch nil (tui/send-msg :msg1) nil)] (let [event (ev/batch nil {:type :msg1} nil)]
(is (= :batch (first cmd))) (is (= :batch (:type event)))
(is (= 2 (count cmd)))) (is (= 1 (count (:events event)))))
(is (= [:batch] (tui/batch nil nil nil)))) (is (nil? (ev/batch nil nil nil))))
(testing "batch with single command" (testing "batch with single event"
(is (= [:batch [:quit]] (tui/batch tui/quit))))) (let [event (ev/batch {:type :msg1})]
(is (= :batch (:type event)))
(is (= 1 (count (:events event)))))))
(deftest sequentially-command-test (deftest sequential-event-test
(testing "sequentially two commands" (testing "sequential multiple events"
(let [cmd (tui/sequentially (tui/after 100 :tick1) tui/quit)] (let [event (ev/sequential {:type :msg1} {:type :msg2})]
(is (= :seq (first cmd))) (is (= :sequential (:type event)))
(is (= 3 (count cmd))) (is (= 2 (count (:events event))))))
(is (fn? (second cmd)))
(is (= [:quit] (last cmd)))))
(testing "sequentially filters nil" (testing "sequential filters nil"
(let [cmd (tui/sequentially nil (tui/send-msg :msg1) nil)] (let [event (ev/sequential nil {:type :msg1} nil)]
(is (= :seq (first cmd))) (is (= :sequential (:type event)))
(is (= 2 (count cmd))))) (is (= 1 (count (:events event))))))
(testing "sequentially with functions" (testing "sequential with delay and quit"
(let [f (fn [] :msg) (let [event (ev/sequential
cmd (tui/sequentially f tui/quit)] (ev/delayed-event 100 {:type :tick})
(is (= 3 (count cmd))) (ev/quit))]
(is (= :seq (first cmd))) (is (= :sequential (:type event)))
(is (fn? (second cmd)))))) (is (= 2 (count (:events event)))))))
(deftest send-msg-command-test (deftest shell-event-test
(testing "from http pattern: send-msg creates function" (testing "shell creates shell event"
(let [cmd (tui/send-msg [:http-success 200])] (let [event (ev/shell ["git" "status"] {:type :git-result})]
(is (fn? cmd)) (is (= :shell (:type event)))
(is (= [:http-success 200] (cmd))))) (is (= ["git" "status"] (:cmd event)))
(is (= {:type :git-result} (:event event))))))
(testing "send-msg with map" (deftest debounce-event-test
(let [cmd (tui/send-msg {:type :custom :data 42})] (testing "debounce creates debounce event"
(is (= {:type :custom :data 42} (cmd))))) (let [event (ev/debounce :search 300 {:type :do-search :query "test"})]
(is (= :debounce (:type event)))
(testing "send-msg with keyword" (is (= :search (:id event)))
(let [cmd (tui/send-msg :done)] (is (= 300 (:ms event)))
(is (= :done (cmd)))))) (is (= {:type :do-search :query "test"} (:event event))))))
(deftest custom-command-function-test
(testing "from http: custom async command pattern"
(let [fetch-result (atom nil)
cmd (fn []
(reset! fetch-result :fetched)
[:http-success 200])]
;; Execute command
(is (= [:http-success 200] (cmd)))
(is (= :fetched @fetch-result)))))
;; ============================================================================= ;; =============================================================================
;; UPDATE FUNCTION PATTERNS ;; UPDATE FUNCTION PATTERNS
;; Testing the [model cmd] return contract ;; Testing the {:model m :events [...]} return contract
;; ============================================================================= ;; =============================================================================
(deftest update-returns-tuple-test (deftest update-returns-map-test
(testing "update always returns [model cmd] tuple" (testing "update always returns {:model m :events [...]}"
(let [model {:count 0} (let [model {:count 0}
;; Counter-style update ;; Counter-style update
update-fn (fn [m msg] update-fn (fn [{:keys [model event]}]
(cond (cond
(tui/key= msg "q") [m tui/quit] (ev/key= event \q) {:model model :events [(ev/quit)]}
(tui/key= msg :up) [(update m :count inc) nil] (ev/key= event :up) {:model (update model :count inc)}
:else [m nil]))] :else {:model model}))]
;; Quit returns original model + quit command ;; Quit returns original model + quit event
(let [[new-model cmd] (update-fn model [:key {:char \q}])] (let [{:keys [model events]} (update-fn {:model model :event {:type :key :key \q}})]
(is (= model new-model)) (is (= {:count 0} model))
(is (= tui/quit cmd))) (is (= [{:type :quit}] events)))
;; Up returns modified model + nil command ;; Up returns modified model, no events
(let [[new-model cmd] (update-fn model [:key :up])] (let [{:keys [model events]} (update-fn {:model model :event {:type :key :key :up}})]
(is (= {:count 1} new-model)) (is (= {:count 1} model))
(is (nil? cmd))) (is (nil? events)))
;; Unknown key returns unchanged model + nil command ;; Unknown key returns unchanged model, no events
(let [[new-model cmd] (update-fn model [:key {:char \x}])] (let [{:keys [model events]} (update-fn {:model model :event {:type :key :key \x}})]
(is (= model new-model)) (is (= {:count 0} model))
(is (nil? cmd)))))) (is (nil? events))))))
(deftest counter-update-pattern-test (deftest counter-update-pattern-test
(testing "counter increment/decrement pattern" (testing "counter increment/decrement pattern"
(let [update-fn (fn [{:keys [count] :as model} msg] (let [update-fn (fn [{:keys [model event]}]
(cond (cond
(or (tui/key= msg :up) (or (ev/key= event :up)
(tui/key= msg "k")) (ev/key= event \k))
[(update model :count inc) nil] {:model (update model :count inc)}
(or (tui/key= msg :down) (or (ev/key= event :down)
(tui/key= msg "j")) (ev/key= event \j))
[(update model :count dec) nil] {:model (update model :count dec)}
(tui/key= msg "r") (ev/key= event \r)
[(assoc model :count 0) nil] {:model (assoc model :count 0)}
:else :else
[model nil]))] {:model model}))]
;; Test sequence: up, up, down, reset ;; Test sequence: up, up, down, reset
(let [m0 {:count 0} (let [m0 {:count 0}
[m1 _] (update-fn m0 [:key :up]) m1 (:model (update-fn {:model m0 :event {:type :key :key :up}}))
[m2 _] (update-fn m1 [:key {:char \k}]) m2 (:model (update-fn {:model m1 :event {:type :key :key \k}}))
[m3 _] (update-fn m2 [:key :down]) m3 (:model (update-fn {:model m2 :event {:type :key :key :down}}))
[m4 _] (update-fn m3 [:key {:char \r}])] m4 (:model (update-fn {:model m3 :event {:type :key :key \r}}))]
(is (= 1 (:count m1))) (is (= 1 (:count m1)))
(is (= 2 (:count m2))) (is (= 2 (:count m2)))
(is (= 1 (:count m3))) (is (= 1 (:count m3)))
@@ -242,68 +226,69 @@
(deftest timer-update-pattern-test (deftest timer-update-pattern-test
(testing "timer tick handling pattern" (testing "timer tick handling pattern"
(let [update-fn (fn [{:keys [seconds running] :as model} msg] (let [update-fn (fn [{:keys [model event]}]
(cond (cond
(= msg :timer-tick) (= (:type event) :timer-tick)
(if running (if (:running model)
(let [new-seconds (dec seconds)] (let [new-seconds (dec (:seconds model))]
(if (<= new-seconds 0) (if (<= new-seconds 0)
[(assoc model :seconds 0 :done true :running false) nil] {:model (assoc model :seconds 0 :done true :running false)}
[(assoc model :seconds new-seconds) (tui/after 1000 :timer-tick)])) {:model (assoc model :seconds new-seconds)
[model nil]) :events [(ev/delayed-event 1000 {:type :timer-tick})]}))
{:model model})
(tui/key= msg " ") (ev/key= event \space)
(let [new-running (not running)] (let [new-running (not (:running model))]
[(assoc model :running new-running) {:model (assoc model :running new-running)
(when new-running (tui/after 1000 :timer-tick))]) :events (when new-running [(ev/delayed-event 1000 {:type :timer-tick})])})
:else :else
[model nil]))] {:model model}))]
;; Test tick countdown ;; Test tick countdown
(let [m0 {:seconds 3 :running true :done false} (let [m0 {:seconds 3 :running true :done false}
[m1 c1] (update-fn m0 :timer-tick) r1 (update-fn {:model m0 :event {:type :timer-tick}})
[m2 c2] (update-fn m1 :timer-tick) r2 (update-fn {:model (:model r1) :event {:type :timer-tick}})
[m3 c3] (update-fn m2 :timer-tick)] r3 (update-fn {:model (:model r2) :event {:type :timer-tick}})]
(is (= 2 (:seconds m1))) (is (= 2 (:seconds (:model r1))))
(is (fn? c1)) (is (= 1 (count (:events r1))))
(is (= 1 (:seconds m2))) (is (= 1 (:seconds (:model r2))))
(is (fn? c2)) (is (= 1 (count (:events r2))))
(is (= 0 (:seconds m3))) (is (= 0 (:seconds (:model r3))))
(is (:done m3)) (is (:done (:model r3)))
(is (not (:running m3))) (is (not (:running (:model r3))))
(is (nil? c3))) (is (nil? (:events r3))))
;; Test pause/resume ;; Test pause/resume
(let [m0 {:seconds 5 :running true :done false} (let [m0 {:seconds 5 :running true :done false}
[m1 c1] (update-fn m0 [:key {:char \space}]) r1 (update-fn {:model m0 :event {:type :key :key \space}})
[m2 c2] (update-fn m1 [:key {:char \space}])] r2 (update-fn {:model (:model r1) :event {:type :key :key \space}})]
(is (not (:running m1))) (is (not (:running (:model r1))))
(is (nil? c1)) (is (nil? (:events r1)))
(is (:running m2)) (is (:running (:model r2)))
(is (fn? c2)))))) (is (= 1 (count (:events r2))))))))
(deftest list-selection-update-pattern-test (deftest list-selection-update-pattern-test
(testing "cursor navigation with bounds" (testing "cursor navigation with bounds"
(let [items ["a" "b" "c" "d"] (let [items ["a" "b" "c" "d"]
update-fn (fn [{:keys [cursor] :as model} msg] update-fn (fn [{:keys [model event]}]
(cond (cond
(or (tui/key= msg :up) (tui/key= msg "k")) (or (ev/key= event :up) (ev/key= event \k))
[(update model :cursor #(max 0 (dec %))) nil] {:model (update model :cursor #(max 0 (dec %)))}
(or (tui/key= msg :down) (tui/key= msg "j")) (or (ev/key= event :down) (ev/key= event \j))
[(update model :cursor #(min (dec (count items)) (inc %))) nil] {:model (update model :cursor #(min (dec (count items)) (inc %)))}
:else :else
[model nil]))] {:model model}))]
;; Test bounds ;; Test bounds
(let [m0 {:cursor 0} (let [m0 {:cursor 0}
[m1 _] (update-fn m0 [:key :up]) ; Can't go below 0 m1 (:model (update-fn {:model m0 :event {:type :key :key :up}}))
[m2 _] (update-fn m1 [:key :down]) m2 (:model (update-fn {:model m1 :event {:type :key :key :down}}))
[m3 _] (update-fn m2 [:key :down]) m3 (:model (update-fn {:model m2 :event {:type :key :key :down}}))
[m4 _] (update-fn m3 [:key :down]) m4 (:model (update-fn {:model m3 :event {:type :key :key :down}}))
[m5 _] (update-fn m4 [:key :down])] ; Can't go above 3 m5 (:model (update-fn {:model m4 :event {:type :key :key :down}}))]
(is (= 0 (:cursor m1))) (is (= 0 (:cursor m1)))
(is (= 1 (:cursor m2))) (is (= 1 (:cursor m2)))
(is (= 2 (:cursor m3))) (is (= 2 (:cursor m3)))
@@ -311,108 +296,109 @@
(is (= 3 (:cursor m5)))))) (is (= 3 (:cursor m5))))))
(testing "toggle selection pattern" (testing "toggle selection pattern"
(let [update-fn (fn [{:keys [cursor] :as model} msg] (let [update-fn (fn [{:keys [model event]}]
(if (tui/key= msg " ") (if (ev/key= event \space)
[(update model :selected {:model (update model :selected
#(if (contains? % cursor) #(let [cursor (:cursor model)]
(if (contains? % cursor)
(disj % cursor) (disj % cursor)
(conj % cursor))) (conj % cursor))))}
nil] {:model model}))]
[model nil]))]
(let [m0 {:cursor 0 :selected #{}} (let [m0 {:cursor 0 :selected #{}}
[m1 _] (update-fn m0 [:key {:char \space}]) m1 (:model (update-fn {:model m0 :event {:type :key :key \space}}))
[m2 _] (update-fn (assoc m1 :cursor 2) [:key {:char \space}]) m2 (:model (update-fn {:model (assoc m1 :cursor 2) :event {:type :key :key \space}}))
[m3 _] (update-fn (assoc m2 :cursor 0) [:key {:char \space}])] m3 (:model (update-fn {:model (assoc m2 :cursor 0) :event {:type :key :key \space}}))]
(is (= #{0} (:selected m1))) (is (= #{0} (:selected m1)))
(is (= #{0 2} (:selected m2))) (is (= #{0 2} (:selected m2)))
(is (= #{2} (:selected m3))))))) (is (= #{2} (:selected m3)))))))
(deftest views-state-machine-pattern-test (deftest views-state-machine-pattern-test
(testing "view state transitions" (testing "view state transitions"
(let [update-fn (fn [{:keys [view] :as model} msg] (let [update-fn (fn [{:keys [model event]}]
(case view (case (:view model)
:menu :menu
(cond (cond
(tui/key= msg :enter) (ev/key= event :enter)
[(assoc model :view :detail) nil] {:model (assoc model :view :detail)}
(tui/key= msg "q") (ev/key= event \q)
[(assoc model :view :confirm) nil] {:model (assoc model :view :confirm)}
:else [model nil]) :else {:model model})
:detail :detail
(cond (cond
(or (tui/key= msg :escape) (tui/key= msg "b")) (or (ev/key= event :escape) (ev/key= event \b))
[(assoc model :view :menu) nil] {:model (assoc model :view :menu)}
(tui/key= msg "q") (ev/key= event \q)
[(assoc model :view :confirm) nil] {:model (assoc model :view :confirm)}
:else [model nil]) :else {:model model})
:confirm :confirm
(cond (cond
(tui/key= msg "y") (ev/key= event \y)
[model tui/quit] {:model model :events [(ev/quit)]}
(tui/key= msg "n") (ev/key= event \n)
[(assoc model :view :detail) nil] {:model (assoc model :view :detail)}
:else [model nil])))] :else {:model model})))]
;; Menu -> Detail -> Confirm -> Quit ;; Menu -> Detail -> Confirm -> Quit
(let [m0 {:view :menu} (let [m0 {:view :menu}
[m1 _] (update-fn m0 [:key :enter]) r1 (update-fn {:model m0 :event {:type :key :key :enter}})
[m2 _] (update-fn m1 [:key {:char \q}]) r2 (update-fn {:model (:model r1) :event {:type :key :key \q}})
[m3 c3] (update-fn m2 [:key {:char \y}])] r3 (update-fn {:model (:model r2) :event {:type :key :key \y}})]
(is (= :detail (:view m1))) (is (= :detail (:view (:model r1))))
(is (= :confirm (:view m2))) (is (= :confirm (:view (:model r2))))
(is (= tui/quit c3))) (is (= [{:type :quit}] (:events r3))))
;; Detail -> Menu (back) ;; Detail -> Menu (back)
(let [m0 {:view :detail} (let [m0 {:view :detail}
[m1 _] (update-fn m0 [:key :escape])] r1 (update-fn {:model m0 :event {:type :key :key :escape}})]
(is (= :menu (:view m1))))))) (is (= :menu (:view (:model r1))))))))
(deftest http-async-pattern-test (deftest http-async-pattern-test
(testing "HTTP state machine pattern" (testing "HTTP state machine pattern"
(let [update-fn (fn [{:keys [state url] :as model} msg] (let [update-fn (fn [{:keys [model event]}]
(cond (cond
(and (= state :idle) (tui/key= msg :enter)) (and (= (:state model) :idle) (ev/key= event :enter))
[(assoc model :state :loading) {:model (assoc model :state :loading)
(fn [] [:http-success 200])] :events [(ev/shell ["curl" "-s" (:url model)]
{:type :http-result})]}
(= (first msg) :http-success) (= (:type event) :http-result)
[(assoc model :state :success :status (second msg)) nil] (let [{:keys [success out err]} (:result event)]
(if success
{:model (assoc model :state :success :status 200)}
{:model (assoc model :state :error :error err)}))
(= (first msg) :http-error) (ev/key= event \r)
[(assoc model :state :error :error (second msg)) nil] {:model (assoc model :state :idle :status nil :error nil)}
(tui/key= msg "r")
[(assoc model :state :idle :status nil :error nil) nil]
:else :else
[model nil]))] {:model model}))]
;; Idle -> Loading ;; Idle -> Loading
(let [m0 {:state :idle :url "http://test.com"} (let [m0 {:state :idle :url "http://test.com"}
[m1 c1] (update-fn m0 [:key :enter])] r1 (update-fn {:model m0 :event {:type :key :key :enter}})]
(is (= :loading (:state m1))) (is (= :loading (:state (:model r1))))
(is (fn? c1))) (is (= 1 (count (:events r1)))))
;; Loading -> Success ;; Loading -> Success
(let [m0 {:state :loading} (let [m0 {:state :loading}
[m1 _] (update-fn m0 [:http-success 200])] r1 (update-fn {:model m0 :event {:type :http-result :result {:success true :out "OK"}}})]
(is (= :success (:state m1))) (is (= :success (:state (:model r1))))
(is (= 200 (:status m1)))) (is (= 200 (:status (:model r1)))))
;; Loading -> Error ;; Loading -> Error
(let [m0 {:state :loading} (let [m0 {:state :loading}
[m1 _] (update-fn m0 [:http-error "Connection refused"])] r1 (update-fn {:model m0 :event {:type :http-result :result {:success false :err "Connection refused"}}})]
(is (= :error (:state m1))) (is (= :error (:state (:model r1))))
(is (= "Connection refused" (:error m1)))) (is (= "Connection refused" (:error (:model r1)))))
;; Reset ;; Reset
(let [m0 {:state :error :error "timeout"} (let [m0 {:state :error :error "timeout"}
[m1 _] (update-fn m0 [:key {:char \r}])] r1 (update-fn {:model m0 :event {:type :key :key \r}})]
(is (= :idle (:state m1))) (is (= :idle (:state (:model r1))))
(is (nil? (:error m1))))))) (is (nil? (:error (:model r1))))))))
;; ============================================================================= ;; =============================================================================
;; RENDER TESTS ;; RENDER TESTS
@@ -565,26 +551,3 @@
(is (str/includes? result "Pizza")) (is (str/includes? result "Pizza"))
(is (str/includes? result "[ ] Sushi")) (is (str/includes? result "[ ] Sushi"))
(is (str/includes? result "[ ] Tacos"))))) (is (str/includes? result "[ ] Tacos")))))
;; =============================================================================
;; KEY-STR TESTS
;; =============================================================================
(deftest key-str-comprehensive-test
(testing "character keys"
(is (= "q" (tui/key-str [:key {:char \q}])))
(is (= " " (tui/key-str [:key {:char \space}]))))
(testing "special keys"
(is (= "enter" (tui/key-str [:key :enter])))
(is (= "escape" (tui/key-str [:key :escape])))
(is (= "tab" (tui/key-str [:key :tab])))
(is (= "backspace" (tui/key-str [:key :backspace])))
(is (= "up" (tui/key-str [:key :up])))
(is (= "down" (tui/key-str [:key :down])))
(is (= "left" (tui/key-str [:key :left])))
(is (= "right" (tui/key-str [:key :right]))))
(testing "modifier keys"
(is (= "ctrl+c" (tui/key-str [:key {:ctrl true :char \c}])))
(is (= "alt+x" (tui/key-str [:key {:alt true :char \x}])))))
+82 -183
View File
@@ -1,90 +1,88 @@
(ns tui.core-test (ns tui.core-test
"Integration tests for the TUI engine. "Integration tests for the TUI engine.
Tests the update loop, command handling, and full render pipeline." Tests the update loop, event handling, and full render pipeline."
(:require [clojure.test :refer [deftest testing is]] (:require [clojure.test :refer [deftest testing is]]
[clojure.core.async :as async :refer [chan >!! <!! timeout alt!! close!]] [clojure.core.async :as async :refer [chan >!! <!! timeout alt!! close!]]
[tui.core :as tui] [tui.core :as tui]
[tui.events :as ev] [tui.events :as ev]
[tui.render :as render])) [tui.render :as render]))
;; === Legacy Command Tests (Backward Compatibility) === ;; =============================================================================
;; EVENT CONSTRUCTOR TESTS
;; =============================================================================
(deftest quit-command-test (deftest quit-event-test
(testing "quit command is correct vector (legacy)" (testing "tui/quit creates quit event"
(is (= [:quit] tui/quit)))) (is (= {:type :quit} (tui/quit)))))
(deftest after-command-test (deftest delayed-event-test
(testing "after creates a function command (legacy)" (testing "tui/delayed-event creates delayed-event event"
(let [cmd (tui/after 0 :my-tick)] (let [event (tui/delayed-event 1000 {:type :timer-tick})]
(is (fn? cmd)) (is (= :delayed-event (:type event)))
(is (= :my-tick (cmd))))) (is (= 1000 (:ms event)))
(is (= {:type :timer-tick} (:event event))))))
(testing "after can send any message type" (deftest batch-event-test
(is (= [:timer-tick {:id 1}] ((tui/after 0 [:timer-tick {:id 1}])))) (testing "tui/batch creates batch event"
(is (= :simple-msg ((tui/after 0 :simple-msg))))) (let [event (tui/batch {:type :msg1} {:type :msg2})]
(is (= :batch (:type event)))
(is (= 2 (count (:events event))))))
(testing "after with non-zero delay creates function" (testing "batch filters nil"
(is (fn? (tui/after 100 :tick))) (let [event (tui/batch nil {:type :msg1} nil)]
(is (fn? (tui/after 1000 :tick))))) (is (= 1 (count (:events event)))))
(is (nil? (tui/batch nil nil nil)))))
(deftest batch-command-test (deftest sequential-event-test
(testing "batch combines commands (legacy)" (testing "tui/sequential creates sequential event"
(let [cmd (tui/batch (tui/send-msg :msg1) tui/quit)] (let [event (tui/sequential {:type :msg1} {:type :msg2})]
(is (vector? cmd)) (is (= :sequential (:type event)))
(is (= :batch (first cmd))) (is (= 2 (count (:events event))))))
(is (= 3 (count cmd)))
(is (= [:quit] (last cmd)))))
(testing "batch filters nil commands" (testing "sequential filters nil"
(let [cmd (tui/batch nil (tui/send-msg :msg1) nil)] (let [event (tui/sequential nil {:type :msg1} nil)]
(is (= :batch (first cmd))) (is (= 1 (count (:events event)))))))
(is (= 2 (count cmd))))))
(deftest sequentially-command-test (deftest shell-event-test
(testing "sequentially creates seq command (legacy)" (testing "tui/shell creates shell event"
(let [cmd (tui/sequentially (tui/send-msg :msg1) tui/quit)] (let [event (tui/shell ["git" "status"] {:type :git-result})]
(is (vector? cmd)) (is (= :shell (:type event)))
(is (= :seq (first cmd))) (is (= ["git" "status"] (:cmd event)))
(is (= 3 (count cmd))) (is (= {:type :git-result} (:event event))))))
(is (= [:quit] (last cmd)))))
(testing "sequentially filters nil commands" (deftest debounce-event-test
(let [cmd (tui/sequentially nil (tui/send-msg :msg1) nil)] (testing "tui/debounce creates debounce event"
(is (= :seq (first cmd))) (let [event (tui/debounce :search 300 {:type :do-search})]
(is (= 2 (count cmd)))))) (is (= :debounce (:type event)))
(is (= :search (:id event)))
(is (= 300 (:ms event))))))
(deftest send-msg-command-test ;; =============================================================================
(testing "send-msg creates function that returns message (legacy)" ;; KEY MATCHING TESTS
(let [cmd (tui/send-msg {:type :custom :data 42})] ;; =============================================================================
(is (fn? cmd))
(is (= {:type :custom :data 42} (cmd))))))
;; === Legacy Key Matching Tests === (deftest key=-test
(testing "key= matches characters"
(is (tui/key= {:type :key :key \q} \q))
(is (not (tui/key= {:type :key :key \a} \q))))
(deftest key=-legacy-test (testing "key= matches special keys"
(testing "key= works with legacy format"
(is (tui/key= [:key {:char \q}] "q"))
(is (tui/key= [:key :enter] :enter))
(is (tui/key= [:key {:ctrl true :char \c}] [:ctrl \c]))
(is (not (tui/key= [:key {:char \a}] "b")))))
(deftest key=-new-format-test
(testing "key= works with new format"
(is (tui/key= {:type :key :key \q} "q"))
(is (tui/key= {:type :key :key :enter} :enter)) (is (tui/key= {:type :key :key :enter} :enter))
(is (tui/key= {:type :key :key \c :modifiers #{:ctrl}} [:ctrl \c])) (is (tui/key= {:type :key :key :up} :up))
(is (not (tui/key= {:type :key :key \a} "b"))))) (is (tui/key= {:type :key :key :escape} :escape)))
(deftest key-str-test (testing "key= matches modifiers"
(testing "key-str converts key to string (legacy)" (is (tui/key= {:type :key :key \c :modifiers #{:ctrl}} \c #{:ctrl}))
(is (= "q" (tui/key-str [:key {:char \q}]))) (is (not (tui/key= {:type :key :key \c :modifiers #{:ctrl}} \c)))
(is (= "enter" (tui/key-str [:key :enter])))) (is (not (tui/key= {:type :key :key \c} \c #{:ctrl}))))
(testing "key-str converts key to string (new format)" (testing "key= doesn't match non-key events"
(is (= "q" (tui/key-str {:type :key :key \q}))) (is (not (tui/key= {:type :timer-tick} \q)))
(is (= "enter" (tui/key-str {:type :key :key :enter}))))) (is (not (tui/key= {:type :quit} :enter)))))
;; === Full Pipeline Tests === ;; =============================================================================
;; RENDER PIPELINE TESTS
;; =============================================================================
(deftest render-pipeline-test (deftest render-pipeline-test
(testing "model -> view -> render produces valid output" (testing "model -> view -> render produces valid output"
@@ -98,10 +96,12 @@
(is (clojure.string/includes? rendered "Counter")) (is (clojure.string/includes? rendered "Counter"))
(is (clojure.string/includes? rendered "Count: 5"))))) (is (clojure.string/includes? rendered "Count: 5")))))
;; === New API Update Function Tests === ;; =============================================================================
;; UPDATE FUNCTION CONTRACT TESTS
;; =============================================================================
(deftest new-update-function-contract-test (deftest update-function-contract-test
(testing "new update function returns {:model ... :events ...}" (testing "update function returns {:model ... :events ...}"
(let [update-fn (fn [{:keys [model event]}] (let [update-fn (fn [{:keys [model event]}]
(cond (cond
(ev/key= event \q) {:model model :events [(ev/quit)]} (ev/key= event \q) {:model model :events [(ev/quit)]}
@@ -124,33 +124,9 @@
(is (= {:n 0} model)) (is (= {:n 0} model))
(is (nil? events)))))) (is (nil? events))))))
;; === Legacy Update Function Tests === ;; =============================================================================
;; EVENT EXECUTION TESTS
(deftest legacy-update-function-contract-test ;; =============================================================================
(testing "legacy update function returns [model cmd] tuple"
(let [update-fn (fn [model msg]
(cond
(tui/key= msg "q") [model tui/quit]
(tui/key= msg :up) [(update model :n inc) nil]
:else [model nil]))
model {:n 0}]
;; Test quit returns command
(let [[new-model cmd] (update-fn model [:key {:char \q}])]
(is (= model new-model))
(is (= [:quit] cmd)))
;; Test up returns updated model
(let [[new-model cmd] (update-fn model [:key :up])]
(is (= {:n 1} new-model))
(is (nil? cmd)))
;; Test unknown key returns model unchanged
(let [[new-model cmd] (update-fn model [:key {:char \x}])]
(is (= model new-model))
(is (nil? cmd))))))
;; === Event Execution Tests ===
(deftest execute-quit-event-test (deftest execute-quit-event-test
(testing "quit event puts {:type :quit} on channel" (testing "quit event puts {:type :quit} on channel"
@@ -162,10 +138,10 @@
(is (= {:type :quit} result))) (is (= {:type :quit} result)))
(close! msg-chan)))) (close! msg-chan))))
(deftest execute-delay-event-test (deftest execute-delayed-event-test
(testing "delay event sends message after delay" (testing "delayed-event sends message after delay"
(let [msg-chan (chan 1) (let [msg-chan (chan 1)
event (ev/delay 50 {:type :delayed-msg})] event (ev/delayed-event 50 {:type :delayed-msg})]
(#'tui/execute-event! event msg-chan) (#'tui/execute-event! event msg-chan)
;; Should not receive immediately ;; Should not receive immediately
(let [immediate (alt!! (let [immediate (alt!!
@@ -218,94 +194,17 @@
(is (= :timeout result))) (is (= :timeout result)))
(close! msg-chan)))) (close! msg-chan))))
;; === Legacy Command Execution Tests === ;; =============================================================================
;; DEFAPP MACRO TESTS
(deftest execute-quit-command-legacy-test ;; =============================================================================
(testing "quit command puts {:type :quit} on channel"
(let [msg-chan (chan 1)]
(#'tui/execute-cmd! [:quit] msg-chan)
(let [result (alt!!
msg-chan ([v] v)
(timeout 100) :timeout)]
(is (= {:type :quit} result)))
(close! msg-chan))))
(deftest execute-after-command-legacy-test
(testing "after command sends message after delay"
(let [msg-chan (chan 1)
cmd (tui/after 50 :delayed-msg)]
(#'tui/execute-cmd! cmd msg-chan)
;; Should not receive immediately
(let [immediate (alt!!
msg-chan ([v] v)
(timeout 10) :timeout)]
(is (= :timeout immediate)))
;; Should receive after delay
(let [delayed (alt!!
msg-chan ([v] v)
(timeout 200) :timeout)]
(is (= :delayed-msg delayed)))
(close! msg-chan))))
(deftest execute-function-command-legacy-test
(testing "function command executes and sends result"
(let [msg-chan (chan 1)
cmd (fn [] {:custom :message})]
(#'tui/execute-cmd! cmd msg-chan)
(let [result (alt!!
msg-chan ([v] v)
(timeout 100) :timeout)]
(is (= {:custom :message} result)))
(close! msg-chan))))
(deftest execute-batch-command-legacy-test
(testing "batch executes multiple commands"
(let [msg-chan (chan 10)]
(#'tui/execute-cmd! [:batch
(fn [] :msg1)
(fn [] :msg2)]
msg-chan)
;; Give time for async execution
(Thread/sleep 50)
(let [results (loop [msgs []]
(let [msg (alt!!
msg-chan ([v] v)
(timeout 10) nil)]
(if msg
(recur (conj msgs msg))
msgs)))]
(is (= #{:msg1 :msg2} (set results))))
(close! msg-chan))))
(deftest execute-nil-command-legacy-test
(testing "nil command does nothing"
(let [msg-chan (chan 1)]
(#'tui/execute-cmd! nil msg-chan)
(let [result (alt!!
msg-chan ([v] v)
(timeout 50) :timeout)]
(is (= :timeout result)))
(close! msg-chan))))
;; === Defapp Macro Tests ===
(deftest defapp-macro-test (deftest defapp-macro-test
(testing "defapp creates app map (legacy)" (testing "defapp creates app map"
(tui/defapp test-app-legacy (tui/defapp test-app
:init {:count 0}
:update (fn [m msg] [m nil])
:view (fn [m] [:text "test"]))
(is (map? test-app-legacy))
(is (= {:count 0} (:init test-app-legacy)))
(is (fn? (:update test-app-legacy)))
(is (fn? (:view test-app-legacy))))
(testing "defapp creates app map (new)"
(tui/defapp test-app-new
:init {:count 0} :init {:count 0}
:update (fn [{:keys [model event]}] {:model model}) :update (fn [{:keys [model event]}] {:model model})
:view (fn [m size] [:text "test"])) :view (fn [m size] [:text "test"]))
(is (map? test-app-new)) (is (map? test-app))
(is (= {:count 0} (:init test-app-new))) (is (= {:count 0} (:init test-app)))
(is (fn? (:update test-app-new))) (is (fn? (:update test-app)))
(is (fn? (:view test-app-new))))) (is (fn? (:view test-app)))))
+68 -104
View File
@@ -4,6 +4,7 @@
(:require [clojure.test :refer [deftest testing is are]] (:require [clojure.test :refer [deftest testing is are]]
[clojure.string :as str] [clojure.string :as str]
[tui.core :as tui] [tui.core :as tui]
[tui.events :as ev]
[tui.render :as render] [tui.render :as render]
[tui.input :as input] [tui.input :as input]
[tui.ansi :as ansi])) [tui.ansi :as ansi]))
@@ -145,98 +146,65 @@
;; ============================================================================= ;; =============================================================================
(deftest key-match-edge-cases-test (deftest key-match-edge-cases-test
(testing "empty string pattern" (testing "nil event returns false"
(is (not (input/key-match? [:key {:char \a}] "")))) (is (not (ev/key= nil \q)))
(is (not (ev/key= nil :enter))))
(testing "multi-char string pattern only matches first char" (testing "non-key event returns false"
;; The current implementation only looks at first char (is (not (ev/key= {:type :timer-tick} \q)))
(is (input/key-match? [:key {:char \q}] "quit"))) (is (not (ev/key= {:type :http-result :status 200} :enter))))
(testing "nil message returns false" (testing "key event with missing key field"
(is (not (input/key-match? nil "q"))) (is (not (ev/key= {:type :key} \q)))
(is (not (input/key-match? nil :enter)))) (is (not (ev/key= {:type :key :modifiers #{:ctrl}} \c #{:ctrl})))))
(testing "non-key message returns false"
(is (not (input/key-match? [:tick 123] "q")))
(is (not (input/key-match? [:http-success 200] :enter)))
(is (not (input/key-match? "not a vector" "q"))))
(testing "unknown key message structure"
(is (not (input/key-match? [:key {:unknown true}] "q")))
(is (not (input/key-match? [:key {}] "q")))))
(deftest key-str-edge-cases-test
(testing "nil message returns empty string"
(is (= "" (input/key->str nil))))
(testing "non-key message returns string representation"
;; Legacy format returns the second element as string
(is (string? (input/key->str [:tick 123])))
(is (string? (input/key->str [:custom :message]))))
(testing "key message with empty map"
(is (= "" (input/key->str [:key {}]))))
(testing "ctrl and alt combined"
;; This is an edge case - both modifiers
(is (= "ctrl+alt+x" (input/key->str [:key {:ctrl true :alt true :char \x}])))))
;; ============================================================================= ;; =============================================================================
;; COMMAND EDGE CASES ;; EVENT EDGE CASES
;; ============================================================================= ;; =============================================================================
(deftest batch-edge-cases-test (deftest batch-edge-cases-test
(testing "batch with all nils" (testing "batch with all nils"
(is (= [:batch] (tui/batch nil nil nil)))) (is (nil? (ev/batch nil nil nil))))
(testing "batch with single command" (testing "batch with single event"
(is (= [:batch tui/quit] (tui/batch tui/quit)))) (let [event (ev/batch {:type :msg1})]
(is (= :batch (:type event)))
(is (= 1 (count (:events event))))))
(testing "batch with no arguments" (testing "batch with no arguments"
(is (= [:batch] (tui/batch)))) (is (nil? (ev/batch))))
(testing "batch with many commands" (testing "batch with many events"
(let [cmd (tui/batch (tui/after 1 :t1) (tui/after 2 :t2) (tui/after 3 :t3) (tui/after 4 :t4) (tui/after 5 :t5))] (let [event (ev/batch {:type :t1} {:type :t2} {:type :t3} {:type :t4} {:type :t5})]
(is (= 6 (count cmd))) ; :batch + 5 commands (is (= 5 (count (:events event))))
(is (= :batch (first cmd)))))) (is (= :batch (:type event))))))
(deftest sequentially-edge-cases-test (deftest sequential-edge-cases-test
(testing "sequentially with all nils" (testing "sequential with all nils"
(is (= [:seq] (tui/sequentially nil nil nil)))) (is (nil? (ev/sequential nil nil nil))))
(testing "sequentially with single command" (testing "sequential with single event"
(is (= [:seq tui/quit] (tui/sequentially tui/quit)))) (let [event (ev/sequential {:type :msg1})]
(is (= :sequential (:type event)))
(is (= 1 (count (:events event))))))
(testing "sequentially with no arguments" (testing "sequential with no arguments"
(is (= [:seq] (tui/sequentially))))) (is (nil? (ev/sequential)))))
(deftest after-edge-cases-test (deftest delayed-event-edge-cases-test
(testing "after with zero delay" (testing "delayed-event with zero delay"
(let [cmd (tui/after 0 :immediate)] (let [event (ev/delayed-event 0 {:type :immediate})]
(is (fn? cmd)) (is (= :delayed-event (:type event)))
;; Zero delay executes immediately (is (= 0 (:ms event)))))
(is (= :immediate (cmd)))))
(testing "after with various delays creates function" (testing "delayed-event with various delays"
;; Don't invoke - just verify the function is created correctly (is (= 1 (:ms (ev/delayed-event 1 {:type :t1}))))
(is (fn? (tui/after 1 :t1))) (is (= 1000 (:ms (ev/delayed-event 1000 {:type :t2}))))
(is (fn? (tui/after 1000 :t2))) (is (= 999999999 (:ms (ev/delayed-event 999999999 {:type :t3})))))
(is (fn? (tui/after 999999999 :t3))))
(testing "after with complex message" (testing "delayed-event with complex message"
(let [cmd (tui/after 0 [:tick {:id 1 :data [1 2 3]}])] (let [event (ev/delayed-event 0 {:type :tick :id 1 :data [1 2 3]})]
(is (= [:tick {:id 1 :data [1 2 3]}] (cmd)))))) (is (= {:type :tick :id 1 :data [1 2 3]} (:event event))))))
(deftest send-msg-edge-cases-test
(testing "send-msg with nil"
(let [cmd (tui/send-msg nil)]
(is (fn? cmd))
(is (nil? (cmd)))))
(testing "send-msg with complex message"
(let [msg {:type :complex :data [1 2 3] :nested {:a :b}}
cmd (tui/send-msg msg)]
(is (= msg (cmd))))))
;; ============================================================================= ;; =============================================================================
;; ANSI EDGE CASES ;; ANSI EDGE CASES
@@ -319,27 +287,24 @@
;; UPDATE FUNCTION EDGE CASES ;; UPDATE FUNCTION EDGE CASES
;; ============================================================================= ;; =============================================================================
(deftest update-with-unknown-messages-test (deftest update-with-unknown-events-test
(testing "update function handles unknown messages gracefully" (testing "update function handles unknown events gracefully"
(let [update-fn (fn [model msg] (let [update-fn (fn [{:keys [model event]}]
(cond (cond
(tui/key= msg "q") [model tui/quit] (ev/key= event \q) {:model model :events [(ev/quit)]}
:else [model nil]))] :else {:model model}))]
;; Unknown key ;; Unknown key
(let [[m cmd] (update-fn {:n 0} [:key {:char \x}])] (let [{:keys [model]} (update-fn {:model {:n 0} :event {:type :key :key \x}})]
(is (= {:n 0} m)) (is (= {:n 0} model)))
(is (nil? cmd)))
;; Unknown message type ;; Unknown event type
(let [[m cmd] (update-fn {:n 0} [:unknown :message])] (let [{:keys [model]} (update-fn {:model {:n 0} :event {:type :unknown :message "test"}})]
(is (= {:n 0} m)) (is (= {:n 0} model)))
(is (nil? cmd)))
;; Empty message ;; Event with no type
(let [[m cmd] (update-fn {:n 0} [])] (let [{:keys [model]} (update-fn {:model {:n 0} :event {}})]
(is (= {:n 0} m)) (is (= {:n 0} model))))))
(is (nil? cmd))))))
(deftest model-with-complex-state-test (deftest model-with-complex-state-test
(testing "model with nested data structures" (testing "model with nested data structures"
@@ -348,23 +313,22 @@
:nested {:deep {:value 42}} :nested {:deep {:value 42}}
:selected #{} :selected #{}
:history []} :history []}
update-fn (fn [model msg] update-fn (fn [{:keys [model event]}]
(if (tui/key= msg :up) (if (ev/key= event :up)
[(-> model {:model (-> model
(update :count inc) (update :count inc)
(update :history conj (:count model))) (update :history conj (:count model)))}
nil] {:model model}))]
[model nil]))]
(let [[m1 _] (update-fn complex-model [:key :up]) (let [r1 (update-fn {:model complex-model :event {:type :key :key :up}})
[m2 _] (update-fn m1 [:key :up])] r2 (update-fn {:model (:model r1) :event {:type :key :key :up}})]
(is (= 1 (:count m1))) (is (= 1 (:count (:model r1))))
(is (= [0] (:history m1))) (is (= [0] (:history (:model r1))))
(is (= 2 (:count m2))) (is (= 2 (:count (:model r2))))
(is (= [0 1] (:history m2))) (is (= [0 1] (:history (:model r2))))
;; Other fields unchanged ;; Other fields unchanged
(is (= ["a" "b" "c"] (:items m2))) (is (= ["a" "b" "c"] (:items (:model r2))))
(is (= 42 (get-in m2 [:nested :deep :value]))))))) (is (= 42 (get-in (:model r2) [:nested :deep :value])))))))
;; ============================================================================= ;; =============================================================================
;; VIEW FUNCTION EDGE CASES ;; VIEW FUNCTION EDGE CASES
+9 -9
View File
@@ -9,14 +9,14 @@
(testing "quit returns quit event" (testing "quit returns quit event"
(is (= {:type :quit} (ev/quit))))) (is (= {:type :quit} (ev/quit)))))
(deftest delay-test (deftest delayed-event-test
(testing "delay creates delay event" (testing "delayed-event creates delayed-event event"
(is (= {:type :delay :ms 1000 :event {:type :tick}} (is (= {:type :delayed-event :ms 1000 :event {:type :tick}}
(ev/delay 1000 {:type :tick})))) (ev/delayed-event 1000 {:type :tick}))))
(testing "delay with different ms values" (testing "delayed-event with different ms values"
(is (= 0 (:ms (ev/delay 0 {:type :x})))) (is (= 0 (:ms (ev/delayed-event 0 {:type :x}))))
(is (= 5000 (:ms (ev/delay 5000 {:type :x})))))) (is (= 5000 (:ms (ev/delayed-event 5000 {:type :x}))))))
(deftest shell-test (deftest shell-test
(testing "shell creates shell event with vector cmd" (testing "shell creates shell event with vector cmd"
@@ -124,11 +124,11 @@
(testing "can compose multiple event constructors" (testing "can compose multiple event constructors"
(let [result (ev/batch (let [result (ev/batch
(ev/shell ["git" "status"] {:type :status}) (ev/shell ["git" "status"] {:type :status})
(ev/delay 1000 {:type :refresh}))] (ev/delayed-event 1000 {:type :refresh}))]
(is (= :batch (:type result))) (is (= :batch (:type result)))
(is (= 2 (count (:events result)))) (is (= 2 (count (:events result))))
(is (= :shell (:type (first (:events result))))) (is (= :shell (:type (first (:events result)))))
(is (= :delay (:type (second (:events result))))))) (is (= :delayed-event (:type (second (:events result)))))))
(testing "can nest batch in sequential" (testing "can nest batch in sequential"
(let [result (ev/sequential (let [result (ev/sequential
+246 -232
View File
@@ -4,6 +4,7 @@
(:require [clojure.test :refer [deftest testing is are]] (:require [clojure.test :refer [deftest testing is are]]
[clojure.string :as str] [clojure.string :as str]
[tui.core :as tui] [tui.core :as tui]
[tui.events :as ev]
[tui.render :as render])) [tui.render :as render]))
;; ============================================================================= ;; =============================================================================
@@ -18,43 +19,43 @@
(deftest counter-update-all-keys-test (deftest counter-update-all-keys-test
(testing "counter responds to all documented keys" (testing "counter responds to all documented keys"
(let [update-fn (fn [model msg] (let [update-fn (fn [{:keys [model event]}]
(cond (cond
(or (tui/key= msg "q") (or (ev/key= event \q)
(tui/key= msg [:ctrl \c])) (ev/key= event \c #{:ctrl}))
[model tui/quit] {:model model :events [(ev/quit)]}
(or (tui/key= msg :up) (or (ev/key= event :up)
(tui/key= msg "k")) (ev/key= event \k))
[(update model :count inc) nil] {:model (update model :count inc)}
(or (tui/key= msg :down) (or (ev/key= event :down)
(tui/key= msg "j")) (ev/key= event \j))
[(update model :count dec) nil] {:model (update model :count dec)}
(tui/key= msg "r") (ev/key= event \r)
[(assoc model :count 0) nil] {:model (assoc model :count 0)}
:else :else
[model nil]))] {:model model}))]
;; All increment keys ;; All increment keys
(are [msg] (= 1 (:count (first (update-fn {:count 0} msg)))) (are [event] (= 1 (:count (:model (update-fn {:model {:count 0} :event event}))))
[:key :up] {:type :key :key :up}
[:key {:char \k}]) {:type :key :key \k})
;; All decrement keys ;; All decrement keys
(are [msg] (= -1 (:count (first (update-fn {:count 0} msg)))) (are [event] (= -1 (:count (:model (update-fn {:model {:count 0} :event event}))))
[:key :down] {:type :key :key :down}
[:key {:char \j}]) {:type :key :key \j})
;; All quit keys ;; All quit keys
(are [msg] (= tui/quit (second (update-fn {:count 0} msg))) (are [event] (= [(ev/quit)] (:events (update-fn {:model {:count 0} :event event})))
[:key {:char \q}] {:type :key :key \q}
[:key {:ctrl true :char \c}]) {:type :key :key \c :modifiers #{:ctrl}})
;; Reset key ;; Reset key
(is (= 0 (:count (first (update-fn {:count 42} [:key {:char \r}])))))))) (is (= 0 (:count (:model (update-fn {:model {:count 42} :event {:type :key :key \r}}))))))))
(deftest counter-view-color-logic-test (deftest counter-view-color-logic-test
(testing "counter view shows correct colors based on count" (testing "counter view shows correct colors based on count"
@@ -125,66 +126,72 @@
(deftest timer-tick-countdown-test (deftest timer-tick-countdown-test
(testing "timer tick decrements and reaches zero" (testing "timer tick decrements and reaches zero"
(let [update-fn (fn [{:keys [seconds running] :as model} msg] (let [update-fn (fn [{:keys [model event]}]
(cond (if (= (:type event) :timer-tick)
(= msg :timer-tick) (if (:running model)
(if running (let [new-seconds (dec (:seconds model))]
(let [new-seconds (dec seconds)]
(if (<= new-seconds 0) (if (<= new-seconds 0)
[(assoc model :seconds 0 :done true :running false) nil] {:model (assoc model :seconds 0 :done true :running false)}
[(assoc model :seconds new-seconds) (tui/after 1000 :timer-tick)])) {:model (assoc model :seconds new-seconds)
[model nil]) :events [(ev/delayed-event 1000 {:type :timer-tick})]}))
:else [model nil]))] {:model model})
{:model model}))]
;; Normal tick ;; Normal tick
(let [[m1 c1] (update-fn {:seconds 10 :running true :done false} :timer-tick)] (let [result (update-fn {:model {:seconds 10 :running true :done false}
(is (= 9 (:seconds m1))) :event {:type :timer-tick}})]
(is (fn? c1))) (is (= 9 (:seconds (:model result))))
(is (= 1 (count (:events result)))))
;; Tick to zero ;; Tick to zero
(let [[m1 c1] (update-fn {:seconds 1 :running true :done false} :timer-tick)] (let [result (update-fn {:model {:seconds 1 :running true :done false}
(is (= 0 (:seconds m1))) :event {:type :timer-tick}})]
(is (true? (:done m1))) (is (= 0 (:seconds (:model result))))
(is (false? (:running m1))) (is (true? (:done (:model result))))
(is (nil? c1))) (is (false? (:running (:model result))))
(is (nil? (:events result))))
;; Tick when paused does nothing ;; Tick when paused does nothing
(let [[m1 c1] (update-fn {:seconds 5 :running false :done false} :timer-tick)] (let [result (update-fn {:model {:seconds 5 :running false :done false}
(is (= 5 (:seconds m1))) :event {:type :timer-tick}})]
(is (nil? c1)))))) (is (= 5 (:seconds (:model result))))
(is (nil? (:events result)))))))
(deftest timer-pause-resume-test (deftest timer-pause-resume-test
(testing "timer pause/resume with space key" (testing "timer pause/resume with space key"
(let [update-fn (fn [{:keys [running] :as model} msg] (let [update-fn (fn [{:keys [model event]}]
(if (tui/key= msg " ") (if (ev/key= event \space)
(let [new-running (not running)] (let [new-running (not (:running model))]
[(assoc model :running new-running) {:model (assoc model :running new-running)
(when new-running (tui/after 1000 :timer-tick))]) :events (when new-running [(ev/delayed-event 1000 {:type :timer-tick})])})
[model nil]))] {:model model}))]
;; Pause (running -> not running) ;; Pause (running -> not running)
(let [[m1 c1] (update-fn {:seconds 5 :running true} [:key {:char \space}])] (let [result (update-fn {:model {:seconds 5 :running true}
(is (false? (:running m1))) :event {:type :key :key \space}})]
(is (nil? c1))) (is (false? (:running (:model result))))
(is (nil? (:events result))))
;; Resume (not running -> running) ;; Resume (not running -> running)
(let [[m1 c1] (update-fn {:seconds 5 :running false} [:key {:char \space}])] (let [result (update-fn {:model {:seconds 5 :running false}
(is (true? (:running m1))) :event {:type :key :key \space}})]
(is (fn? c1)))))) (is (true? (:running (:model result))))
(is (= 1 (count (:events result))))))))
(deftest timer-reset-test (deftest timer-reset-test
(testing "timer reset restores initial state" (testing "timer reset restores initial state"
(let [update-fn (fn [model msg] (let [update-fn (fn [{:keys [model event]}]
(if (tui/key= msg "r") (if (ev/key= event \r)
[(assoc model :seconds 10 :done false :running true) {:model (assoc model :seconds 10 :done false :running true)
(tui/after 1000 :timer-tick)] :events [(ev/delayed-event 1000 {:type :timer-tick})]}
[model nil]))] {:model model}))]
(let [[m1 c1] (update-fn {:seconds 0 :done true :running false} [:key {:char \r}])] (let [result (update-fn {:model {:seconds 0 :done true :running false}
(is (= 10 (:seconds m1))) :event {:type :key :key \r}})]
(is (false? (:done m1))) (is (= 10 (:seconds (:model result))))
(is (true? (:running m1))) (is (false? (:done (:model result))))
(is (fn? c1)))))) (is (true? (:running (:model result))))
(is (= 1 (count (:events result))))))))
(deftest timer-view-color-logic-test (deftest timer-view-color-logic-test
(testing "timer view shows correct colors" (testing "timer view shows correct colors"
@@ -239,68 +246,74 @@
(deftest spinner-tick-advances-frame-test (deftest spinner-tick-advances-frame-test
(testing "spinner tick advances frame when loading" (testing "spinner tick advances frame when loading"
(let [update-fn (fn [model msg] (let [update-fn (fn [{:keys [model event]}]
(if (= msg :spinner-frame) (if (= (:type event) :spinner-frame)
(if (:loading model) (if (:loading model)
[(update model :frame inc) (tui/after 80 :spinner-frame)] {:model (update model :frame inc)
[model nil]) :events [(ev/delayed-event 80 {:type :spinner-frame})]}
[model nil]))] {:model model})
{:model model}))]
;; Tick advances frame when loading ;; Tick advances frame when loading
(let [[m1 c1] (update-fn {:frame 0 :loading true} :spinner-frame)] (let [result (update-fn {:model {:frame 0 :loading true}
(is (= 1 (:frame m1))) :event {:type :spinner-frame}})]
(is (fn? c1))) (is (= 1 (:frame (:model result))))
(is (= 1 (count (:events result)))))
;; Tick does nothing when not loading ;; Tick does nothing when not loading
(let [[m1 c1] (update-fn {:frame 5 :loading false} :spinner-frame)] (let [result (update-fn {:model {:frame 5 :loading false}
(is (= 5 (:frame m1))) :event {:type :spinner-frame}})]
(is (nil? c1)))))) (is (= 5 (:frame (:model result))))
(is (nil? (:events result)))))))
(deftest spinner-style-switching-test (deftest spinner-style-switching-test
(testing "spinner tab key cycles through styles" (testing "spinner tab key cycles through styles"
(let [styles (keys spinner-styles) (let [styles (vec (keys spinner-styles))
update-fn (fn [{:keys [style-idx] :as model} msg] update-fn (fn [{:keys [model event]}]
(if (tui/key= msg :tab) (if (ev/key= event :tab)
(let [new-idx (mod (inc style-idx) (count styles))] (let [new-idx (mod (inc (:style-idx model)) (count styles))]
[(assoc model {:model (assoc model
:style-idx new-idx :style-idx new-idx
:style (nth styles new-idx)) :style (nth styles new-idx))})
nil]) {:model model}))]
[model nil]))]
;; Tab advances style ;; Tab advances style
(let [[m1 _] (update-fn {:style-idx 0 :style (first styles)} [:key :tab])] (let [result (update-fn {:model {:style-idx 0 :style (first styles)}
(is (= 1 (:style-idx m1)))) :event {:type :key :key :tab}})]
(is (= 1 (:style-idx (:model result)))))
;; Tab wraps around ;; Tab wraps around
(let [last-idx (dec (count styles)) (let [last-idx (dec (count styles))
[m1 _] (update-fn {:style-idx last-idx :style (last styles)} [:key :tab])] result (update-fn {:model {:style-idx last-idx :style (last styles)}
(is (= 0 (:style-idx m1))))))) :event {:type :key :key :tab}})]
(is (= 0 (:style-idx (:model result))))))))
(deftest spinner-completion-test (deftest spinner-completion-test
(testing "spinner space key completes loading" (testing "spinner space key completes loading"
(let [update-fn (fn [model msg] (let [update-fn (fn [{:keys [model event]}]
(if (tui/key= msg " ") (if (ev/key= event \space)
[(assoc model :loading false :message "Done!") nil] {:model (assoc model :loading false :message "Done!")}
[model nil]))] {:model model}))]
(let [[m1 _] (update-fn {:loading true :message "Loading..."} [:key {:char \space}])] (let [result (update-fn {:model {:loading true :message "Loading..."}
(is (false? (:loading m1))) :event {:type :key :key \space}})]
(is (= "Done!" (:message m1))))))) (is (false? (:loading (:model result))))
(is (= "Done!" (:message (:model result))))))))
(deftest spinner-restart-test (deftest spinner-restart-test
(testing "spinner r key restarts animation" (testing "spinner r key restarts animation"
(let [update-fn (fn [model msg] (let [update-fn (fn [{:keys [model event]}]
(if (tui/key= msg "r") (if (ev/key= event \r)
[(assoc model :loading true :frame 0 :message "Loading...") {:model (assoc model :loading true :frame 0 :message "Loading...")
(tui/after 80 :spinner-frame)] :events [(ev/delayed-event 80 {:type :spinner-frame})]}
[model nil]))] {:model model}))]
(let [[m1 c1] (update-fn {:loading false :frame 100 :message "Done!"} [:key {:char \r}])] (let [result (update-fn {:model {:loading false :frame 100 :message "Done!"}
(is (true? (:loading m1))) :event {:type :key :key \r}})]
(is (= 0 (:frame m1))) (is (true? (:loading (:model result))))
(is (= "Loading..." (:message m1))) (is (= 0 (:frame (:model result))))
(is (fn? c1)))))) (is (= "Loading..." (:message (:model result))))
(is (= 1 (count (:events result))))))))
;; ============================================================================= ;; =============================================================================
;; LIST SELECTION EXAMPLE TESTS ;; LIST SELECTION EXAMPLE TESTS
@@ -320,25 +333,25 @@
(deftest list-selection-cursor-navigation-test (deftest list-selection-cursor-navigation-test
(testing "cursor navigation respects bounds" (testing "cursor navigation respects bounds"
(let [items ["A" "B" "C" "D" "E"] (let [items ["A" "B" "C" "D" "E"]
update-fn (fn [{:keys [cursor] :as model} msg] update-fn (fn [{:keys [model event]}]
(cond (cond
(or (tui/key= msg :up) (or (ev/key= event :up)
(tui/key= msg "k")) (ev/key= event \k))
[(update model :cursor #(max 0 (dec %))) nil] {:model (update model :cursor #(max 0 (dec %)))}
(or (tui/key= msg :down) (or (ev/key= event :down)
(tui/key= msg "j")) (ev/key= event \j))
[(update model :cursor #(min (dec (count items)) (inc %))) nil] {:model (update model :cursor #(min (dec (count items)) (inc %)))}
:else [model nil]))] :else {:model model}))]
;; Move down through list ;; Move down through list
(let [m0 {:cursor 0} (let [m0 {:cursor 0}
[m1 _] (update-fn m0 [:key :down]) m1 (:model (update-fn {:model m0 :event {:type :key :key :down}}))
[m2 _] (update-fn m1 [:key :down]) m2 (:model (update-fn {:model m1 :event {:type :key :key :down}}))
[m3 _] (update-fn m2 [:key :down]) m3 (:model (update-fn {:model m2 :event {:type :key :key :down}}))
[m4 _] (update-fn m3 [:key :down]) m4 (:model (update-fn {:model m3 :event {:type :key :key :down}}))
[m5 _] (update-fn m4 [:key :down])] ; Should stop at 4 m5 (:model (update-fn {:model m4 :event {:type :key :key :down}}))]
(is (= 1 (:cursor m1))) (is (= 1 (:cursor m1)))
(is (= 2 (:cursor m2))) (is (= 2 (:cursor m2)))
(is (= 3 (:cursor m3))) (is (= 3 (:cursor m3)))
@@ -346,47 +359,49 @@
(is (= 4 (:cursor m5)))) ; Clamped at max (is (= 4 (:cursor m5)))) ; Clamped at max
;; Move up from top ;; Move up from top
(let [[m1 _] (update-fn {:cursor 0} [:key :up])] (let [result (update-fn {:model {:cursor 0} :event {:type :key :key :up}})]
(is (= 0 (:cursor m1))))))) ; Clamped at 0 (is (= 0 (:cursor (:model result)))))))) ; Clamped at 0
(deftest list-selection-toggle-test (deftest list-selection-toggle-test
(testing "space toggles selection" (testing "space toggles selection"
(let [update-fn (fn [{:keys [cursor] :as model} msg] (let [update-fn (fn [{:keys [model event]}]
(if (tui/key= msg " ") (if (ev/key= event \space)
[(update model :selected {:model (update model :selected
#(if (contains? % cursor) #(let [cursor (:cursor model)]
(if (contains? % cursor)
(disj % cursor) (disj % cursor)
(conj % cursor))) (conj % cursor))))}
nil] {:model model}))]
[model nil]))]
;; Select item ;; Select item
(let [[m1 _] (update-fn {:cursor 0 :selected #{}} [:key {:char \space}])] (let [result (update-fn {:model {:cursor 0 :selected #{}}
(is (= #{0} (:selected m1)))) :event {:type :key :key \space}})]
(is (= #{0} (:selected (:model result)))))
;; Select multiple items ;; Select multiple items
(let [m0 {:cursor 0 :selected #{}} (let [m0 {:cursor 0 :selected #{}}
[m1 _] (update-fn m0 [:key {:char \space}]) m1 (:model (update-fn {:model m0 :event {:type :key :key \space}}))
m1' (assoc m1 :cursor 2) m2 (:model (update-fn {:model (assoc m1 :cursor 2) :event {:type :key :key \space}}))
[m2 _] (update-fn m1' [:key {:char \space}]) m3 (:model (update-fn {:model (assoc m2 :cursor 4) :event {:type :key :key \space}}))]
m2' (assoc m2 :cursor 4)
[m3 _] (update-fn m2' [:key {:char \space}])]
(is (= #{0 2 4} (:selected m3)))) (is (= #{0 2 4} (:selected m3))))
;; Deselect item ;; Deselect item
(let [[m1 _] (update-fn {:cursor 1 :selected #{1 2}} [:key {:char \space}])] (let [result (update-fn {:model {:cursor 1 :selected #{1 2}}
(is (= #{2} (:selected m1))))))) :event {:type :key :key \space}})]
(is (= #{2} (:selected (:model result))))))))
(deftest list-selection-submit-test (deftest list-selection-submit-test
(testing "enter submits selection" (testing "enter submits selection"
(let [update-fn (fn [model msg] (let [update-fn (fn [{:keys [model event]}]
(if (tui/key= msg :enter) (if (ev/key= event :enter)
[(assoc model :submitted true) tui/quit] {:model (assoc model :submitted true)
[model nil]))] :events [(ev/quit)]}
{:model model}))]
(let [[m1 c1] (update-fn {:selected #{0 2} :submitted false} [:key :enter])] (let [result (update-fn {:model {:selected #{0 2} :submitted false}
(is (true? (:submitted m1))) :event {:type :key :key :enter}})]
(is (= tui/quit c1)))))) (is (true? (:submitted (:model result))))
(is (= [(ev/quit)] (:events result)))))))
(deftest list-selection-view-item-count-test (deftest list-selection-view-item-count-test
(testing "view shows correct item count" (testing "view shows correct item count"
@@ -416,86 +431,93 @@
(deftest views-menu-navigation-test (deftest views-menu-navigation-test
(testing "menu view cursor navigation" (testing "menu view cursor navigation"
(let [items [{:name "A"} {:name "B"} {:name "C"} {:name "D"}] (let [items [{:name "A"} {:name "B"} {:name "C"} {:name "D"}]
update-fn (fn [{:keys [cursor] :as model} msg] update-fn (fn [{:keys [model event]}]
(cond (cond
(or (tui/key= msg :up) (or (ev/key= event :up)
(tui/key= msg "k")) (ev/key= event \k))
[(update model :cursor #(max 0 (dec %))) nil] {:model (update model :cursor #(max 0 (dec %)))}
(or (tui/key= msg :down) (or (ev/key= event :down)
(tui/key= msg "j")) (ev/key= event \j))
[(update model :cursor #(min (dec (count items)) (inc %))) nil] {:model (update model :cursor #(min (dec (count items)) (inc %)))}
:else [model nil]))] :else {:model model}))]
;; Navigate down ;; Navigate down
(let [[m1 _] (update-fn {:cursor 0} [:key {:char \j}])] (let [result (update-fn {:model {:cursor 0} :event {:type :key :key \j}})]
(is (= 1 (:cursor m1)))) (is (= 1 (:cursor (:model result)))))
;; Navigate up ;; Navigate up
(let [[m1 _] (update-fn {:cursor 2} [:key {:char \k}])] (let [result (update-fn {:model {:cursor 2} :event {:type :key :key \k}})]
(is (= 1 (:cursor m1))))))) (is (= 1 (:cursor (:model result))))))))
(deftest views-state-transitions-test (deftest views-state-transitions-test
(testing "all view state transitions" (testing "all view state transitions"
(let [items [{:name "Profile"} {:name "Settings"}] (let [items [{:name "Profile"} {:name "Settings"}]
update-fn (fn [{:keys [view cursor] :as model} msg] update-fn (fn [{:keys [model event]}]
(case view (case (:view model)
:menu :menu
(cond (cond
(tui/key= msg :enter) (ev/key= event :enter)
[(assoc model :view :detail :selected (nth items cursor)) nil] {:model (assoc model :view :detail :selected (nth items (:cursor model)))}
(tui/key= msg "q") (ev/key= event \q)
[model tui/quit] {:model model :events [(ev/quit)]}
:else [model nil]) :else {:model model})
:detail :detail
(cond (cond
(or (tui/key= msg :escape) (or (ev/key= event :escape)
(tui/key= msg "b")) (ev/key= event \b))
[(assoc model :view :menu :selected nil) nil] {:model (assoc model :view :menu :selected nil)}
(tui/key= msg "q") (ev/key= event \q)
[(assoc model :view :confirm) nil] {:model (assoc model :view :confirm)}
:else [model nil]) :else {:model model})
:confirm :confirm
(cond (cond
(tui/key= msg "y") (ev/key= event \y)
[model tui/quit] {:model model :events [(ev/quit)]}
(or (tui/key= msg "n") (or (ev/key= event \n)
(tui/key= msg :escape)) (ev/key= event :escape))
[(assoc model :view :detail) nil] {:model (assoc model :view :detail)}
:else [model nil])))] :else {:model model})))]
;; Menu -> Detail via enter ;; Menu -> Detail via enter
(let [[m1 _] (update-fn {:view :menu :cursor 0 :items items} [:key :enter])] (let [result (update-fn {:model {:view :menu :cursor 0 :items items}
(is (= :detail (:view m1))) :event {:type :key :key :enter}})]
(is (= "Profile" (:name (:selected m1))))) (is (= :detail (:view (:model result))))
(is (= "Profile" (:name (:selected (:model result))))))
;; Detail -> Menu via escape ;; Detail -> Menu via escape
(let [[m1 _] (update-fn {:view :detail :selected {:name "X"}} [:key :escape])] (let [result (update-fn {:model {:view :detail :selected {:name "X"}}
(is (= :menu (:view m1))) :event {:type :key :key :escape}})]
(is (nil? (:selected m1)))) (is (= :menu (:view (:model result))))
(is (nil? (:selected (:model result)))))
;; Detail -> Menu via b ;; Detail -> Menu via b
(let [[m1 _] (update-fn {:view :detail :selected {:name "X"}} [:key {:char \b}])] (let [result (update-fn {:model {:view :detail :selected {:name "X"}}
(is (= :menu (:view m1)))) :event {:type :key :key \b}})]
(is (= :menu (:view (:model result)))))
;; Detail -> Confirm via q ;; Detail -> Confirm via q
(let [[m1 _] (update-fn {:view :detail} [:key {:char \q}])] (let [result (update-fn {:model {:view :detail}
(is (= :confirm (:view m1)))) :event {:type :key :key \q}})]
(is (= :confirm (:view (:model result)))))
;; Confirm -> Quit via y ;; Confirm -> Quit via y
(let [[_ c1] (update-fn {:view :confirm} [:key {:char \y}])] (let [result (update-fn {:model {:view :confirm}
(is (= tui/quit c1))) :event {:type :key :key \y}})]
(is (= [(ev/quit)] (:events result))))
;; Confirm -> Detail via n ;; Confirm -> Detail via n
(let [[m1 _] (update-fn {:view :confirm} [:key {:char \n}])] (let [result (update-fn {:model {:view :confirm}
(is (= :detail (:view m1)))) :event {:type :key :key \n}})]
(is (= :detail (:view (:model result)))))
;; Confirm -> Detail via escape ;; Confirm -> Detail via escape
(let [[m1 _] (update-fn {:view :confirm} [:key :escape])] (let [result (update-fn {:model {:view :confirm}
(is (= :detail (:view m1))))))) :event {:type :key :key :escape}})]
(is (= :detail (:view (:model result))))))))
;; ============================================================================= ;; =============================================================================
;; HTTP EXAMPLE TESTS ;; HTTP EXAMPLE TESTS
@@ -514,66 +536,58 @@
(deftest http-state-machine-test (deftest http-state-machine-test
(testing "http state transitions" (testing "http state transitions"
(let [update-fn (fn [{:keys [state url] :as model} msg] (let [update-fn (fn [{:keys [model event]}]
(cond (cond
;; Start request ;; Start request
(and (= state :idle) (and (= (:state model) :idle)
(tui/key= msg :enter)) (ev/key= event :enter))
[(assoc model :state :loading) {:model (assoc model :state :loading)
(fn [] [:http-success 200])] :events [(ev/shell ["curl" "-s" (:url model)] {:type :http-result})]}
;; Reset ;; Reset
(tui/key= msg "r") (ev/key= event \r)
[(assoc model :state :idle :status nil :error nil) nil] {:model (assoc model :state :idle :status nil :error nil)}
;; HTTP success ;; HTTP result
(= (first msg) :http-success) (= (:type event) :http-result)
[(assoc model :state :success :status (second msg)) nil] (if (get-in event [:result :success])
{:model (assoc model :state :success :status 200)}
;; HTTP error {:model (assoc model :state :error :error (get-in event [:result :err]))})
(= (first msg) :http-error)
[(assoc model :state :error :error (second msg)) nil]
:else :else
[model nil]))] {:model model}))]
;; Idle -> Loading via enter ;; Idle -> Loading via enter
(let [[m1 c1] (update-fn {:state :idle :url "http://test.com"} [:key :enter])] (let [result (update-fn {:model {:state :idle :url "http://test.com"}
(is (= :loading (:state m1))) :event {:type :key :key :enter}})]
(is (fn? c1))) (is (= :loading (:state (:model result))))
(is (= 1 (count (:events result)))))
;; Enter ignored when not idle ;; Enter ignored when not idle
(let [[m1 c1] (update-fn {:state :loading} [:key :enter])] (let [result (update-fn {:model {:state :loading}
(is (= :loading (:state m1))) :event {:type :key :key :enter}})]
(is (nil? c1))) (is (= :loading (:state (:model result))))
(is (nil? (:events result))))
;; Loading -> Success ;; Loading -> Success
(let [[m1 _] (update-fn {:state :loading} [:http-success 200])] (let [result (update-fn {:model {:state :loading}
(is (= :success (:state m1))) :event {:type :http-result :result {:success true :out "200"}}})]
(is (= 200 (:status m1)))) (is (= :success (:state (:model result))))
(is (= 200 (:status (:model result)))))
;; Loading -> Error ;; Loading -> Error
(let [[m1 _] (update-fn {:state :loading} [:http-error "Connection refused"])] (let [result (update-fn {:model {:state :loading}
(is (= :error (:state m1))) :event {:type :http-result :result {:success false :err "Connection refused"}}})]
(is (= "Connection refused" (:error m1)))) (is (= :error (:state (:model result))))
(is (= "Connection refused" (:error (:model result)))))
;; Reset from any state ;; Reset from any state
(doseq [state [:idle :loading :success :error]] (doseq [state [:idle :loading :success :error]]
(let [[m1 _] (update-fn {:state state :status 200 :error "err"} [:key {:char \r}])] (let [result (update-fn {:model {:state state :status 200 :error "err"}
(is (= :idle (:state m1))) :event {:type :key :key \r}})]
(is (nil? (:status m1))) (is (= :idle (:state (:model result))))
(is (nil? (:error m1)))))))) (is (nil? (:status (:model result))))
(is (nil? (:error (:model result)))))))))
(deftest http-fetch-command-test
(testing "fetch command creates async function"
(let [fetch-url (fn [url]
(fn []
;; Simulate success
[:http-success 200]))]
(let [cmd (fetch-url "https://test.com")]
(is (fn? cmd))
(is (= [:http-success 200] (cmd)))))))
(deftest http-view-states-test (deftest http-view-states-test
(testing "http view renders different states" (testing "http view renders different states"