From 8c7cb241714939a78655460354d2337db5ff6001 Mon Sep 17 00:00:00 2001 From: Adam Jeniski Date: Thu, 22 Jan 2026 22:22:19 -0500 Subject: [PATCH] simplify. remove tick --- .gitignore | 1 + CLAUDE.md | 4 +-- examples/spinner.clj | 10 +++--- examples/timer.clj | 12 +++---- src/tui/core.clj | 23 ++++++------- test/tui/api_test.clj | 65 +++++++++++++++++++++--------------- test/tui/core_test.clj | 50 +++++++++++++++++++-------- test/tui/edge_cases_test.clj | 22 ++++++++---- test/tui/examples_test.clj | 34 +++++++++---------- 9 files changed, 133 insertions(+), 88 deletions(-) diff --git a/.gitignore b/.gitignore index bd04223..bf0a70b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .cpcache/ +CLAUDE.local.md diff --git a/CLAUDE.md b/CLAUDE.md index f4995fa..a1033f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ View (hiccup) → Render (ANSI string) → Terminal (raw mode I/O) ### Core Modules -- **tui.core** - Main runtime with core.async. Manages the event loop, executes commands (quit, tick, batch, seq), handles input via goroutines. +- **tui.core** - Main runtime with core.async. Manages the event loop, executes commands (quit, after, batch, seq), handles input via goroutines. - **tui.render** - Converts hiccup (`[:col [:text "hi"]]`) to ANSI strings. Handles `:text`, `:row`, `:col`, `:box`, `:space` elements. - **tui.terminal** - Platform abstraction: raw mode via `stty`, reads from `/dev/tty`, renders by printing ANSI. - **tui.input** - Parses raw bytes into key messages (`[:key {:char \a}]`, `[:key :up]`, `[:key {:ctrl true :char \c}]`). @@ -56,7 +56,7 @@ View (hiccup) → Render (ANSI string) → Terminal (raw mode I/O) ### Command Types - `tui/quit` - Exit -- `(tui/tick ms)` - Send `:tick` after delay +- `(tui/after ms msg)` - Send `msg` after delay (for timers, animations) - `(tui/batch cmd1 cmd2)` - Parallel execution - `(tui/sequentially cmd1 cmd2)` - Sequential execution - `(fn [] msg)` - Custom async returning a message diff --git a/examples/spinner.clj b/examples/spinner.clj index 8717ea5..f8ba590 100644 --- a/examples/spinner.clj +++ b/examples/spinner.clj @@ -32,10 +32,10 @@ (tui/key= msg [:ctrl \c])) [model tui/quit] - ;; Tick - advance frame - (= (first msg) :tick) + ;; Spinner frame - advance animation + (= msg :spinner-frame) (if (:loading model) - [(update model :frame inc) (tui/tick 80)] + [(update model :frame inc) (tui/after 80 :spinner-frame)] [model nil]) ;; Space - simulate completion @@ -53,7 +53,7 @@ ;; r - restart (tui/key= msg "r") [(assoc model :loading true :frame 0 :message "Loading...") - (tui/tick 80)] + (tui/after 80 :spinner-frame)] :else [model nil])) @@ -86,5 +86,5 @@ (tui/run {:init initial-model :update update-model :view view - :init-cmd (tui/tick 80)}) + :init-cmd (tui/after 80 :spinner-frame)}) (println "Spinner demo finished.")) diff --git a/examples/timer.clj b/examples/timer.clj index c0a31d4..58164e9 100644 --- a/examples/timer.clj +++ b/examples/timer.clj @@ -17,27 +17,27 @@ (tui/key= msg [:ctrl \c])) [model tui/quit] - ;; Tick - decrement timer - (= (first msg) :tick) + ;; Timer tick - decrement timer + (= msg :timer-tick) (if (:running model) (let [new-seconds (dec (:seconds model))] (if (<= new-seconds 0) ;; Timer done [(assoc model :seconds 0 :done true :running false) nil] ;; Continue countdown - [(assoc model :seconds new-seconds) (tui/tick 1000)])) + [(assoc model :seconds new-seconds) (tui/after 1000 :timer-tick)])) [model nil]) ;; Space - pause/resume (tui/key= msg " ") (let [new-running (not (:running model))] [(assoc model :running new-running) - (when new-running (tui/tick 1000))]) + (when new-running (tui/after 1000 :timer-tick))]) ;; r - reset (tui/key= msg "r") [(assoc model :seconds 10 :done false :running true) - (tui/tick 1000)] + (tui/after 1000 :timer-tick)] :else [model nil])) @@ -76,6 +76,6 @@ (let [final-model (tui/run {:init initial-model :update update-model :view view - :init-cmd (tui/tick 1000)})] + :init-cmd (tui/after 1000 :timer-tick)})] (when (:done final-model) (println "Timer completed!")))) diff --git a/src/tui/core.clj b/src/tui/core.clj index 60c0f75..ff7448b 100644 --- a/src/tui/core.clj +++ b/src/tui/core.clj @@ -9,7 +9,6 @@ ;; === Command Types === ;; nil - no-op ;; [:quit] - exit program -;; [:tick ms] - send :tick message after ms ;; [:batch cmd1 cmd2 ...] - run commands in parallel ;; [:seq cmd1 cmd2 ...] - run commands sequentially ;; (fn [] msg) - arbitrary async function returning message @@ -17,10 +16,17 @@ ;; === Built-in Commands === (def quit [:quit]) -(defn tick - "Send a :tick message after ms milliseconds." - [ms] - [:tick ms]) +(defn after + "Returns a command that sends msg after ms milliseconds. + Use this for timers, animations, or delayed actions. + + Example: + (after 1000 [:timer-tick]) + (after 80 [:spinner-frame {:id 1}])" + [ms msg] + (fn [] + (Thread/sleep ms) + msg)) (defn batch "Run multiple commands in parallel." @@ -47,13 +53,6 @@ (= cmd [:quit]) (put! msg-chan [:quit]) - ;; Tick command - (and (vector? cmd) (= (first cmd) :tick)) - (let [ms (second cmd)] - (go - (! msg-chan [:tick (System/currentTimeMillis)]))) - ;; Batch - run all in parallel (and (vector? cmd) (= (first cmd) :batch)) (doseq [c (rest cmd)] diff --git a/test/tui/api_test.clj b/test/tui/api_test.clj index 84464dd..c8b6780 100644 --- a/test/tui/api_test.clj +++ b/test/tui/api_test.clj @@ -102,40 +102,53 @@ (is (vector? tui/quit)) (is (= :quit (first tui/quit))))) -(deftest tick-command-test - (testing "from timer: tick with 1000ms" - (is (= [:tick 1000] (tui/tick 1000)))) +(deftest after-command-test + (testing "from timer: after creates function" + (let [cmd (tui/after 1000 :timer-tick)] + (is (fn? cmd)))) - (testing "from spinner: tick with 80ms" - (is (= [:tick 80] (tui/tick 80)))) + (testing "from spinner: after creates function" + (let [cmd (tui/after 80 :spinner-frame)] + (is (fn? cmd)))) - (testing "tick with various intervals" - (are [ms] (= [:tick ms] (tui/tick ms)) - 0 1 10 100 500 1000 5000 60000))) + (testing "after with zero delay returns message immediately" + (is (= :timer-tick ((tui/after 0 :timer-tick)))) + (is (= [:my-tick {:id 1}] ((tui/after 0 [:my-tick {:id 1}])))))) (deftest batch-command-test (testing "batch two commands" - (let [cmd (tui/batch (tui/tick 100) tui/quit)] - (is (= [:batch [:tick 100] [:quit]] cmd)))) + (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/tick 50) (tui/tick 100) tui/quit)] - (is (= [:batch [:tick 50] [:tick 100] [:quit]] cmd)))) + (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" - (is (= [:batch [:tick 100]] (tui/batch nil (tui/tick 100) nil))) + (let [cmd (tui/batch nil (tui/send-msg :msg1) nil)] + (is (= :batch (first cmd))) + (is (= 2 (count cmd)))) (is (= [:batch] (tui/batch nil nil nil)))) (testing "batch with single command" - (is (= [:batch tui/quit] (tui/batch tui/quit))))) + (is (= [:batch [:quit]] (tui/batch tui/quit))))) (deftest sequentially-command-test (testing "sequentially two commands" - (let [cmd (tui/sequentially (tui/tick 100) tui/quit)] - (is (= [:seq [:tick 100] [:quit]] cmd)))) + (let [cmd (tui/sequentially (tui/after 100 :tick1) tui/quit)] + (is (= :seq (first cmd))) + (is (= 3 (count cmd))) + (is (fn? (second cmd))) + (is (= [:quit] (last cmd))))) (testing "sequentially filters nil" - (is (= [:seq [:tick 100]] (tui/sequentially nil (tui/tick 100) nil)))) + (let [cmd (tui/sequentially nil (tui/send-msg :msg1) nil)] + (is (= :seq (first cmd))) + (is (= 2 (count cmd))))) (testing "sequentially with functions" (let [f (fn [] :msg) @@ -231,31 +244,31 @@ (testing "timer tick handling pattern" (let [update-fn (fn [{:keys [seconds running] :as model} msg] (cond - (= (first msg) :tick) + (= msg :timer-tick) (if running (let [new-seconds (dec seconds)] (if (<= new-seconds 0) [(assoc model :seconds 0 :done true :running false) nil] - [(assoc model :seconds new-seconds) (tui/tick 1000)])) + [(assoc model :seconds new-seconds) (tui/after 1000 :timer-tick)])) [model nil]) (tui/key= msg " ") (let [new-running (not running)] [(assoc model :running new-running) - (when new-running (tui/tick 1000))]) + (when new-running (tui/after 1000 :timer-tick))]) :else [model nil]))] ;; Test tick countdown (let [m0 {:seconds 3 :running true :done false} - [m1 c1] (update-fn m0 [:tick 123]) - [m2 c2] (update-fn m1 [:tick 123]) - [m3 c3] (update-fn m2 [:tick 123])] + [m1 c1] (update-fn m0 :timer-tick) + [m2 c2] (update-fn m1 :timer-tick) + [m3 c3] (update-fn m2 :timer-tick)] (is (= 2 (:seconds m1))) - (is (= [:tick 1000] c1)) + (is (fn? c1)) (is (= 1 (:seconds m2))) - (is (= [:tick 1000] c2)) + (is (fn? c2)) (is (= 0 (:seconds m3))) (is (:done m3)) (is (not (:running m3))) @@ -268,7 +281,7 @@ (is (not (:running m1))) (is (nil? c1)) (is (:running m2)) - (is (= [:tick 1000] c2)))))) + (is (fn? c2)))))) (deftest list-selection-update-pattern-test (testing "cursor navigation with bounds" diff --git a/test/tui/core_test.clj b/test/tui/core_test.clj index 1999a3a..b89d0a9 100644 --- a/test/tui/core_test.clj +++ b/test/tui/core_test.clj @@ -12,24 +12,46 @@ (testing "quit command is correct vector" (is (= [:quit] tui/quit)))) -(deftest tick-command-test - (testing "tick creates correct command" - (is (= [:tick 100] (tui/tick 100))) - (is (= [:tick 1000] (tui/tick 1000))))) +(deftest after-command-test + (testing "after creates a function command" + (let [cmd (tui/after 0 :my-tick)] + (is (fn? cmd)) + (is (= :my-tick (cmd))))) + + (testing "after can send any message type" + (is (= [:timer-tick {:id 1}] ((tui/after 0 [:timer-tick {:id 1}])))) + (is (= :simple-msg ((tui/after 0 :simple-msg))))) + + (testing "after with non-zero delay creates function" + ;; Don't invoke - these would sleep + (is (fn? (tui/after 100 :tick))) + (is (fn? (tui/after 1000 :tick))))) (deftest batch-command-test (testing "batch combines commands" - (is (= [:batch [:tick 100] [:quit]] (tui/batch (tui/tick 100) tui/quit)))) + (let [cmd (tui/batch (tui/send-msg :msg1) tui/quit)] + (is (vector? cmd)) + (is (= :batch (first cmd))) + (is (= 3 (count cmd))) ; [:batch fn [:quit]] + (is (= [:quit] (last cmd))))) (testing "batch filters nil commands" - (is (= [:batch [:tick 100]] (tui/batch nil (tui/tick 100) nil))))) + (let [cmd (tui/batch nil (tui/send-msg :msg1) nil)] + (is (= :batch (first cmd))) + (is (= 2 (count cmd)))))) (deftest sequentially-command-test (testing "sequentially creates seq command" - (is (= [:seq [:tick 100] [:quit]] (tui/sequentially (tui/tick 100) tui/quit)))) + (let [cmd (tui/sequentially (tui/send-msg :msg1) tui/quit)] + (is (vector? cmd)) + (is (= :seq (first cmd))) + (is (= 3 (count cmd))) + (is (= [:quit] (last cmd))))) (testing "sequentially filters nil commands" - (is (= [:seq [:tick 100]] (tui/sequentially nil (tui/tick 100) nil))))) + (let [cmd (tui/sequentially nil (tui/send-msg :msg1) nil)] + (is (= :seq (first cmd))) + (is (= 2 (count cmd)))))) (deftest send-msg-command-test (testing "send-msg creates function that returns message" @@ -102,10 +124,11 @@ (is (= [:quit] result))) (close! msg-chan)))) -(deftest execute-tick-command-test - (testing "tick command sends :tick message after delay" - (let [msg-chan (chan 1)] - (#'tui/execute-cmd! [:tick 50] msg-chan) +(deftest execute-after-command-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) @@ -115,8 +138,7 @@ (let [delayed (alt!! msg-chan ([v] v) (timeout 200) :timeout)] - (is (vector? delayed)) - (is (= :tick (first delayed)))) + (is (= :delayed-msg delayed))) (close! msg-chan)))) (deftest execute-function-command-test diff --git a/test/tui/edge_cases_test.clj b/test/tui/edge_cases_test.clj index 0e20d4e..abe801d 100644 --- a/test/tui/edge_cases_test.clj +++ b/test/tui/edge_cases_test.clj @@ -195,7 +195,7 @@ (is (= [:batch] (tui/batch)))) (testing "batch with many commands" - (let [cmd (tui/batch (tui/tick 1) (tui/tick 2) (tui/tick 3) (tui/tick 4) (tui/tick 5))] + (let [cmd (tui/batch (tui/after 1 :t1) (tui/after 2 :t2) (tui/after 3 :t3) (tui/after 4 :t4) (tui/after 5 :t5))] (is (= 6 (count cmd))) ; :batch + 5 commands (is (= :batch (first cmd)))))) @@ -209,12 +209,22 @@ (testing "sequentially with no arguments" (is (= [:seq] (tui/sequentially))))) -(deftest tick-edge-cases-test - (testing "tick with zero" - (is (= [:tick 0] (tui/tick 0)))) +(deftest after-edge-cases-test + (testing "after with zero delay" + (let [cmd (tui/after 0 :immediate)] + (is (fn? cmd)) + ;; Zero delay executes immediately + (is (= :immediate (cmd))))) - (testing "tick with very large value" - (is (= [:tick 999999999] (tui/tick 999999999))))) + (testing "after with various delays creates function" + ;; Don't invoke - just verify the function is created correctly + (is (fn? (tui/after 1 :t1))) + (is (fn? (tui/after 1000 :t2))) + (is (fn? (tui/after 999999999 :t3)))) + + (testing "after with complex message" + (let [cmd (tui/after 0 [:tick {:id 1 :data [1 2 3]}])] + (is (= [:tick {:id 1 :data [1 2 3]}] (cmd)))))) (deftest send-msg-edge-cases-test (testing "send-msg with nil" diff --git a/test/tui/examples_test.clj b/test/tui/examples_test.clj index 034c03c..ce0acc1 100644 --- a/test/tui/examples_test.clj +++ b/test/tui/examples_test.clj @@ -127,29 +127,29 @@ (testing "timer tick decrements and reaches zero" (let [update-fn (fn [{:keys [seconds running] :as model} msg] (cond - (= (first msg) :tick) + (= msg :timer-tick) (if running (let [new-seconds (dec seconds)] (if (<= new-seconds 0) [(assoc model :seconds 0 :done true :running false) nil] - [(assoc model :seconds new-seconds) (tui/tick 1000)])) + [(assoc model :seconds new-seconds) (tui/after 1000 :timer-tick)])) [model nil]) :else [model nil]))] ;; Normal tick - (let [[m1 c1] (update-fn {:seconds 10 :running true :done false} [:tick 123])] + (let [[m1 c1] (update-fn {:seconds 10 :running true :done false} :timer-tick)] (is (= 9 (:seconds m1))) - (is (= [:tick 1000] c1))) + (is (fn? c1))) ;; Tick to zero - (let [[m1 c1] (update-fn {:seconds 1 :running true :done false} [:tick 123])] + (let [[m1 c1] (update-fn {:seconds 1 :running true :done false} :timer-tick)] (is (= 0 (:seconds m1))) (is (true? (:done m1))) (is (false? (:running m1))) (is (nil? c1))) ;; Tick when paused does nothing - (let [[m1 c1] (update-fn {:seconds 5 :running false :done false} [:tick 123])] + (let [[m1 c1] (update-fn {:seconds 5 :running false :done false} :timer-tick)] (is (= 5 (:seconds m1))) (is (nil? c1)))))) @@ -159,7 +159,7 @@ (if (tui/key= msg " ") (let [new-running (not running)] [(assoc model :running new-running) - (when new-running (tui/tick 1000))]) + (when new-running (tui/after 1000 :timer-tick))]) [model nil]))] ;; Pause (running -> not running) @@ -170,21 +170,21 @@ ;; Resume (not running -> running) (let [[m1 c1] (update-fn {:seconds 5 :running false} [:key {:char \space}])] (is (true? (:running m1))) - (is (= [:tick 1000] c1)))))) + (is (fn? c1)))))) (deftest timer-reset-test (testing "timer reset restores initial state" (let [update-fn (fn [model msg] (if (tui/key= msg "r") [(assoc model :seconds 10 :done false :running true) - (tui/tick 1000)] + (tui/after 1000 :timer-tick)] [model nil]))] (let [[m1 c1] (update-fn {:seconds 0 :done true :running false} [:key {:char \r}])] (is (= 10 (:seconds m1))) (is (false? (:done m1))) (is (true? (:running m1))) - (is (= [:tick 1000] c1)))))) + (is (fn? c1)))))) (deftest timer-view-color-logic-test (testing "timer view shows correct colors" @@ -240,19 +240,19 @@ (deftest spinner-tick-advances-frame-test (testing "spinner tick advances frame when loading" (let [update-fn (fn [model msg] - (if (= (first msg) :tick) + (if (= msg :spinner-frame) (if (:loading model) - [(update model :frame inc) (tui/tick 80)] + [(update model :frame inc) (tui/after 80 :spinner-frame)] [model nil]) [model nil]))] ;; Tick advances frame when loading - (let [[m1 c1] (update-fn {:frame 0 :loading true} [:tick 123])] + (let [[m1 c1] (update-fn {:frame 0 :loading true} :spinner-frame)] (is (= 1 (:frame m1))) - (is (= [:tick 80] c1))) + (is (fn? c1))) ;; Tick does nothing when not loading - (let [[m1 c1] (update-fn {:frame 5 :loading false} [:tick 123])] + (let [[m1 c1] (update-fn {:frame 5 :loading false} :spinner-frame)] (is (= 5 (:frame m1))) (is (nil? c1)))))) @@ -293,14 +293,14 @@ (let [update-fn (fn [model msg] (if (tui/key= msg "r") [(assoc model :loading true :frame 0 :message "Loading...") - (tui/tick 80)] + (tui/after 80 :spinner-frame)] [model nil]))] (let [[m1 c1] (update-fn {:loading false :frame 100 :message "Done!"} [:key {:char \r}])] (is (true? (:loading m1))) (is (= 0 (:frame m1))) (is (= "Loading..." (:message m1))) - (is (= [:tick 80] c1)))))) + (is (fn? c1)))))) ;; ============================================================================= ;; LIST SELECTION EXAMPLE TESTS