update docs
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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:"]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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))))))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)))))))
|
|
||||||
Reference in New Issue
Block a user