From 0e40fe01d78001a481135cc8793ed1b34054318a Mon Sep 17 00:00:00 2001 From: Adam Jeniski Date: Thu, 22 Jan 2026 10:50:26 -0500 Subject: [PATCH] update docs --- CLAUDE.md | 11 +- README.md | 57 +++----- docs/api-reference.md | 68 +-------- docs/examples.md | 8 +- docs/getting-started.md | 37 ++--- examples/counter.clj | 2 +- examples/http.clj | 2 +- examples/list_selection.clj | 2 +- examples/spinner.clj | 2 +- examples/timer.clj | 2 +- examples/views.clj | 2 +- src/tui/core.clj | 16 +- src/tui/simple.clj | 75 ---------- test/tui/simple_test.clj | 281 ------------------------------------ 14 files changed, 57 insertions(+), 508 deletions(-) delete mode 100644 src/tui/simple.clj delete mode 100644 test/tui/simple_test.clj diff --git a/CLAUDE.md b/CLAUDE.md index 75b8fa2..f4995fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Clojure TUI framework inspired by Bubbletea (Go), using the Elm Architecture with Hiccup for views. Two runtimes: full async (`tui.core` with core.async) and simple sync (`tui.simple` for Babashka). +Clojure TUI framework inspired by Bubbletea (Go), using the Elm Architecture with Hiccup for views. Works with both Babashka and full Clojure. ## Commands @@ -15,7 +15,7 @@ bb test # List available examples bb examples -# Run examples (with Babashka - simple sync runtime) +# Run examples with Babashka (recommended - fast startup) bb counter bb timer bb list @@ -23,7 +23,7 @@ bb spinner bb views bb http -# Run examples with full Clojure (async support) +# Run examples with full Clojure clojure -A:dev -M -m examples.counter ``` @@ -38,8 +38,7 @@ View (hiccup) → Render (ANSI string) → Terminal (raw mode I/O) ### Core Modules -- **tui.core** - Full async runtime with core.async. Manages the event loop, executes commands (quit, tick, batch, seq), handles input via goroutines. -- **tui.simple** - Sync runtime for Babashka. Same API but no async commands. +- **tui.core** - Main runtime with core.async. Manages the event loop, executes commands (quit, tick, 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}]`). @@ -47,7 +46,7 @@ View (hiccup) → Render (ANSI string) → Terminal (raw mode I/O) ### Elm Architecture Flow -1. App provides `:init` (model), `:update` (fn [model msg] -> [new-model cmd]), `:view` (fn [model] -> hiccup) +1. App provides `:init` (model), `:update` (fn [model msg] -> [new-model cmd]), `:view` (fn [model size] -> hiccup) 2. Runtime renders initial view 3. Input loop reads keys, puts messages on channel 4. Update function processes messages, may return commands diff --git a/README.md b/README.md index 01fb158..25a6749 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,11 @@ A terminal user interface framework for Clojure, inspired by [Bubbletea](https:/ - **Elm Architecture** - Predictable state management with `init`, `update`, and `view` - **Hiccup Views** - Declarative UI with familiar Clojure syntax -- **Two Runtimes** - Full async (`tui.core`) or simple sync (`tui.simple` for Babashka) +- **Async Commands** - Timers, batched operations, and custom async functions - **Rich Styling** - Colors (16, 256, true color), bold, italic, underline, and more - **Layout System** - Rows, columns, and boxes with borders - **Input Handling** - Full keyboard support including arrows, function keys, and modifiers +- **Babashka Compatible** - Fast startup with Babashka or full Clojure ## Quick Start @@ -27,14 +28,14 @@ Add to your `deps.edn`: ```clojure (ns myapp.core - (:require [tui.simple :as tui])) + (:require [tui.core :as tui])) (defn update-fn [model msg] (if (tui/key= msg "q") [model tui/quit] [model nil])) -(defn view [model] +(defn view [model _size] [:col [:text {:fg :cyan :bold true} "Hello, TUI!"] [:text {:fg :gray} "Press q to quit"]]) @@ -54,7 +55,7 @@ Press q to quit ```clojure (ns myapp.counter - (:require [tui.simple :as tui])) + (:require [tui.core :as tui])) (defn update-fn [model msg] (cond @@ -64,7 +65,7 @@ Press q to quit (tui/key= msg "r") [0 nil] :else [model nil])) -(defn view [model] +(defn view [model _size] [:col [:box {:border :rounded :padding [0 2]} [:text {:fg :yellow :bold true} (str "Count: " model)]] @@ -139,9 +140,16 @@ Async HTTP requests with loading states. bb http ``` -### Running with Full Clojure +### Running Examples -For full async support (core.async), run with Clojure: +Run examples with Babashka (recommended for fast startup): + +```bash +bb counter +bb timer +``` + +Or with full Clojure: ```bash clojure -A:dev -M -m examples.counter @@ -167,37 +175,7 @@ The application follows the Elm Architecture: 1. **Model** - Your application state (any Clojure data structure) 2. **Update** - A pure function `(fn [model msg] [new-model cmd])` that handles messages -3. **View** - A pure function `(fn [model] hiccup)` that renders the UI - -## Two Runtimes - -### `tui.simple` - Synchronous (Babashka-compatible) - -Best for simple applications. No async support, but works with Babashka. - -```clojure -(require '[tui.simple :as tui]) - -(tui/run {:init model :update update-fn :view view-fn}) -``` - -### `tui.core` - Asynchronous (Full Clojure) - -Full-featured runtime with async commands like timers and batched operations. - -```clojure -(require '[tui.core :as tui]) - -(defn update-fn [model msg] - (case msg - :tick [(update model :seconds inc) (tui/tick 1000)] - [model nil])) - -(tui/run {:init {:seconds 0} - :update update-fn - :view view-fn - :init-cmd (tui/tick 1000)}) -``` +3. **View** - A pure function `(fn [model size] hiccup)` that renders the UI, where `size` is `{:width w :height h}` ## Hiccup View Elements @@ -236,8 +214,7 @@ Commands are returned from your `update` function to trigger side effects: ``` src/ tui/ - core.clj # Full async runtime (core.async) - simple.clj # Simple sync runtime (Babashka-compatible) + core.clj # Main runtime (Elm architecture + async commands) render.clj # Hiccup → ANSI terminal.clj # Raw mode, input/output input.clj # Key parsing diff --git a/docs/api-reference.md b/docs/api-reference.md index a78f47a..33d3c03 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -4,8 +4,7 @@ Complete API documentation for Clojure TUI. ## Table of Contents -- [tui.core](#tuicore) - Full async runtime -- [tui.simple](#tuisimple) - Sync runtime (Babashka) +- [tui.core](#tuicore) - Main runtime - [tui.render](#tuirender) - Hiccup rendering - [tui.input](#tuiinput) - Key input parsing - [tui.terminal](#tuiterminal) - Terminal control @@ -15,7 +14,7 @@ Complete API documentation for Clojure TUI. ## tui.core -Full-featured async runtime using core.async. Use this when you need timers, async commands, or background operations. +Main runtime using core.async. Provides timers, async commands, background operations, and responsive layout support. ### run @@ -31,7 +30,7 @@ Run a TUI application synchronously (blocks until quit). |-----|------|----------|-------------| | `:init` | any | Yes | Initial model value | | `:update` | function | Yes | `(fn [model msg] [new-model cmd])` | -| `:view` | function | Yes | `(fn [model] hiccup)` | +| `:view` | function | Yes | `(fn [model size] hiccup)` where size is `{:width w :height h}` | | `:init-cmd` | command | No | Initial command to execute | | `:fps` | integer | No | Frames per second (default: 60) | | `:alt-screen` | boolean | No | Use alternate screen (default: true) | @@ -48,7 +47,7 @@ Run a TUI application synchronously (blocks until quit). (if (tui/key= msg "q") [model tui/quit] [model nil])) - :view (fn [{:keys [count]}] + :view (fn [{:keys [count]} _size] [:text (str "Count: " count)]) :fps 30 :alt-screen true}) @@ -238,62 +237,6 @@ Re-exported from `tui.render`. Render hiccup to ANSI string. --- -## tui.simple - -Synchronous runtime compatible with Babashka. Same API as `tui.core` but without async commands. - -### run - -```clojure -(run options) -``` - -Run a TUI application synchronously. - -**Parameters:** - -| Key | Type | Required | Description | -|-----|------|----------|-------------| -| `:init` | any | Yes | Initial model value | -| `:update` | function | Yes | `(fn [model msg] [new-model cmd])` | -| `:view` | function | Yes | `(fn [model] hiccup)` | -| `:alt-screen` | boolean | No | Use alternate screen (default: true) | - -**Returns:** Final model value after quit - -**Example:** - -```clojure -(require '[tui.simple :as tui]) - -(tui/run {:init 0 - :update (fn [model msg] - (cond - (tui/key= msg "q") [model tui/quit] - (tui/key= msg "k") [(inc model) nil] - :else [model nil])) - :view (fn [count] - [:text (str "Count: " count)])}) -``` - -### quit - -Same as `tui.core/quit`. - -### key= - -Same as `tui.core/key=`. - -### key-str - -Same as `tui.core/key-str`. - -### render - -Re-exported from `tui.render`. - ---- - ## tui.render Converts hiccup data structures to ANSI-formatted strings. @@ -715,8 +658,7 @@ The function must: | Namespace | Purpose | |-----------|---------| -| `tui.core` | Full async runtime with all features | -| `tui.simple` | Sync runtime for Babashka | +| `tui.core` | Main runtime with Elm architecture and async commands | | `tui.render` | Hiccup to ANSI rendering | | `tui.input` | Key input parsing | | `tui.terminal` | Low-level terminal control | diff --git a/docs/examples.md b/docs/examples.md index aec371f..e8d86e0 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -5,7 +5,7 @@ Detailed walkthroughs of the example applications included with Clojure TUI. ## Running Examples ```bash -# With Babashka (simple sync runtime) +# With Babashka (recommended - fast startup) bb counter bb timer bb list @@ -13,7 +13,7 @@ bb spinner bb views bb http -# With Clojure (full async support) +# With full Clojure clojure -A:dev -M -m examples.counter clojure -A:dev -M -m examples.timer clojure -A:dev -M -m examples.list-selection @@ -263,7 +263,7 @@ A multi-select list demonstrating cursor navigation and selection. ```clojure (ns examples.list-selection - (:require [tui.simple :as tui])) + (:require [tui.core :as tui])) (def items ["Apple" "Banana" "Cherry" "Date" "Elderberry" @@ -519,7 +519,7 @@ A multi-view application demonstrating state machine navigation. ```clojure (ns examples.views - (:require [tui.simple :as tui])) + (:require [tui.core :as tui])) (def menu-items [{:id :profile :label "View Profile" :desc "See your user information"} diff --git a/docs/getting-started.md b/docs/getting-started.md index 9156223..976076f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -14,8 +14,7 @@ Add the library to your `deps.edn`: ### Requirements -- **Clojure 1.11+** for full async runtime (`tui.core`) -- **Babashka** for simple sync runtime (`tui.simple`) +- **Babashka** (recommended) or **Clojure 1.11+** - A terminal that supports ANSI escape codes (most modern terminals) ## Your First Application @@ -41,7 +40,7 @@ Create `src/myapp/core.clj`: ```clojure (ns myapp.core - (:require [tui.simple :as tui])) + (:require [tui.core :as tui])) ;; 1. Model - the application state (def initial-model @@ -58,8 +57,8 @@ Create `src/myapp/core.clj`: :else [model nil])) -;; 3. View - render the model as hiccup -(defn view [{:keys [message]}] +;; 3. View - render the model as hiccup (receives model and terminal size) +(defn view [{:keys [message]} _size] [:col [:text {:fg :cyan :bold true} message] [:space {:height 1}] @@ -120,7 +119,7 @@ Clojure TUI uses the [Elm Architecture](https://guide.elm-lang.org/architecture/ 2. **Update**: A pure function that takes the current model and a message, returning a vector of `[new-model command]`. -3. **View**: A pure function that takes the model and returns a hiccup data structure representing the UI. +3. **View**: A pure function that takes the model and terminal size, returning a hiccup data structure representing the UI. ### The Flow @@ -198,7 +197,7 @@ Let's build something more interactive: a counter. ```clojure (ns myapp.counter - (:require [tui.simple :as tui])) + (:require [tui.core :as tui])) (defn update-fn [model msg] (cond @@ -220,7 +219,7 @@ Let's build something more interactive: a counter. :else [model nil])) -(defn view [count] +(defn view [count _size] [:col [:box {:border :rounded :padding [0 2]} [:row @@ -315,7 +314,7 @@ Commands are returned as the second element of the update function's return vect :else [model nil])) -(defn view [{:keys [count done]}] +(defn view [{:keys [count done]} _size] [:col (if done [:text {:fg :green :bold true} "Time's up!"] @@ -329,22 +328,6 @@ Commands are returned as the second element of the update function's return vect :init-cmd (tui/tick 1000)})) ;; Start first tick ``` -## Choosing a Runtime - -### `tui.simple` - For Babashka and Simple Apps - -- Works with Babashka -- Synchronous (blocking) input -- No async commands (tick, batch, etc.) -- Lower resource usage - -### `tui.core` - For Complex Applications - -- Requires full Clojure with core.async -- Async input handling -- Full command support (tick, batch, sequentially) -- Better for animations and background tasks - ## Configuration Options The `run` function accepts these options: @@ -353,9 +336,9 @@ The `run` function accepts these options: |--------|-------------|---------| | `:init` | Initial model (required) | - | | `:update` | Update function (required) | - | -| `:view` | View function (required) | - | +| `:view` | View function `(fn [model size] hiccup)` (required) | - | | `:init-cmd` | Initial command to run | `nil` | -| `:fps` | Frames per second (core only) | `60` | +| `:fps` | Frames per second | `60` | | `:alt-screen` | Use alternate screen buffer | `true` | ### Alternate Screen diff --git a/examples/counter.clj b/examples/counter.clj index ba74e89..ce3a99d 100644 --- a/examples/counter.clj +++ b/examples/counter.clj @@ -33,7 +33,7 @@ [model nil])) ;; === View === -(defn view [{:keys [count]}] +(defn view [{:keys [count]} _size] [:col {:gap 1} [:box {:border :rounded :padding [0 1]} [:col diff --git a/examples/http.clj b/examples/http.clj index 51fd422..2103144 100644 --- a/examples/http.clj +++ b/examples/http.clj @@ -66,7 +66,7 @@ [model nil])) ;; === View === -(defn view [{:keys [state status error url]}] +(defn view [{:keys [state status error url]} _size] [:col {:gap 1} [:box {:border :rounded :padding [1 2]} [:col {:gap 1} diff --git a/examples/list_selection.clj b/examples/list_selection.clj index dbdca9b..5f237e3 100644 --- a/examples/list_selection.clj +++ b/examples/list_selection.clj @@ -45,7 +45,7 @@ [model nil])) ;; === View === -(defn view [{:keys [cursor items selected submitted]}] +(defn view [{:keys [cursor items selected submitted]} _size] (if submitted [:col [:text {:bold true :fg :green} "You selected:"] diff --git a/examples/spinner.clj b/examples/spinner.clj index 5309367..8717ea5 100644 --- a/examples/spinner.clj +++ b/examples/spinner.clj @@ -64,7 +64,7 @@ idx (mod frame (count frames))] (nth frames idx))) -(defn view [{:keys [loading message style] :as model}] +(defn view [{:keys [loading message style] :as model} _size] [:col {:gap 1} [:box {:border :rounded :padding [1 2]} [:col {:gap 1} diff --git a/examples/timer.clj b/examples/timer.clj index e0c1f94..c0a31d4 100644 --- a/examples/timer.clj +++ b/examples/timer.clj @@ -48,7 +48,7 @@ secs (mod seconds 60)] (format "%02d:%02d" mins secs))) -(defn view [{:keys [seconds running done]}] +(defn view [{:keys [seconds running done]} _size] [:col {:gap 1} [:box {:border :rounded :padding [1 2]} [:col diff --git a/examples/views.clj b/examples/views.clj index cadf261..c8265bc 100644 --- a/examples/views.clj +++ b/examples/views.clj @@ -102,7 +102,7 @@ [:text {:fg :green} "[y] Yes"] [:text {:fg :red} "[n] No"]]]]]) -(defn view [{:keys [view] :as model}] +(defn view [{:keys [view] :as model} _size] (case view :menu (menu-view model) :detail (detail-view model) diff --git a/src/tui/core.clj b/src/tui/core.clj index d21f447..60c0f75 100644 --- a/src/tui/core.clj +++ b/src/tui/core.clj @@ -101,7 +101,7 @@ Options: - :init - Initial model (required) - :update - (fn [model msg] [new-model cmd]) (required) - - :view - (fn [model] hiccup) (required) + - :view - (fn [model size] hiccup) where size is {:width w :height h} (required) - :init-cmd - Initial command to run - :fps - Target frames per second (default 60) - :alt-screen - Use alternate screen buffer (default true) @@ -128,8 +128,10 @@ (execute-cmd! init-cmd msg-chan)) ;; Initial render - (let [initial-view (render/render (view init))] - (term/render! initial-view)) + (let [size (term/get-terminal-size) + ctx {:available-height (:height size) + :available-width (:width size)}] + (term/render! (render/render (view init size) ctx))) ;; Main loop (loop [model init @@ -151,15 +153,17 @@ ;; Update model (let [[new-model cmd] (update model msg) - new-view (render/render (view new-model)) + size (term/get-terminal-size) + ctx {:available-height (:height size) + :available-width (:width size)} now (System/currentTimeMillis)] ;; Execute command (when cmd (execute-cmd! cmd msg-chan)) - ;; Render - (term/render! new-view) + ;; Render with context for flex layouts + (term/render! (render/render (view new-model size) ctx)) (recur new-model now)))))) diff --git a/src/tui/simple.clj b/src/tui/simple.clj deleted file mode 100644 index 613265d..0000000 --- a/src/tui/simple.clj +++ /dev/null @@ -1,75 +0,0 @@ -(ns tui.simple - "Simplified TUI runtime - no core.async, works with Babashka. - Synchronous event loop, no timers/async commands." - (:require [tui.terminal :as term] - [tui.input :as input] - [tui.render :as render] - [tui.ansi :as ansi])) - -;; === Commands === -(def quit [:quit]) - -;; === Key Matching === -(defn key= - "Check if message is a specific key." - [msg key-pattern] - (input/key-match? msg key-pattern)) - -(defn key-str - "Get string representation of key." - [msg] - (input/key->str msg)) - -;; === Simple Run Loop === -(defn run - "Run a TUI application (synchronous, no async commands). - - Options: - - :init - Initial model (required) - - :update - (fn [model msg] [new-model cmd]) (required) - - :view - (fn [model size] hiccup) where size is {:width w :height h} (required) - - :alt-screen - Use alternate screen buffer (default true) - - Returns the final model." - [{:keys [init update view alt-screen] - :or {alt-screen true}}] - - ;; Setup terminal - (term/raw-mode!) - (term/init-input!) - (when alt-screen (term/alt-screen!)) - (term/clear!) - - (try - ;; Initial render - (let [size (term/get-terminal-size) - ctx {:available-height (:height size) - :available-width (:width size)}] - (term/render! (render/render (view init size) ctx))) - - ;; Main loop - simple synchronous - (loop [model init] - (if-let [key-msg (input/read-key)] - (let [[new-model cmd] (update model key-msg) - size (term/get-terminal-size) - ctx {:available-height (:height size) - :available-width (:width size)}] - ;; Render with context for flex layouts - (term/render! (render/render (view new-model size) ctx)) - - ;; Check for quit - (if (= cmd [:quit]) - new-model - (recur new-model))) - (recur model))) - - (finally - ;; Cleanup - (when alt-screen (term/exit-alt-screen!)) - (term/restore!) - (term/close-input!) - (println) - (flush)))) - -;; Re-export render -(def render render/render) diff --git a/test/tui/simple_test.clj b/test/tui/simple_test.clj deleted file mode 100644 index feedbab..0000000 --- a/test/tui/simple_test.clj +++ /dev/null @@ -1,281 +0,0 @@ -(ns tui.simple-test - "Unit tests for the simple (synchronous) TUI runtime." - (:require [clojure.test :refer [deftest testing is]] - [tui.simple :as simple] - [tui.render :as render])) - -;; ============================================================================= -;; QUIT COMMAND TESTS -;; ============================================================================= - -(deftest quit-command-test - (testing "quit command is correct vector" - (is (= [:quit] simple/quit)) - (is (vector? simple/quit)) - (is (= :quit (first simple/quit))))) - -;; ============================================================================= -;; KEY MATCHING TESTS (same API as tui.core) -;; ============================================================================= - -(deftest key=-character-keys-test - (testing "matches single character keys" - (is (simple/key= [:key {:char \q}] "q")) - (is (simple/key= [:key {:char \a}] "a")) - (is (simple/key= [:key {:char \space}] " "))) - - (testing "does not match different characters" - (is (not (simple/key= [:key {:char \q}] "a"))) - (is (not (simple/key= [:key {:char \x}] "y"))))) - -(deftest key=-special-keys-test - (testing "matches special keys by keyword" - (is (simple/key= [:key :enter] :enter)) - (is (simple/key= [:key :escape] :escape)) - (is (simple/key= [:key :up] :up)) - (is (simple/key= [:key :down] :down)) - (is (simple/key= [:key :left] :left)) - (is (simple/key= [:key :right] :right)) - (is (simple/key= [:key :tab] :tab)) - (is (simple/key= [:key :backspace] :backspace)))) - -(deftest key=-ctrl-combos-test - (testing "matches ctrl+char combinations" - (is (simple/key= [:key {:ctrl true :char \c}] [:ctrl \c])) - (is (simple/key= [:key {:ctrl true :char \x}] [:ctrl \x]))) - - (testing "ctrl combo does not match plain char" - (is (not (simple/key= [:key {:ctrl true :char \c}] "c"))) - (is (not (simple/key= [:key {:char \c}] [:ctrl \c]))))) - -(deftest key=-alt-combos-test - (testing "matches alt+char combinations" - (is (simple/key= [:key {:alt true :char \x}] [:alt \x]))) - - (testing "alt combo does not match plain char" - (is (not (simple/key= [:key {:alt true :char \x}] "x"))))) - -(deftest key=-non-key-messages-test - (testing "returns nil for non-key messages" - (is (nil? (simple/key= [:tick 123] "q"))) - (is (nil? (simple/key= [:quit] :enter))) - (is (nil? (simple/key= nil "a"))))) - -;; ============================================================================= -;; KEY-STR TESTS -;; ============================================================================= - -(deftest key-str-test - (testing "converts character keys to strings" - (is (= "q" (simple/key-str [:key {:char \q}]))) - (is (= " " (simple/key-str [:key {:char \space}])))) - - (testing "converts special keys to strings" - (is (= "enter" (simple/key-str [:key :enter]))) - (is (= "escape" (simple/key-str [:key :escape]))) - (is (= "up" (simple/key-str [:key :up])))) - - (testing "converts modifier keys to strings" - (is (= "ctrl+c" (simple/key-str [:key {:ctrl true :char \c}]))) - (is (= "alt+x" (simple/key-str [:key {:alt true :char \x}])))) - - (testing "returns nil for non-key messages" - (is (nil? (simple/key-str [:tick 123]))) - (is (nil? (simple/key-str nil))))) - -;; ============================================================================= -;; RENDER RE-EXPORT TESTS -;; ============================================================================= - -(deftest render-reexport-test - (testing "simple/render is the same as render/render" - (is (= (render/render [:text "hello"]) - (simple/render [:text "hello"]))) - (is (= (render/render [:col "a" "b"]) - (simple/render [:col "a" "b"]))))) - -;; ============================================================================= -;; UPDATE FUNCTION CONTRACT TESTS (same as tui.core) -;; ============================================================================= - -(deftest simple-update-contract-test - (testing "update function returns [model cmd] tuple" - (let [update-fn (fn [model msg] - (cond - (simple/key= msg "q") [model simple/quit] - (simple/key= msg :up) [(update model :n inc) nil] - :else [model nil])) - model {:n 0}] - - ;; Quit returns command - (let [[new-model cmd] (update-fn model [:key {:char \q}])] - (is (= model new-model)) - (is (= [:quit] cmd))) - - ;; Up returns updated model - (let [[new-model cmd] (update-fn model [:key :up])] - (is (= {:n 1} new-model)) - (is (nil? cmd))) - - ;; Unknown key returns model unchanged - (let [[new-model cmd] (update-fn model [:key {:char \x}])] - (is (= model new-model)) - (is (nil? cmd)))))) - -;; ============================================================================= -;; COUNTER PATTERN TESTS (from counter example, works with simple runtime) -;; ============================================================================= - -(deftest simple-counter-pattern-test - (testing "counter update pattern without async commands" - (let [update-fn (fn [{:keys [count] :as model} msg] - (cond - (or (simple/key= msg "q") - (simple/key= msg [:ctrl \c])) - [model simple/quit] - - (or (simple/key= msg :up) - (simple/key= msg "k")) - [(update model :count inc) nil] - - (or (simple/key= msg :down) - (simple/key= msg "j")) - [(update model :count dec) nil] - - (simple/key= msg "r") - [(assoc model :count 0) nil] - - :else - [model nil]))] - - ;; Test increment with up arrow - (let [[m1 _] (update-fn {:count 0} [:key :up])] - (is (= 1 (:count m1)))) - - ;; Test increment with k - (let [[m1 _] (update-fn {:count 0} [:key {:char \k}])] - (is (= 1 (:count m1)))) - - ;; Test decrement - (let [[m1 _] (update-fn {:count 5} [:key :down])] - (is (= 4 (:count m1)))) - - ;; Test reset - (let [[m1 _] (update-fn {:count 42} [:key {:char \r}])] - (is (= 0 (:count m1)))) - - ;; Test quit with q - (let [[_ cmd] (update-fn {:count 0} [:key {:char \q}])] - (is (= simple/quit cmd))) - - ;; Test quit with ctrl+c - (let [[_ cmd] (update-fn {:count 0} [:key {:ctrl true :char \c}])] - (is (= simple/quit cmd)))))) - -;; ============================================================================= -;; LIST SELECTION PATTERN TESTS (works with simple runtime) -;; ============================================================================= - -(deftest simple-list-selection-pattern-test - (testing "list selection with cursor navigation" - (let [items ["Pizza" "Sushi" "Tacos" "Burger"] - update-fn (fn [{:keys [cursor items] :as model} msg] - (cond - (or (simple/key= msg :up) - (simple/key= msg "k")) - [(update model :cursor #(max 0 (dec %))) nil] - - (or (simple/key= msg :down) - (simple/key= msg "j")) - [(update model :cursor #(min (dec (count items)) (inc %))) nil] - - (simple/key= msg " ") - [(update model :selected - #(if (contains? % cursor) - (disj % cursor) - (conj % cursor))) - nil] - - (simple/key= msg :enter) - [(assoc model :submitted true) simple/quit] - - :else - [model nil]))] - - ;; Test cursor bounds - can't go below 0 - (let [[m1 _] (update-fn {:cursor 0 :items items :selected #{}} [:key :up])] - (is (= 0 (:cursor m1)))) - - ;; Test cursor bounds - can't go above max - (let [[m1 _] (update-fn {:cursor 3 :items items :selected #{}} [:key :down])] - (is (= 3 (:cursor m1)))) - - ;; Test toggle selection - (let [m0 {:cursor 1 :items items :selected #{}} - [m1 _] (update-fn m0 [:key {:char \space}]) - [m2 _] (update-fn m1 [:key {:char \space}])] - (is (= #{1} (:selected m1))) - (is (= #{} (:selected m2)))) - - ;; Test submission - (let [[m1 cmd] (update-fn {:cursor 0 :items items :selected #{0 2} :submitted false} - [:key :enter])] - (is (:submitted m1)) - (is (= simple/quit cmd)))))) - -;; ============================================================================= -;; VIEWS STATE MACHINE TESTS (works with simple runtime) -;; ============================================================================= - -(deftest simple-views-state-machine-test - (testing "view state transitions" - (let [items [{:name "Profile" :desc "Profile settings"} - {:name "Settings" :desc "App preferences"}] - update-fn (fn [{:keys [view cursor items] :as model} msg] - (case view - :menu - (cond - (simple/key= msg :enter) - [(assoc model :view :detail :selected (nth items cursor)) nil] - (simple/key= msg "q") - [model simple/quit] - :else [model nil]) - - :detail - (cond - (or (simple/key= msg :escape) - (simple/key= msg "b")) - [(assoc model :view :menu :selected nil) nil] - (simple/key= msg "q") - [(assoc model :view :confirm) nil] - :else [model nil]) - - :confirm - (cond - (simple/key= msg "y") - [model simple/quit] - (simple/key= msg "n") - [(assoc model :view :detail) nil] - :else [model nil])))] - - ;; Menu -> Detail - (let [[m1 _] (update-fn {:view :menu :cursor 0 :items items} [:key :enter])] - (is (= :detail (:view m1))) - (is (= "Profile" (:name (:selected m1))))) - - ;; Detail -> Menu (back) - (let [[m1 _] (update-fn {:view :detail :selected (first items)} [:key :escape])] - (is (= :menu (:view m1))) - (is (nil? (:selected m1)))) - - ;; Detail -> Confirm (quit attempt) - (let [[m1 _] (update-fn {:view :detail} [:key {:char \q}])] - (is (= :confirm (:view m1)))) - - ;; Confirm -> Quit (yes) - (let [[_ cmd] (update-fn {:view :confirm} [:key {:char \y}])] - (is (= simple/quit cmd))) - - ;; Confirm -> Detail (no) - (let [[m1 _] (update-fn {:view :confirm} [:key {:char \n}])] - (is (= :detail (:view m1)))))))