update docs

This commit is contained in:
2026-01-22 10:50:26 -05:00
parent 95b53f7533
commit 0e40fe01d7
14 changed files with 57 additions and 508 deletions
+5 -6
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## 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 ## Commands
@@ -15,7 +15,7 @@ bb test
# List available examples # List available examples
bb examples bb examples
# Run examples (with Babashka - simple sync runtime) # Run examples with Babashka (recommended - fast startup)
bb counter bb counter
bb timer bb timer
bb list bb list
@@ -23,7 +23,7 @@ bb spinner
bb views bb views
bb http bb http
# Run examples with full Clojure (async support) # Run examples with full Clojure
clojure -A:dev -M -m examples.counter clojure -A:dev -M -m examples.counter
``` ```
@@ -38,8 +38,7 @@ View (hiccup) → Render (ANSI string) → Terminal (raw mode I/O)
### Core Modules ### 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.core** - Main 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.render** - Converts hiccup (`[:col [:text "hi"]]`) to ANSI strings. Handles `:text`, `:row`, `:col`, `:box`, `:space` elements. - **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.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}]`). - **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 ### 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 2. Runtime renders initial view
3. Input loop reads keys, puts messages on channel 3. Input loop reads keys, puts messages on channel
4. Update function processes messages, may return commands 4. Update function processes messages, may return commands
+17 -40
View File
@@ -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` - **Elm Architecture** - Predictable state management with `init`, `update`, and `view`
- **Hiccup Views** - Declarative UI with familiar Clojure syntax - **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 - **Rich Styling** - Colors (16, 256, true color), bold, italic, underline, and more
- **Layout System** - Rows, columns, and boxes with borders - **Layout System** - Rows, columns, and boxes with borders
- **Input Handling** - Full keyboard support including arrows, function keys, and modifiers - **Input Handling** - Full keyboard support including arrows, function keys, and modifiers
- **Babashka Compatible** - Fast startup with Babashka or full Clojure
## Quick Start ## Quick Start
@@ -27,14 +28,14 @@ Add to your `deps.edn`:
```clojure ```clojure
(ns myapp.core (ns myapp.core
(:require [tui.simple :as tui])) (:require [tui.core :as tui]))
(defn update-fn [model msg] (defn update-fn [model msg]
(if (tui/key= msg "q") (if (tui/key= msg "q")
[model tui/quit] [model tui/quit]
[model nil])) [model nil]))
(defn view [model] (defn view [model _size]
[:col [:col
[:text {:fg :cyan :bold true} "Hello, TUI!"] [:text {:fg :cyan :bold true} "Hello, TUI!"]
[:text {:fg :gray} "Press q to quit"]]) [:text {:fg :gray} "Press q to quit"]])
@@ -54,7 +55,7 @@ Press q to quit
```clojure ```clojure
(ns myapp.counter (ns myapp.counter
(:require [tui.simple :as tui])) (:require [tui.core :as tui]))
(defn update-fn [model msg] (defn update-fn [model msg]
(cond (cond
@@ -64,7 +65,7 @@ Press q to quit
(tui/key= msg "r") [0 nil] (tui/key= msg "r") [0 nil]
:else [model nil])) :else [model nil]))
(defn view [model] (defn view [model _size]
[:col [:col
[:box {:border :rounded :padding [0 2]} [:box {:border :rounded :padding [0 2]}
[:text {:fg :yellow :bold true} (str "Count: " model)]] [:text {:fg :yellow :bold true} (str "Count: " model)]]
@@ -139,9 +140,16 @@ Async HTTP requests with loading states.
bb http 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 ```bash
clojure -A:dev -M -m examples.counter 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) 1. **Model** - Your application state (any Clojure data structure)
2. **Update** - A pure function `(fn [model msg] [new-model cmd])` that handles messages 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 3. **View** - A pure function `(fn [model size] hiccup)` that renders the UI, where `size` is `{:width w :height h}`
## 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)})
```
## Hiccup View Elements ## Hiccup View Elements
@@ -236,8 +214,7 @@ Commands are returned from your `update` function to trigger side effects:
``` ```
src/ src/
tui/ tui/
core.clj # Full async runtime (core.async) core.clj # Main runtime (Elm architecture + async commands)
simple.clj # Simple sync runtime (Babashka-compatible)
render.clj # Hiccup → ANSI render.clj # Hiccup → ANSI
terminal.clj # Raw mode, input/output terminal.clj # Raw mode, input/output
input.clj # Key parsing input.clj # Key parsing
+5 -63
View File
@@ -4,8 +4,7 @@ Complete API documentation for Clojure TUI.
## Table of Contents ## Table of Contents
- [tui.core](#tuicore) - Full async runtime - [tui.core](#tuicore) - Main runtime
- [tui.simple](#tuisimple) - Sync runtime (Babashka)
- [tui.render](#tuirender) - Hiccup rendering - [tui.render](#tuirender) - Hiccup rendering
- [tui.input](#tuiinput) - Key input parsing - [tui.input](#tuiinput) - Key input parsing
- [tui.terminal](#tuiterminal) - Terminal control - [tui.terminal](#tuiterminal) - Terminal control
@@ -15,7 +14,7 @@ Complete API documentation for Clojure TUI.
## tui.core ## 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 ### run
@@ -31,7 +30,7 @@ Run a TUI application synchronously (blocks until quit).
|-----|------|----------|-------------| |-----|------|----------|-------------|
| `:init` | any | Yes | Initial model value | | `:init` | any | Yes | Initial model value |
| `:update` | function | Yes | `(fn [model msg] [new-model cmd])` | | `: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 | | `:init-cmd` | command | No | Initial command to execute |
| `:fps` | integer | No | Frames per second (default: 60) | | `:fps` | integer | No | Frames per second (default: 60) |
| `:alt-screen` | boolean | No | Use alternate screen (default: true) | | `: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") (if (tui/key= msg "q")
[model tui/quit] [model tui/quit]
[model nil])) [model nil]))
:view (fn [{:keys [count]}] :view (fn [{:keys [count]} _size]
[:text (str "Count: " count)]) [:text (str "Count: " count)])
:fps 30 :fps 30
:alt-screen true}) :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 ## tui.render
Converts hiccup data structures to ANSI-formatted strings. Converts hiccup data structures to ANSI-formatted strings.
@@ -715,8 +658,7 @@ The function must:
| Namespace | Purpose | | Namespace | Purpose |
|-----------|---------| |-----------|---------|
| `tui.core` | Full async runtime with all features | | `tui.core` | Main runtime with Elm architecture and async commands |
| `tui.simple` | Sync runtime for Babashka |
| `tui.render` | Hiccup to ANSI rendering | | `tui.render` | Hiccup to ANSI rendering |
| `tui.input` | Key input parsing | | `tui.input` | Key input parsing |
| `tui.terminal` | Low-level terminal control | | `tui.terminal` | Low-level terminal control |
+4 -4
View File
@@ -5,7 +5,7 @@ Detailed walkthroughs of the example applications included with Clojure TUI.
## Running Examples ## Running Examples
```bash ```bash
# With Babashka (simple sync runtime) # With Babashka (recommended - fast startup)
bb counter bb counter
bb timer bb timer
bb list bb list
@@ -13,7 +13,7 @@ bb spinner
bb views bb views
bb http bb http
# With Clojure (full async support) # With full Clojure
clojure -A:dev -M -m examples.counter clojure -A:dev -M -m examples.counter
clojure -A:dev -M -m examples.timer clojure -A:dev -M -m examples.timer
clojure -A:dev -M -m examples.list-selection clojure -A:dev -M -m examples.list-selection
@@ -263,7 +263,7 @@ A multi-select list demonstrating cursor navigation and selection.
```clojure ```clojure
(ns examples.list-selection (ns examples.list-selection
(:require [tui.simple :as tui])) (:require [tui.core :as tui]))
(def items (def items
["Apple" "Banana" "Cherry" "Date" "Elderberry" ["Apple" "Banana" "Cherry" "Date" "Elderberry"
@@ -519,7 +519,7 @@ A multi-view application demonstrating state machine navigation.
```clojure ```clojure
(ns examples.views (ns examples.views
(:require [tui.simple :as tui])) (:require [tui.core :as tui]))
(def menu-items (def menu-items
[{:id :profile :label "View Profile" :desc "See your user information"} [{:id :profile :label "View Profile" :desc "See your user information"}
+10 -27
View File
@@ -14,8 +14,7 @@ Add the library to your `deps.edn`:
### Requirements ### Requirements
- **Clojure 1.11+** for full async runtime (`tui.core`) - **Babashka** (recommended) or **Clojure 1.11+**
- **Babashka** for simple sync runtime (`tui.simple`)
- A terminal that supports ANSI escape codes (most modern terminals) - A terminal that supports ANSI escape codes (most modern terminals)
## Your First Application ## Your First Application
@@ -41,7 +40,7 @@ Create `src/myapp/core.clj`:
```clojure ```clojure
(ns myapp.core (ns myapp.core
(:require [tui.simple :as tui])) (:require [tui.core :as tui]))
;; 1. Model - the application state ;; 1. Model - the application state
(def initial-model (def initial-model
@@ -58,8 +57,8 @@ Create `src/myapp/core.clj`:
:else :else
[model nil])) [model nil]))
;; 3. View - render the model as hiccup ;; 3. View - render the model as hiccup (receives model and terminal size)
(defn view [{:keys [message]}] (defn view [{:keys [message]} _size]
[:col [:col
[:text {:fg :cyan :bold true} message] [:text {:fg :cyan :bold true} message]
[:space {:height 1}] [: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]`. 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 ### The Flow
@@ -198,7 +197,7 @@ Let's build something more interactive: a counter.
```clojure ```clojure
(ns myapp.counter (ns myapp.counter
(:require [tui.simple :as tui])) (:require [tui.core :as tui]))
(defn update-fn [model msg] (defn update-fn [model msg]
(cond (cond
@@ -220,7 +219,7 @@ Let's build something more interactive: a counter.
:else [model nil])) :else [model nil]))
(defn view [count] (defn view [count _size]
[:col [:col
[:box {:border :rounded :padding [0 2]} [:box {:border :rounded :padding [0 2]}
[:row [:row
@@ -315,7 +314,7 @@ Commands are returned as the second element of the update function's return vect
:else :else
[model nil])) [model nil]))
(defn view [{:keys [count done]}] (defn view [{:keys [count done]} _size]
[:col [:col
(if done (if done
[:text {:fg :green :bold true} "Time's up!"] [: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 :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 ## Configuration Options
The `run` function accepts these options: The `run` function accepts these options:
@@ -353,9 +336,9 @@ The `run` function accepts these options:
|--------|-------------|---------| |--------|-------------|---------|
| `:init` | Initial model (required) | - | | `:init` | Initial model (required) | - |
| `:update` | Update function (required) | - | | `:update` | Update function (required) | - |
| `:view` | View function (required) | - | | `:view` | View function `(fn [model size] hiccup)` (required) | - |
| `:init-cmd` | Initial command to run | `nil` | | `: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` | | `:alt-screen` | Use alternate screen buffer | `true` |
### Alternate Screen ### Alternate Screen
+1 -1
View File
@@ -33,7 +33,7 @@
[model nil])) [model nil]))
;; === View === ;; === View ===
(defn view [{:keys [count]}] (defn view [{:keys [count]} _size]
[:col {:gap 1} [:col {:gap 1}
[:box {:border :rounded :padding [0 1]} [:box {:border :rounded :padding [0 1]}
[:col [:col
+1 -1
View File
@@ -66,7 +66,7 @@
[model nil])) [model nil]))
;; === View === ;; === View ===
(defn view [{:keys [state status error url]}] (defn view [{:keys [state status error url]} _size]
[:col {:gap 1} [:col {:gap 1}
[:box {:border :rounded :padding [1 2]} [:box {:border :rounded :padding [1 2]}
[:col {:gap 1} [:col {:gap 1}
+1 -1
View File
@@ -45,7 +45,7 @@
[model nil])) [model nil]))
;; === View === ;; === View ===
(defn view [{:keys [cursor items selected submitted]}] (defn view [{:keys [cursor items selected submitted]} _size]
(if submitted (if submitted
[:col [:col
[:text {:bold true :fg :green} "You selected:"] [:text {:bold true :fg :green} "You selected:"]
+1 -1
View File
@@ -64,7 +64,7 @@
idx (mod frame (count frames))] idx (mod frame (count frames))]
(nth frames idx))) (nth frames idx)))
(defn view [{:keys [loading message style] :as model}] (defn view [{:keys [loading message style] :as model} _size]
[:col {:gap 1} [:col {:gap 1}
[:box {:border :rounded :padding [1 2]} [:box {:border :rounded :padding [1 2]}
[:col {:gap 1} [:col {:gap 1}
+1 -1
View File
@@ -48,7 +48,7 @@
secs (mod seconds 60)] secs (mod seconds 60)]
(format "%02d:%02d" mins secs))) (format "%02d:%02d" mins secs)))
(defn view [{:keys [seconds running done]}] (defn view [{:keys [seconds running done]} _size]
[:col {:gap 1} [:col {:gap 1}
[:box {:border :rounded :padding [1 2]} [:box {:border :rounded :padding [1 2]}
[:col [:col
+1 -1
View File
@@ -102,7 +102,7 @@
[:text {:fg :green} "[y] Yes"] [:text {:fg :green} "[y] Yes"]
[:text {:fg :red} "[n] No"]]]]]) [:text {:fg :red} "[n] No"]]]]])
(defn view [{:keys [view] :as model}] (defn view [{:keys [view] :as model} _size]
(case view (case view
:menu (menu-view model) :menu (menu-view model)
:detail (detail-view model) :detail (detail-view model)
+10 -6
View File
@@ -101,7 +101,7 @@
Options: Options:
- :init - Initial model (required) - :init - Initial model (required)
- :update - (fn [model msg] [new-model cmd]) (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 - :init-cmd - Initial command to run
- :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)
@@ -128,8 +128,10 @@
(execute-cmd! init-cmd msg-chan)) (execute-cmd! init-cmd msg-chan))
;; Initial render ;; Initial render
(let [initial-view (render/render (view init))] (let [size (term/get-terminal-size)
(term/render! initial-view)) ctx {:available-height (:height size)
:available-width (:width size)}]
(term/render! (render/render (view init size) ctx)))
;; Main loop ;; Main loop
(loop [model init (loop [model init
@@ -151,15 +153,17 @@
;; Update model ;; Update model
(let [[new-model cmd] (update model msg) (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)] now (System/currentTimeMillis)]
;; Execute command ;; Execute command
(when cmd (when cmd
(execute-cmd! cmd msg-chan)) (execute-cmd! cmd msg-chan))
;; Render ;; Render with context for flex layouts
(term/render! new-view) (term/render! (render/render (view new-model size) ctx))
(recur new-model now)))))) (recur new-model now))))))
-75
View File
@@ -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)
-281
View File
@@ -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)))))))