add comprehensive documentation for external users
Includes getting started guide, hiccup views reference, full API documentation, and annotated example walkthroughs with ASCII output examples. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,112 +1,186 @@
|
||||
# Clojure TUI
|
||||
|
||||
A Clojure TUI (Terminal User Interface) framework inspired by [Bubbletea](https://github.com/charmbracelet/bubbletea), using the Elm Architecture with Hiccup for views.
|
||||
A terminal user interface framework for Clojure, inspired by [Bubbletea](https://github.com/charmbracelet/bubbletea) (Go). Build interactive CLI applications using the Elm Architecture with Hiccup-style views.
|
||||
|
||||
```
|
||||
╭──────────────────────────────────────╮
|
||||
│ │
|
||||
│ ╭────────────────────╮ │
|
||||
│ │ Counter: 42 │ │
|
||||
│ ╰────────────────────╯ │
|
||||
│ │
|
||||
│ j/k: change r: reset q: quit │
|
||||
│ │
|
||||
╰──────────────────────────────────────╯
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **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)
|
||||
- **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
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
Add to your `deps.edn`:
|
||||
|
||||
```clojure
|
||||
{:deps {io.github.yourname/clojure-tui {:git/tag "v0.1.0" :git/sha "..."}}}
|
||||
```
|
||||
|
||||
### Hello World
|
||||
|
||||
```clojure
|
||||
(ns myapp.core
|
||||
(:require [tui.simple :as tui]))
|
||||
|
||||
(defn update-fn [model msg]
|
||||
(if (tui/key= msg "q")
|
||||
[model tui/quit]
|
||||
[model nil]))
|
||||
|
||||
(defn view [model]
|
||||
[:col
|
||||
[:text {:fg :cyan :bold true} "Hello, TUI!"]
|
||||
[:text {:fg :gray} "Press q to quit"]])
|
||||
|
||||
(tui/run {:init {}
|
||||
:update update-fn
|
||||
:view view})
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Hello, TUI!
|
||||
Press q to quit
|
||||
```
|
||||
|
||||
### Counter Example
|
||||
|
||||
```clojure
|
||||
(ns myapp.counter
|
||||
(:require [tui.simple :as tui]))
|
||||
|
||||
(defn update-fn [model msg]
|
||||
(cond
|
||||
(tui/key= msg "q") [model tui/quit]
|
||||
(tui/key= msg "k") [(inc model) nil]
|
||||
(tui/key= msg "j") [(dec model) nil]
|
||||
(tui/key= msg "r") [0 nil]
|
||||
:else [model nil]))
|
||||
|
||||
(defn view [model]
|
||||
[:col
|
||||
[:box {:border :rounded :padding [0 2]}
|
||||
[:text {:fg :yellow :bold true} (str "Count: " model)]]
|
||||
[:text {:fg :gray} "j/k: change r: reset q: quit"]])
|
||||
|
||||
(tui/run {:init 0
|
||||
:update update-fn
|
||||
:view view})
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
╭──────────────╮
|
||||
│ Count: 0 │
|
||||
╰──────────────╯
|
||||
j/k: change r: reset q: quit
|
||||
```
|
||||
|
||||
## Running Examples
|
||||
|
||||
```bash
|
||||
# With Babashka (simple sync runtime)
|
||||
bb counter
|
||||
bb timer
|
||||
bb list
|
||||
bb spinner
|
||||
bb views
|
||||
bb http
|
||||
|
||||
# With Clojure (full async support)
|
||||
clojure -A:dev -M -m examples.counter
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Getting Started](docs/getting-started.md) - Installation, first app, core concepts
|
||||
- [Hiccup Views](docs/hiccup-views.md) - View elements, styling, and layout
|
||||
- [API Reference](docs/api-reference.md) - Complete API documentation
|
||||
- [Examples](docs/examples.md) - Annotated example applications
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Hiccup DSL (view returns hiccup) │ ← User-facing API
|
||||
├─────────────────────────────────────┤
|
||||
│ Layout Engine (calculates sizes) │ ← Constraint solving
|
||||
├─────────────────────────────────────┤
|
||||
│ Render (hiccup → ANSI string) │ ← Colors, styles
|
||||
├─────────────────────────────────────┤
|
||||
│ Terminal (raw mode, input, output) │ ← Platform abstraction
|
||||
└─────────────────────────────────────┘
|
||||
View (hiccup) → Render (ANSI string) → Terminal (raw mode I/O)
|
||||
↑ │
|
||||
│ v
|
||||
Model ←──────── Update ←─────────── Input (key parsing)
|
||||
```
|
||||
|
||||
## The Elm Architecture
|
||||
The application follows the Elm Architecture:
|
||||
|
||||
Every app has three parts:
|
||||
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
|
||||
|
||||
```clojure
|
||||
;; Model - your application state
|
||||
(def initial-model {:count 0})
|
||||
## Two Runtimes
|
||||
|
||||
;; Update - handle messages, return [new-model command]
|
||||
(defn update-model [model msg]
|
||||
(cond
|
||||
(tui/key= msg "q") [model tui/quit]
|
||||
(tui/key= msg :up) [(update model :count inc) nil]
|
||||
:else [model nil]))
|
||||
### `tui.simple` - Synchronous (Babashka-compatible)
|
||||
|
||||
;; View - render model as hiccup
|
||||
(defn view [{:keys [count]}]
|
||||
[:col
|
||||
[:text {:bold true} "Counter"]
|
||||
[:text (str "Count: " count)]
|
||||
[:text {:fg :gray} "Press up to increment, q to quit"]])
|
||||
```
|
||||
|
||||
## Hiccup Elements
|
||||
|
||||
| Element | Description | Attributes |
|
||||
|---------|-------------|------------|
|
||||
| `:text` | Styled text | `:fg` `:bg` `:bold` `:dim` `:italic` `:underline` `:inverse` |
|
||||
| `:row` | Horizontal layout | `:gap` |
|
||||
| `:col` | Vertical layout | `:gap` |
|
||||
| `:box` | Bordered container | `:border` `:title` `:padding` |
|
||||
| `:space` | Empty space | `:width` `:height` |
|
||||
|
||||
### Colors
|
||||
|
||||
`:fg` and `:bg` accept: `:black` `:red` `:green` `:yellow` `:blue` `:magenta` `:cyan` `:white` `:gray` and bright variants.
|
||||
|
||||
### Borders
|
||||
|
||||
`:border` accepts: `:rounded` `:single` `:double` `:heavy` `:ascii`
|
||||
|
||||
### Padding
|
||||
|
||||
`:padding` accepts: `n` (all sides), `[v h]` (vertical, horizontal), or `[top right bottom left]`
|
||||
|
||||
## Running Examples
|
||||
|
||||
### With Clojure CLI
|
||||
|
||||
```bash
|
||||
# Counter - basic Elm architecture
|
||||
clojure -A:dev -M -m examples.counter
|
||||
|
||||
# Timer - async commands (tick)
|
||||
clojure -A:dev -M -m examples.timer
|
||||
|
||||
# List selection - cursor navigation, multi-select
|
||||
clojure -A:dev -M -m examples.list-selection
|
||||
|
||||
# Spinner - animated loading
|
||||
clojure -A:dev -M -m examples.spinner
|
||||
|
||||
# Views - multi-screen state machine
|
||||
clojure -A:dev -M -m examples.views
|
||||
|
||||
# HTTP - async HTTP requests
|
||||
clojure -A:dev -M -m examples.http
|
||||
```
|
||||
|
||||
### With Babashka (limited)
|
||||
|
||||
The full async runtime requires `core.async`. For Babashka, use `tui.simple`:
|
||||
Best for simple applications. No async support, but works with Babashka.
|
||||
|
||||
```clojure
|
||||
(require '[tui.simple :as tui])
|
||||
|
||||
;; Same API, but no async commands (tick, http, etc.)
|
||||
(tui/run {:init initial-model
|
||||
:update update-model
|
||||
:view view})
|
||||
(tui/run {:init model :update update-fn :view view-fn})
|
||||
```
|
||||
|
||||
## Built-in Commands
|
||||
### `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
|
||||
|
||||
| Element | Description | Example |
|
||||
|---------|-------------|---------|
|
||||
| `:text` | Styled text | `[:text {:fg :red :bold true} "Error"]` |
|
||||
| `:row` | Horizontal layout | `[:row "Left" "Right"]` |
|
||||
| `:col` | Vertical layout | `[:col "Line 1" "Line 2"]` |
|
||||
| `:box` | Bordered container | `[:box {:border :rounded} "Content"]` |
|
||||
| `:space` | Empty space | `[:space {:width 5}]` |
|
||||
|
||||
## Commands
|
||||
|
||||
Commands are returned from your `update` function to trigger side effects:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `tui/quit` | Exit the program |
|
||||
| `(tui/tick ms)` | Send `:tick` message after ms |
|
||||
| `(tui/batch cmd1 cmd2)` | Run commands in parallel |
|
||||
| `(tui/sequentially cmd1 cmd2)` | Run commands in sequence |
|
||||
| `(fn [] msg)` | Custom async command |
|
||||
| `tui/quit` | Exit the application |
|
||||
| `(tui/tick ms)` | Send `:tick` after ms milliseconds |
|
||||
| `(tui/batch c1 c2)` | Run commands in parallel |
|
||||
| `(tui/sequentially c1 c2)` | Run commands sequentially |
|
||||
| `(fn [] msg)` | Custom async function returning a message |
|
||||
|
||||
## Key Matching
|
||||
|
||||
@@ -138,15 +212,6 @@ examples/
|
||||
http.clj
|
||||
```
|
||||
|
||||
## Differences from Bubbletea
|
||||
|
||||
| Bubbletea (Go) | Clojure TUI |
|
||||
|----------------|-------------|
|
||||
| String views | Hiccup views |
|
||||
| Lipgloss styling | Inline `:fg` `:bold` attrs |
|
||||
| `tea.Cmd` functions | Vector commands `[:tick 100]` |
|
||||
| Imperative builder | Declarative data |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
# API Reference
|
||||
|
||||
Complete API documentation for Clojure TUI.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [tui.core](#tuicore) - Full async runtime
|
||||
- [tui.simple](#tuisimple) - Sync runtime (Babashka)
|
||||
- [tui.render](#tuirender) - Hiccup rendering
|
||||
- [tui.input](#tuiinput) - Key input parsing
|
||||
- [tui.terminal](#tuiterminal) - Terminal control
|
||||
- [tui.ansi](#tuiansi) - ANSI escape codes
|
||||
|
||||
---
|
||||
|
||||
## tui.core
|
||||
|
||||
Full-featured async runtime using core.async. Use this when you need timers, async commands, or background operations.
|
||||
|
||||
### run
|
||||
|
||||
```clojure
|
||||
(run options)
|
||||
```
|
||||
|
||||
Run a TUI application synchronously (blocks until quit).
|
||||
|
||||
**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)` |
|
||||
| `: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) |
|
||||
|
||||
**Returns:** Final model value after quit
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
(require '[tui.core :as tui])
|
||||
|
||||
(tui/run {:init {:count 0}
|
||||
:update (fn [model msg]
|
||||
(if (tui/key= msg "q")
|
||||
[model tui/quit]
|
||||
[model nil]))
|
||||
:view (fn [{:keys [count]}]
|
||||
[:text (str "Count: " count)])
|
||||
:fps 30
|
||||
:alt-screen true})
|
||||
```
|
||||
|
||||
### quit
|
||||
|
||||
```clojure
|
||||
quit
|
||||
```
|
||||
|
||||
Constant value returned as a command to exit the application.
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
(defn update-fn [model msg]
|
||||
(if (tui/key= msg "q")
|
||||
[model tui/quit]
|
||||
[model nil]))
|
||||
```
|
||||
|
||||
### tick
|
||||
|
||||
```clojure
|
||||
(tick ms)
|
||||
```
|
||||
|
||||
Create a command that sends `:tick` message after a delay.
|
||||
|
||||
**Parameters:**
|
||||
- `ms` - Delay in milliseconds
|
||||
|
||||
**Returns:** A tick command
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
;; Start a 1-second timer
|
||||
(defn update-fn [model msg]
|
||||
(case msg
|
||||
:tick [(update model :seconds inc) (tui/tick 1000)]
|
||||
[model nil]))
|
||||
|
||||
;; Initial tick
|
||||
(tui/run {:init {:seconds 0}
|
||||
:update update-fn
|
||||
:view view
|
||||
:init-cmd (tui/tick 1000)})
|
||||
```
|
||||
|
||||
### batch
|
||||
|
||||
```clojure
|
||||
(batch & cmds)
|
||||
```
|
||||
|
||||
Create a command that executes multiple commands in parallel.
|
||||
|
||||
**Parameters:**
|
||||
- `cmds` - Variable number of commands
|
||||
|
||||
**Returns:** A batch command
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
;; Execute two async operations in parallel
|
||||
(defn update-fn [model msg]
|
||||
(if (= msg :start)
|
||||
[model (tui/batch
|
||||
(fn [] (fetch-user))
|
||||
(fn [] (fetch-settings)))]
|
||||
[model nil]))
|
||||
```
|
||||
|
||||
### sequentially
|
||||
|
||||
```clojure
|
||||
(sequentially & cmds)
|
||||
```
|
||||
|
||||
Create a command that executes commands one after another.
|
||||
|
||||
**Parameters:**
|
||||
- `cmds` - Variable number of commands
|
||||
|
||||
**Returns:** A sequential command
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
;; First save, then notify
|
||||
(defn update-fn [model msg]
|
||||
(if (= msg :save)
|
||||
[model (tui/sequentially
|
||||
(fn [] (save-data model))
|
||||
(fn [] {:type :saved}))]
|
||||
[model nil]))
|
||||
```
|
||||
|
||||
### send-msg
|
||||
|
||||
```clojure
|
||||
(send-msg msg)
|
||||
```
|
||||
|
||||
Create a command that immediately sends a message.
|
||||
|
||||
**Parameters:**
|
||||
- `msg` - Message to send
|
||||
|
||||
**Returns:** A send-msg command
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
(defn update-fn [model msg]
|
||||
(if (tui/key= msg "r")
|
||||
[{:status :resetting} (tui/send-msg :do-reset)]
|
||||
[model nil]))
|
||||
```
|
||||
|
||||
### key=
|
||||
|
||||
```clojure
|
||||
(key= msg pattern)
|
||||
```
|
||||
|
||||
Check if a key message matches a pattern.
|
||||
|
||||
**Parameters:**
|
||||
- `msg` - The message to check
|
||||
- `pattern` - Pattern to match (see below)
|
||||
|
||||
**Returns:** `true` if matches, `false` otherwise
|
||||
|
||||
**Pattern types:**
|
||||
|
||||
| Pattern | Matches |
|
||||
|---------|---------|
|
||||
| `"q"` | Character 'q' |
|
||||
| `:enter` | Enter key |
|
||||
| `:up` | Arrow up |
|
||||
| `[:ctrl \c]` | Ctrl+C |
|
||||
| `[:alt \x]` | Alt+X |
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
(defn update-fn [model msg]
|
||||
(cond
|
||||
(tui/key= msg "q") [model tui/quit]
|
||||
(tui/key= msg :up) [(update model :y dec) nil]
|
||||
(tui/key= msg [:ctrl \c]) [model tui/quit]
|
||||
:else [model nil]))
|
||||
```
|
||||
|
||||
### key-str
|
||||
|
||||
```clojure
|
||||
(key-str msg)
|
||||
```
|
||||
|
||||
Convert a key message to a human-readable string.
|
||||
|
||||
**Parameters:**
|
||||
- `msg` - Key message
|
||||
|
||||
**Returns:** String representation
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
(tui/key-str [:key {:char \a}]) ;; => "a"
|
||||
(tui/key-str [:key :up]) ;; => "up"
|
||||
(tui/key-str [:key {:ctrl true :char \c}]) ;; => "ctrl+c"
|
||||
```
|
||||
|
||||
### render
|
||||
|
||||
```clojure
|
||||
(render hiccup)
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### render
|
||||
|
||||
```clojure
|
||||
(render hiccup)
|
||||
(render hiccup ctx)
|
||||
```
|
||||
|
||||
Render hiccup to ANSI string.
|
||||
|
||||
**Parameters:**
|
||||
- `hiccup` - Hiccup data structure
|
||||
- `ctx` - Optional context map (for internal use)
|
||||
|
||||
**Returns:** String with ANSI escape codes
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
(require '[tui.render :as r])
|
||||
|
||||
(r/render [:text {:fg :red :bold true} "Error!"])
|
||||
;; => "\e[31m\e[1mError!\e[0m"
|
||||
|
||||
(r/render [:col
|
||||
[:text "Line 1"]
|
||||
[:text "Line 2"]])
|
||||
;; => "Line 1\nLine 2"
|
||||
|
||||
(r/render [:box {:border :rounded} "Content"])
|
||||
;; => "╭─────────╮\n│Content │\n╰─────────╯"
|
||||
```
|
||||
|
||||
### text
|
||||
|
||||
```clojure
|
||||
(text & args)
|
||||
```
|
||||
|
||||
Helper function to create `:text` elements.
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
(text "Hello") ;; => [:text "Hello"]
|
||||
(text {:fg :red} "Error") ;; => [:text {:fg :red} "Error"]
|
||||
```
|
||||
|
||||
### row
|
||||
|
||||
```clojure
|
||||
(row & args)
|
||||
```
|
||||
|
||||
Helper function to create `:row` elements.
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
(row "A" "B" "C") ;; => [:row "A" "B" "C"]
|
||||
(row {:gap 2} "A" "B") ;; => [:row {:gap 2} "A" "B"]
|
||||
```
|
||||
|
||||
### col
|
||||
|
||||
```clojure
|
||||
(col & args)
|
||||
```
|
||||
|
||||
Helper function to create `:col` elements.
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
(col "Line 1" "Line 2") ;; => [:col "Line 1" "Line 2"]
|
||||
(col {:gap 1} "A" "B") ;; => [:col {:gap 1} "A" "B"]
|
||||
```
|
||||
|
||||
### box
|
||||
|
||||
```clojure
|
||||
(box & args)
|
||||
```
|
||||
|
||||
Helper function to create `:box` elements.
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
(box "Content") ;; => [:box "Content"]
|
||||
(box {:title "Info"} "Content") ;; => [:box {:title "Info"} "Content"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## tui.input
|
||||
|
||||
Parses raw terminal input into structured key messages.
|
||||
|
||||
### read-key
|
||||
|
||||
```clojure
|
||||
(read-key)
|
||||
```
|
||||
|
||||
Read a single key event from the terminal.
|
||||
|
||||
**Returns:** Key message vector `[:key ...]`
|
||||
|
||||
**Key message formats:**
|
||||
|
||||
```clojure
|
||||
;; Regular character
|
||||
[:key {:char \a}]
|
||||
|
||||
;; Special key
|
||||
[:key :enter]
|
||||
[:key :up]
|
||||
[:key :f1]
|
||||
|
||||
;; Control combination
|
||||
[:key {:ctrl true :char \c}]
|
||||
|
||||
;; Alt combination
|
||||
[:key {:alt true :char \x}]
|
||||
|
||||
;; Unknown sequence
|
||||
[:key :unknown "\e[xyz"]
|
||||
```
|
||||
|
||||
### Special Key Keywords
|
||||
|
||||
| Keyword | Key |
|
||||
|---------|-----|
|
||||
| `:up` | Arrow Up |
|
||||
| `:down` | Arrow Down |
|
||||
| `:left` | Arrow Left |
|
||||
| `:right` | Arrow Right |
|
||||
| `:home` | Home |
|
||||
| `:end` | End |
|
||||
| `:page-up` | Page Up |
|
||||
| `:page-down` | Page Down |
|
||||
| `:insert` | Insert |
|
||||
| `:delete` | Delete |
|
||||
| `:escape` | Escape |
|
||||
| `:tab` | Tab |
|
||||
| `:shift-tab` | Shift+Tab |
|
||||
| `:enter` | Enter |
|
||||
| `:backspace` | Backspace |
|
||||
| `:f1` - `:f12` | Function keys |
|
||||
|
||||
### key-match?
|
||||
|
||||
```clojure
|
||||
(key-match? msg pattern)
|
||||
```
|
||||
|
||||
Internal function used by `key=` to match patterns.
|
||||
|
||||
### key->str
|
||||
|
||||
```clojure
|
||||
(key->str msg)
|
||||
```
|
||||
|
||||
Convert key message to string representation.
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
(key->str [:key {:char \a}]) ;; => "a"
|
||||
(key->str [:key :enter]) ;; => "enter"
|
||||
(key->str [:key {:ctrl true :char \c}]) ;; => "ctrl+c"
|
||||
(key->str [:key {:alt true :char \x}]) ;; => "alt+x"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## tui.terminal
|
||||
|
||||
Low-level terminal control functions.
|
||||
|
||||
### get-terminal-size
|
||||
|
||||
```clojure
|
||||
(get-terminal-size)
|
||||
```
|
||||
|
||||
Get the terminal dimensions.
|
||||
|
||||
**Returns:** Map with `:width` and `:height` keys
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
(require '[tui.terminal :as term])
|
||||
|
||||
(term/get-terminal-size)
|
||||
;; => {:width 120 :height 40}
|
||||
```
|
||||
|
||||
### raw-mode!
|
||||
|
||||
```clojure
|
||||
(raw-mode!)
|
||||
```
|
||||
|
||||
Enter raw terminal mode. Disables echo and line buffering.
|
||||
|
||||
### restore!
|
||||
|
||||
```clojure
|
||||
(restore!)
|
||||
```
|
||||
|
||||
Restore terminal to original state.
|
||||
|
||||
### alt-screen!
|
||||
|
||||
```clojure
|
||||
(alt-screen!)
|
||||
```
|
||||
|
||||
Enter the alternate screen buffer.
|
||||
|
||||
### exit-alt-screen!
|
||||
|
||||
```clojure
|
||||
(exit-alt-screen!)
|
||||
```
|
||||
|
||||
Exit the alternate screen buffer.
|
||||
|
||||
### clear!
|
||||
|
||||
```clojure
|
||||
(clear!)
|
||||
```
|
||||
|
||||
Clear the screen and move cursor to home position.
|
||||
|
||||
### render!
|
||||
|
||||
```clojure
|
||||
(render! s)
|
||||
```
|
||||
|
||||
Render a string to the terminal.
|
||||
|
||||
**Parameters:**
|
||||
- `s` - String to render (typically from `tui.render/render`)
|
||||
|
||||
### Input Functions
|
||||
|
||||
```clojure
|
||||
(init-input!) ;; Initialize input reader
|
||||
(close-input!) ;; Close input reader
|
||||
(input-ready?) ;; Check if input available (non-blocking)
|
||||
(read-char) ;; Read single character (blocking)
|
||||
(read-available) ;; Read all available characters
|
||||
(read-char-timeout ms) ;; Read with timeout
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## tui.ansi
|
||||
|
||||
ANSI escape codes and text styling utilities.
|
||||
|
||||
### style
|
||||
|
||||
```clojure
|
||||
(style text & {:keys [fg bg bold dim italic underline inverse strike]})
|
||||
```
|
||||
|
||||
Apply multiple styles to text.
|
||||
|
||||
**Parameters:**
|
||||
- `text` - String to style
|
||||
- `:fg` - Foreground color
|
||||
- `:bg` - Background color
|
||||
- `:bold` - Boolean
|
||||
- `:dim` - Boolean
|
||||
- `:italic` - Boolean
|
||||
- `:underline` - Boolean
|
||||
- `:inverse` - Boolean
|
||||
- `:strike` - Boolean
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
(require '[tui.ansi :as ansi])
|
||||
|
||||
(ansi/style "Error" :fg :red :bold true)
|
||||
(ansi/style "Warning" :fg :yellow :bg :black :underline true)
|
||||
```
|
||||
|
||||
### Color Functions
|
||||
|
||||
```clojure
|
||||
(fg color text) ;; Set foreground color
|
||||
(bg color text) ;; Set background color
|
||||
(fg-256 n text) ;; 256-color foreground (0-255)
|
||||
(bg-256 n text) ;; 256-color background (0-255)
|
||||
(fg-rgb r g b text) ;; True color foreground (24-bit)
|
||||
(bg-rgb r g b text) ;; True color background (24-bit)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
(ansi/fg :red "Error")
|
||||
(ansi/bg :yellow "Highlighted")
|
||||
(ansi/fg-256 208 "Orange")
|
||||
(ansi/fg-rgb 255 128 0 "True color orange")
|
||||
```
|
||||
|
||||
### String Utilities
|
||||
|
||||
```clojure
|
||||
(visible-length s) ;; Get visible length (excludes ANSI codes)
|
||||
(pad-right s width) ;; Pad with spaces on right
|
||||
(pad-left s width) ;; Pad with spaces on left
|
||||
(pad-center s width) ;; Center within width
|
||||
(truncate s max-width) ;; Truncate with ellipsis
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```clojure
|
||||
(ansi/visible-length "\e[31mRed\e[0m") ;; => 3
|
||||
(ansi/pad-right "Hi" 10) ;; => "Hi "
|
||||
(ansi/pad-center "Hi" 10) ;; => " Hi "
|
||||
(ansi/truncate "Hello World" 8) ;; => "Hello..."
|
||||
```
|
||||
|
||||
### Cursor Control
|
||||
|
||||
```clojure
|
||||
(cursor-to row col) ;; Move cursor to position (1-indexed)
|
||||
(cursor-up n) ;; Move cursor up n lines
|
||||
(cursor-down n) ;; Move cursor down n lines
|
||||
(cursor-forward n) ;; Move cursor right n columns
|
||||
(cursor-back n) ;; Move cursor left n columns
|
||||
```
|
||||
|
||||
### Constants
|
||||
|
||||
```clojure
|
||||
clear-screen ;; Clear entire screen
|
||||
clear-line ;; Clear current line
|
||||
clear-to-end ;; Clear from cursor to end of screen
|
||||
cursor-home ;; Move cursor to home (1,1)
|
||||
hide-cursor ;; Hide cursor
|
||||
show-cursor ;; Show cursor
|
||||
enter-alt-screen ;; Enter alternate screen buffer
|
||||
exit-alt-screen ;; Exit alternate screen buffer
|
||||
cursor-save ;; Save cursor position
|
||||
cursor-restore ;; Restore cursor position
|
||||
reset ;; Reset all attributes
|
||||
```
|
||||
|
||||
### Box Drawing Characters
|
||||
|
||||
```clojure
|
||||
ansi/box-chars
|
||||
;; => {:rounded {:tl "╭" :tr "╮" :bl "╰" :br "╯" :h "─" :v "│"}
|
||||
;; :single {:tl "┌" :tr "┐" :bl "└" :br "┘" :h "─" :v "│"}
|
||||
;; :double {:tl "╔" :tr "╗" :bl "╚" :br "╝" :h "═" :v "║"}
|
||||
;; :heavy {:tl "┏" :tr "┓" :bl "┗" :br "┛" :h "━" :v "┃"}
|
||||
;; :ascii {:tl "+" :tr "+" :bl "+" :br "+" :h "-" :v "|"}}
|
||||
```
|
||||
|
||||
### Color Maps
|
||||
|
||||
```clojure
|
||||
ansi/fg-colors ;; Map of color keywords to ANSI codes
|
||||
ansi/bg-colors ;; Map of color keywords to ANSI codes
|
||||
ansi/attrs ;; Map of attribute keywords to ANSI codes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Async Commands
|
||||
|
||||
In `tui.core`, you can create custom async commands by returning functions:
|
||||
|
||||
```clojure
|
||||
;; Custom command that fetches data
|
||||
(defn fetch-data-cmd []
|
||||
(fn []
|
||||
;; This runs asynchronously
|
||||
(let [result (http/get "https://api.example.com/data")]
|
||||
{:type :data-loaded :data (:body result)})))
|
||||
|
||||
(defn update-fn [model msg]
|
||||
(cond
|
||||
(tui/key= msg "f")
|
||||
[{:loading true} (fetch-data-cmd)]
|
||||
|
||||
(= (:type msg) :data-loaded)
|
||||
[{:loading false :data (:data msg)} nil]
|
||||
|
||||
:else
|
||||
[model nil]))
|
||||
```
|
||||
|
||||
The function must:
|
||||
1. Take no arguments
|
||||
2. Return a message (any Clojure value)
|
||||
3. The returned message will be passed to your update function
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Namespace | Purpose |
|
||||
|-----------|---------|
|
||||
| `tui.core` | Full async runtime with all features |
|
||||
| `tui.simple` | Sync runtime for Babashka |
|
||||
| `tui.render` | Hiccup to ANSI rendering |
|
||||
| `tui.input` | Key input parsing |
|
||||
| `tui.terminal` | Low-level terminal control |
|
||||
| `tui.ansi` | ANSI codes and text utilities |
|
||||
@@ -0,0 +1,905 @@
|
||||
# Examples
|
||||
|
||||
Detailed walkthroughs of the example applications included with Clojure TUI.
|
||||
|
||||
## Running Examples
|
||||
|
||||
```bash
|
||||
# With Babashka (simple sync runtime)
|
||||
bb counter
|
||||
bb timer
|
||||
bb list
|
||||
bb spinner
|
||||
bb views
|
||||
bb http
|
||||
|
||||
# With Clojure (full async support)
|
||||
clojure -A:dev -M -m examples.counter
|
||||
clojure -A:dev -M -m examples.timer
|
||||
clojure -A:dev -M -m examples.list-selection
|
||||
clojure -A:dev -M -m examples.spinner
|
||||
clojure -A:dev -M -m examples.views
|
||||
clojure -A:dev -M -m examples.http
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Counter
|
||||
|
||||
A simple counter demonstrating the basic Elm architecture.
|
||||
|
||||
**Features:**
|
||||
- Basic state management
|
||||
- Key handling (multiple keys for same action)
|
||||
- Conditional styling based on value
|
||||
- Box layout
|
||||
|
||||
### Code
|
||||
|
||||
```clojure
|
||||
(ns examples.counter
|
||||
(:require [tui.core :as tui]))
|
||||
|
||||
(defn update-fn [model msg]
|
||||
(cond
|
||||
;; Quit on 'q' or Ctrl+C
|
||||
(or (tui/key= msg "q")
|
||||
(tui/key= msg [:ctrl \c]))
|
||||
[model tui/quit]
|
||||
|
||||
;; Increment on 'k' or up arrow
|
||||
(or (tui/key= msg "k")
|
||||
(tui/key= msg :up))
|
||||
[(inc model) nil]
|
||||
|
||||
;; Decrement on 'j' or down arrow
|
||||
(or (tui/key= msg "j")
|
||||
(tui/key= msg :down))
|
||||
[(dec model) nil]
|
||||
|
||||
;; Reset on 'r'
|
||||
(tui/key= msg "r")
|
||||
[0 nil]
|
||||
|
||||
:else
|
||||
[model nil]))
|
||||
|
||||
(defn view [count]
|
||||
[:col
|
||||
[:box {:border :rounded :padding [0 2]}
|
||||
[:text {:fg (cond
|
||||
(pos? count) :green
|
||||
(neg? count) :red
|
||||
:else :yellow)
|
||||
:bold true}
|
||||
(str "Counter: " count)]]
|
||||
[:space {:height 1}]
|
||||
[:text {:fg :gray} "j/k or up/down: change value"]
|
||||
[:text {:fg :gray} "r: reset q: quit"]])
|
||||
|
||||
(defn -main [& args]
|
||||
(tui/run {:init 0
|
||||
:update update-fn
|
||||
:view view}))
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
Initial state:
|
||||
```
|
||||
╭────────────────╮
|
||||
│ Counter: 0 │
|
||||
╰────────────────╯
|
||||
|
||||
j/k or up/down: change value
|
||||
r: reset q: quit
|
||||
```
|
||||
|
||||
After pressing `k` three times:
|
||||
```
|
||||
╭────────────────╮
|
||||
│ Counter: 3 │
|
||||
╰────────────────╯
|
||||
|
||||
j/k or up/down: change value
|
||||
r: reset q: quit
|
||||
```
|
||||
|
||||
After pressing `j` five times (negative):
|
||||
```
|
||||
╭────────────────╮
|
||||
│ Counter: -2 │
|
||||
╰────────────────╯
|
||||
|
||||
j/k or up/down: change value
|
||||
r: reset q: quit
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
1. **Multiple key bindings**: Use `or` to bind multiple keys to the same action
|
||||
2. **Conditional styling**: Change colors based on state (`pos?`, `neg?`)
|
||||
3. **Simple state**: Model is just an integer
|
||||
|
||||
---
|
||||
|
||||
## Timer
|
||||
|
||||
A countdown timer demonstrating async commands with `tick`.
|
||||
|
||||
**Features:**
|
||||
- Async tick command
|
||||
- State machine (running/paused/done)
|
||||
- Conditional rendering
|
||||
- Pause/resume functionality
|
||||
|
||||
### Code
|
||||
|
||||
```clojure
|
||||
(ns examples.timer
|
||||
(:require [tui.core :as tui]))
|
||||
|
||||
(def initial-seconds 10)
|
||||
|
||||
(defn update-fn [model msg]
|
||||
(cond
|
||||
;; Quit
|
||||
(tui/key= msg "q")
|
||||
[model tui/quit]
|
||||
|
||||
;; Toggle pause with space
|
||||
(tui/key= msg " ")
|
||||
(if (:done model)
|
||||
[model nil]
|
||||
(let [paused (not (:paused model))]
|
||||
[(assoc model :paused paused)
|
||||
(when-not paused (tui/tick 1000))]))
|
||||
|
||||
;; Reset with 'r'
|
||||
(tui/key= msg "r")
|
||||
[{:seconds initial-seconds :paused false :done false}
|
||||
(tui/tick 1000)]
|
||||
|
||||
;; Handle tick
|
||||
(= msg :tick)
|
||||
(if (:paused model)
|
||||
[model nil]
|
||||
(let [new-seconds (dec (:seconds model))]
|
||||
(if (pos? new-seconds)
|
||||
[{:seconds new-seconds :paused false :done false}
|
||||
(tui/tick 1000)]
|
||||
[{:seconds 0 :paused false :done true} nil])))
|
||||
|
||||
:else
|
||||
[model nil]))
|
||||
|
||||
(defn format-time [seconds]
|
||||
(let [mins (quot seconds 60)
|
||||
secs (mod seconds 60)]
|
||||
(format "%02d:%02d" mins secs)))
|
||||
|
||||
(defn view [{:keys [seconds paused done]}]
|
||||
[:col
|
||||
[:box {:border :rounded :padding [0 2]}
|
||||
[:col
|
||||
[:text {:bold true} "Countdown Timer"]
|
||||
[:space {:height 1}]
|
||||
[:text {:fg (cond
|
||||
done :green
|
||||
paused :yellow
|
||||
:else :cyan)
|
||||
:bold true}
|
||||
(if done
|
||||
"Time's up!"
|
||||
(format-time seconds))]
|
||||
(when paused
|
||||
[:text {:fg :yellow} "(paused)"])]]
|
||||
[:space {:height 1}]
|
||||
[:text {:fg :gray} "space: pause/resume r: reset q: quit"]])
|
||||
|
||||
(defn -main [& args]
|
||||
(tui/run {:init {:seconds initial-seconds :paused false :done false}
|
||||
:update update-fn
|
||||
:view view
|
||||
:init-cmd (tui/tick 1000)}))
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
Running:
|
||||
```
|
||||
╭──────────────────────╮
|
||||
│ Countdown Timer │
|
||||
│ │
|
||||
│ 00:07 │
|
||||
╰──────────────────────╯
|
||||
|
||||
space: pause/resume r: reset q: quit
|
||||
```
|
||||
|
||||
Paused:
|
||||
```
|
||||
╭──────────────────────╮
|
||||
│ Countdown Timer │
|
||||
│ │
|
||||
│ 00:05 │
|
||||
│ (paused) │
|
||||
╰──────────────────────╯
|
||||
|
||||
space: pause/resume r: reset q: quit
|
||||
```
|
||||
|
||||
Done:
|
||||
```
|
||||
╭──────────────────────╮
|
||||
│ Countdown Timer │
|
||||
│ │
|
||||
│ Time's up! │
|
||||
╰──────────────────────╯
|
||||
|
||||
space: pause/resume r: reset q: quit
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
1. **Tick command**: `(tui/tick 1000)` sends `:tick` after 1 second
|
||||
2. **Initial command**: `:init-cmd` starts the first tick
|
||||
3. **State machine**: Track `paused` and `done` states
|
||||
4. **Conditional ticks**: Only schedule next tick when not paused
|
||||
|
||||
---
|
||||
|
||||
## List Selection
|
||||
|
||||
A multi-select list demonstrating cursor navigation and selection.
|
||||
|
||||
**Features:**
|
||||
- Cursor navigation with bounds checking
|
||||
- Multi-select using a set
|
||||
- Visual selection indicators
|
||||
- Enter to confirm
|
||||
|
||||
### Code
|
||||
|
||||
```clojure
|
||||
(ns examples.list-selection
|
||||
(:require [tui.simple :as tui]))
|
||||
|
||||
(def items
|
||||
["Apple" "Banana" "Cherry" "Date" "Elderberry"
|
||||
"Fig" "Grape" "Honeydew"])
|
||||
|
||||
(defn update-fn [model msg]
|
||||
(cond
|
||||
;; Quit
|
||||
(tui/key= msg "q")
|
||||
[model tui/quit]
|
||||
|
||||
;; Move up
|
||||
(or (tui/key= msg "k")
|
||||
(tui/key= msg :up))
|
||||
[(update model :cursor #(max 0 (dec %))) nil]
|
||||
|
||||
;; Move down
|
||||
(or (tui/key= msg "j")
|
||||
(tui/key= msg :down))
|
||||
[(update model :cursor #(min (dec (count items)) (inc %))) nil]
|
||||
|
||||
;; Toggle selection
|
||||
(tui/key= msg " ")
|
||||
(let [item (nth items (:cursor model))]
|
||||
[(update model :selected
|
||||
#(if (contains? % item)
|
||||
(disj % item)
|
||||
(conj % item)))
|
||||
nil])
|
||||
|
||||
;; Confirm and quit
|
||||
(tui/key= msg :enter)
|
||||
[(assoc model :confirmed true) tui/quit]
|
||||
|
||||
:else
|
||||
[model nil]))
|
||||
|
||||
(defn view [{:keys [cursor selected]}]
|
||||
[:col
|
||||
[:text {:bold true} "Select items:"]
|
||||
[:space {:height 1}]
|
||||
[:col
|
||||
(for [[idx item] (map-indexed vector items)]
|
||||
(let [is-cursor (= idx cursor)
|
||||
is-selected (contains? selected item)]
|
||||
[:row {:gap 1}
|
||||
[:text (if is-cursor ">" " ")]
|
||||
[:text (if is-selected "[x]" "[ ]")]
|
||||
[:text {:fg (cond
|
||||
is-cursor :cyan
|
||||
is-selected :green
|
||||
:else :white)}
|
||||
item]]))]
|
||||
[:space {:height 1}]
|
||||
[:text {:fg :gray} "j/k: move space: toggle enter: confirm q: quit"]])
|
||||
|
||||
(defn -main [& args]
|
||||
(let [result (tui/run {:init {:cursor 0 :selected #{}}
|
||||
:update update-fn
|
||||
:view view})]
|
||||
(when (:confirmed result)
|
||||
(println "Selected:" (:selected result)))))
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
Initial state (cursor on Apple):
|
||||
```
|
||||
Select items:
|
||||
|
||||
> [ ] Apple
|
||||
[ ] Banana
|
||||
[ ] Cherry
|
||||
[ ] Date
|
||||
[ ] Elderberry
|
||||
[ ] Fig
|
||||
[ ] Grape
|
||||
[ ] Honeydew
|
||||
|
||||
j/k: move space: toggle enter: confirm q: quit
|
||||
```
|
||||
|
||||
After selecting some items:
|
||||
```
|
||||
Select items:
|
||||
|
||||
[x] Apple
|
||||
> [ ] Banana
|
||||
[x] Cherry
|
||||
[ ] Date
|
||||
[ ] Elderberry
|
||||
[ ] Fig
|
||||
[x] Grape
|
||||
[ ] Honeydew
|
||||
|
||||
j/k: move space: toggle enter: confirm q: quit
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
1. **Cursor bounds**: Use `max` and `min` to keep cursor in range
|
||||
2. **Set for selection**: Using a set makes toggle logic simple
|
||||
3. **Result after quit**: Access final model after `run` returns
|
||||
4. **Visual indicators**: Different characters for cursor (`>`) and selection (`[x]`)
|
||||
|
||||
---
|
||||
|
||||
## Spinner
|
||||
|
||||
An animated loading spinner demonstrating fast tick-based animation.
|
||||
|
||||
**Features:**
|
||||
- Multiple spinner styles
|
||||
- Fast animation (80ms ticks)
|
||||
- Tab to cycle styles
|
||||
- State machine (loading/done)
|
||||
|
||||
### Code
|
||||
|
||||
```clojure
|
||||
(ns examples.spinner
|
||||
(:require [tui.core :as tui]))
|
||||
|
||||
(def spinners
|
||||
{:dots {:frames ["⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"] :interval 80}
|
||||
:line {:frames ["-" "\\" "|" "/"] :interval 80}
|
||||
:circle {:frames ["◐" "◓" "◑" "◒"] :interval 100}
|
||||
:square {:frames ["◰" "◳" "◲" "◱"] :interval 100}
|
||||
:triangle {:frames ["◢" "◣" "◤" "◥"] :interval 80}
|
||||
:bounce {:frames ["⠁" "⠂" "⠄" "⠂"] :interval 120}
|
||||
:dots2 {:frames ["⣾" "⣽" "⣻" "⢿" "⡿" "⣟" "⣯" "⣷"] :interval 80}
|
||||
:arc {:frames ["◜" "◠" "◝" "◞" "◡" "◟"] :interval 100}
|
||||
:toggle {:frames ["⊶" "⊷"] :interval 250}
|
||||
:arrow {:frames ["←" "↖" "↑" "↗" "→" "↘" "↓" "↙"] :interval 100}})
|
||||
|
||||
(def spinner-order (vec (keys spinners)))
|
||||
|
||||
(defn update-fn [model msg]
|
||||
(cond
|
||||
;; Quit
|
||||
(tui/key= msg "q")
|
||||
[model tui/quit]
|
||||
|
||||
;; Cycle spinner style with Tab
|
||||
(tui/key= msg :tab)
|
||||
(let [next-idx (mod (inc (:style-idx model)) (count spinner-order))]
|
||||
[(assoc model :style-idx next-idx :frame 0) nil])
|
||||
|
||||
;; Complete with space
|
||||
(tui/key= msg " ")
|
||||
[(assoc model :done true) nil]
|
||||
|
||||
;; Reset with 'r'
|
||||
(tui/key= msg "r")
|
||||
[{:style-idx (:style-idx model) :frame 0 :done false}
|
||||
(tui/tick 80)]
|
||||
|
||||
;; Handle tick
|
||||
(= msg :tick)
|
||||
(if (:done model)
|
||||
[model nil]
|
||||
(let [style-key (nth spinner-order (:style-idx model))
|
||||
frames (get-in spinners [style-key :frames])
|
||||
interval (get-in spinners [style-key :interval])
|
||||
next-frame (mod (inc (:frame model)) (count frames))]
|
||||
[(assoc model :frame next-frame)
|
||||
(tui/tick interval)]))
|
||||
|
||||
:else
|
||||
[model nil]))
|
||||
|
||||
(defn view [{:keys [style-idx frame done]}]
|
||||
(let [style-key (nth spinner-order style-idx)
|
||||
spinner (get spinners style-key)
|
||||
char (nth (:frames spinner) frame)]
|
||||
[:col
|
||||
[:box {:border :rounded :padding [0 2]}
|
||||
[:col
|
||||
[:text {:bold true} (str "Spinner: " (name style-key))]
|
||||
[:space {:height 1}]
|
||||
(if done
|
||||
[:row
|
||||
[:text {:fg :green :bold true} "✓"]
|
||||
[:text " Done!"]]
|
||||
[:row
|
||||
[:text {:fg :cyan :bold true} char]
|
||||
[:text " Loading..."]])]]
|
||||
[:space {:height 1}]
|
||||
[:text {:fg :gray} "tab: change style space: complete r: restart q: quit"]]))
|
||||
|
||||
(defn -main [& args]
|
||||
(tui/run {:init {:style-idx 0 :frame 0 :done false}
|
||||
:update update-fn
|
||||
:view view
|
||||
:init-cmd (tui/tick 80)}))
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
Loading (with dots spinner):
|
||||
```
|
||||
╭─────────────────────────╮
|
||||
│ Spinner: dots │
|
||||
│ │
|
||||
│ ⠹ Loading... │
|
||||
╰─────────────────────────╯
|
||||
|
||||
tab: change style space: complete r: restart q: quit
|
||||
```
|
||||
|
||||
Loading (with arrow spinner):
|
||||
```
|
||||
╭─────────────────────────╮
|
||||
│ Spinner: arrow │
|
||||
│ │
|
||||
│ → Loading... │
|
||||
╰─────────────────────────╯
|
||||
|
||||
tab: change style space: complete r: restart q: quit
|
||||
```
|
||||
|
||||
Done:
|
||||
```
|
||||
╭─────────────────────────╮
|
||||
│ Spinner: dots │
|
||||
│ │
|
||||
│ ✓ Done! │
|
||||
╰─────────────────────────╯
|
||||
|
||||
tab: change style space: complete r: restart q: quit
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
1. **Fast ticks**: Use short intervals (80-100ms) for smooth animation
|
||||
2. **Frame-based animation**: Cycle through an array of frames
|
||||
3. **Multiple styles**: Store different spinner configurations
|
||||
4. **Stop animation**: Stop scheduling ticks when done
|
||||
|
||||
---
|
||||
|
||||
## Views (Multi-Screen)
|
||||
|
||||
A multi-view application demonstrating state machine navigation.
|
||||
|
||||
**Features:**
|
||||
- Multiple views (menu, detail, confirm dialog)
|
||||
- Navigation between views
|
||||
- Confirmation dialog for quit
|
||||
- Different box styles per view
|
||||
|
||||
### Code
|
||||
|
||||
```clojure
|
||||
(ns examples.views
|
||||
(:require [tui.simple :as tui]))
|
||||
|
||||
(def menu-items
|
||||
[{:id :profile :label "View Profile" :desc "See your user information"}
|
||||
{:id :settings :label "Settings" :desc "Configure preferences"}
|
||||
{:id :help :label "Help" :desc "Get assistance"}])
|
||||
|
||||
(defn handle-menu [model msg]
|
||||
(cond
|
||||
(tui/key= msg "q")
|
||||
[(assoc model :view :confirm) nil]
|
||||
|
||||
(or (tui/key= msg "k") (tui/key= msg :up))
|
||||
[(update model :cursor #(max 0 (dec %))) nil]
|
||||
|
||||
(or (tui/key= msg "j") (tui/key= msg :down))
|
||||
[(update model :cursor #(min (dec (count menu-items)) (inc %))) nil]
|
||||
|
||||
(tui/key= msg :enter)
|
||||
(let [item (nth menu-items (:cursor model))]
|
||||
[(assoc model :view :detail :selected item) nil])
|
||||
|
||||
:else
|
||||
[model nil]))
|
||||
|
||||
(defn handle-detail [model msg]
|
||||
(cond
|
||||
(or (tui/key= msg "b")
|
||||
(tui/key= msg :escape))
|
||||
[(assoc model :view :menu) nil]
|
||||
|
||||
(tui/key= msg "q")
|
||||
[(assoc model :view :confirm) nil]
|
||||
|
||||
:else
|
||||
[model nil]))
|
||||
|
||||
(defn handle-confirm [model msg]
|
||||
(cond
|
||||
(tui/key= msg "y")
|
||||
[model tui/quit]
|
||||
|
||||
(or (tui/key= msg "n")
|
||||
(tui/key= msg :escape))
|
||||
[(assoc model :view :menu) nil]
|
||||
|
||||
:else
|
||||
[model nil]))
|
||||
|
||||
(defn update-fn [model msg]
|
||||
(case (:view model)
|
||||
:menu (handle-menu model msg)
|
||||
:detail (handle-detail model msg)
|
||||
:confirm (handle-confirm model msg)))
|
||||
|
||||
(defn menu-view [{:keys [cursor]}]
|
||||
[:col
|
||||
[:box {:border :rounded :title "Main Menu" :padding 1}
|
||||
[:col
|
||||
(for [[idx item] (map-indexed vector menu-items)]
|
||||
(let [selected (= idx cursor)]
|
||||
[:row
|
||||
[:text (if selected "> " " ")]
|
||||
[:text {:fg (if selected :cyan :white)
|
||||
:bold selected}
|
||||
(:label item)]]))]]
|
||||
[:space {:height 1}]
|
||||
[:text {:fg :gray} "j/k: navigate enter: select q: quit"]])
|
||||
|
||||
(defn detail-view [{:keys [selected]}]
|
||||
[:col
|
||||
[:box {:border :double :title (:label selected) :padding 1}
|
||||
[:col
|
||||
[:text {:bold true} "Description:"]
|
||||
[:text (:desc selected)]
|
||||
[:space {:height 1}]
|
||||
[:text {:fg :gray :dim true} (str "ID: " (name (:id selected)))]]]
|
||||
[:space {:height 1}]
|
||||
[:text {:fg :gray} "b/esc: back q: quit"]])
|
||||
|
||||
(defn confirm-view [model]
|
||||
[:col
|
||||
[:box {:border :rounded :title "Confirm" :padding 1}
|
||||
[:col
|
||||
[:text "Are you sure you want to quit?"]
|
||||
[:space {:height 1}]
|
||||
[:row {:gap 2}
|
||||
[:text {:fg :green} "[Y]es"]
|
||||
[:text {:fg :red} "[N]o"]]]]
|
||||
[:space {:height 1}]
|
||||
[:text {:fg :gray} "y: quit n: cancel"]])
|
||||
|
||||
(defn view [model]
|
||||
(case (:view model)
|
||||
:menu (menu-view model)
|
||||
:detail (detail-view model)
|
||||
:confirm (confirm-view model)))
|
||||
|
||||
(defn -main [& args]
|
||||
(tui/run {:init {:view :menu :cursor 0 :selected nil}
|
||||
:update update-fn
|
||||
:view view}))
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
Menu view:
|
||||
```
|
||||
╭─Main Menu─────────────────────╮
|
||||
│ │
|
||||
│ > View Profile │
|
||||
│ Settings │
|
||||
│ Help │
|
||||
│ │
|
||||
╰───────────────────────────────╯
|
||||
|
||||
j/k: navigate enter: select q: quit
|
||||
```
|
||||
|
||||
Detail view (Profile selected):
|
||||
```
|
||||
╔═View Profile══════════════════╗
|
||||
║ ║
|
||||
║ Description: ║
|
||||
║ See your user information ║
|
||||
║ ║
|
||||
║ ID: profile ║
|
||||
║ ║
|
||||
╚═══════════════════════════════╝
|
||||
|
||||
b/esc: back q: quit
|
||||
```
|
||||
|
||||
Confirm dialog:
|
||||
```
|
||||
╭─Confirm───────────────────────╮
|
||||
│ │
|
||||
│ Are you sure you want to │
|
||||
│ quit? │
|
||||
│ │
|
||||
│ [Y]es [N]o │
|
||||
│ │
|
||||
╰───────────────────────────────╯
|
||||
|
||||
y: quit n: cancel
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
1. **State machine pattern**: Use `:view` key to track current screen
|
||||
2. **Separate handlers**: Break update function into per-view handlers
|
||||
3. **View dispatch**: Use `case` in both update and view functions
|
||||
4. **Confirmation dialog**: Intercept quit to show confirmation
|
||||
|
||||
---
|
||||
|
||||
## HTTP (Async Requests)
|
||||
|
||||
An async HTTP request example demonstrating custom commands.
|
||||
|
||||
**Features:**
|
||||
- Custom async command function
|
||||
- State machine (idle/loading/success/error)
|
||||
- Error handling
|
||||
- URL input display
|
||||
|
||||
### Code
|
||||
|
||||
```clojure
|
||||
(ns examples.http
|
||||
(:require [tui.core :as tui]
|
||||
[clj-http.client :as http]))
|
||||
|
||||
(def api-url "https://httpbin.org/get")
|
||||
|
||||
(defn fetch-data []
|
||||
(fn []
|
||||
(try
|
||||
(let [response (http/get api-url {:as :json})]
|
||||
{:type :success :data (:body response)})
|
||||
(catch Exception e
|
||||
{:type :error :message (.getMessage e)}))))
|
||||
|
||||
(defn update-fn [model msg]
|
||||
(cond
|
||||
;; Quit
|
||||
(tui/key= msg "q")
|
||||
[model tui/quit]
|
||||
|
||||
;; Start fetch
|
||||
(tui/key= msg :enter)
|
||||
(if (= (:status model) :loading)
|
||||
[model nil]
|
||||
[{:status :loading} (fetch-data)])
|
||||
|
||||
;; Reset
|
||||
(tui/key= msg "r")
|
||||
[{:status :idle} nil]
|
||||
|
||||
;; Handle success
|
||||
(= (:type msg) :success)
|
||||
[{:status :success :data (:data msg)} nil]
|
||||
|
||||
;; Handle error
|
||||
(= (:type msg) :error)
|
||||
[{:status :error :error (:message msg)} nil]
|
||||
|
||||
:else
|
||||
[model nil]))
|
||||
|
||||
(defn view [{:keys [status data error]}]
|
||||
[:col
|
||||
[:box {:border :rounded :title "HTTP Example" :padding 1}
|
||||
[:col
|
||||
[:row
|
||||
[:text {:bold true} "URL: "]
|
||||
[:text {:fg :cyan} api-url]]
|
||||
[:space {:height 1}]
|
||||
[:row
|
||||
[:text {:bold true} "Status: "]
|
||||
[:text {:fg (case status
|
||||
:idle :gray
|
||||
:loading :yellow
|
||||
:success :green
|
||||
:error :red)}
|
||||
(name status)]]
|
||||
[:space {:height 1}]
|
||||
(case status
|
||||
:idle
|
||||
[:text {:fg :gray} "Press Enter to fetch data"]
|
||||
|
||||
:loading
|
||||
[:text {:fg :yellow} "Fetching..."]
|
||||
|
||||
:success
|
||||
[:col
|
||||
[:text {:fg :green} "Response received!"]
|
||||
[:text {:fg :gray :dim true}
|
||||
(str "Origin: " (get data "origin"))]]
|
||||
|
||||
:error
|
||||
[:col
|
||||
[:text {:fg :red} "Request failed"]
|
||||
[:text {:fg :red :dim true} error]])]]
|
||||
[:space {:height 1}]
|
||||
[:text {:fg :gray} "enter: fetch r: reset q: quit"]])
|
||||
|
||||
(defn -main [& args]
|
||||
(tui/run {:init {:status :idle}
|
||||
:update update-fn
|
||||
:view view}))
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
Idle state:
|
||||
```
|
||||
╭─HTTP Example──────────────────────────╮
|
||||
│ │
|
||||
│ URL: https://httpbin.org/get │
|
||||
│ │
|
||||
│ Status: idle │
|
||||
│ │
|
||||
│ Press Enter to fetch data │
|
||||
│ │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
enter: fetch r: reset q: quit
|
||||
```
|
||||
|
||||
Loading state:
|
||||
```
|
||||
╭─HTTP Example──────────────────────────╮
|
||||
│ │
|
||||
│ URL: https://httpbin.org/get │
|
||||
│ │
|
||||
│ Status: loading │
|
||||
│ │
|
||||
│ Fetching... │
|
||||
│ │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
enter: fetch r: reset q: quit
|
||||
```
|
||||
|
||||
Success state:
|
||||
```
|
||||
╭─HTTP Example──────────────────────────╮
|
||||
│ │
|
||||
│ URL: https://httpbin.org/get │
|
||||
│ │
|
||||
│ Status: success │
|
||||
│ │
|
||||
│ Response received! │
|
||||
│ Origin: 203.0.113.42 │
|
||||
│ │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
enter: fetch r: reset q: quit
|
||||
```
|
||||
|
||||
Error state:
|
||||
```
|
||||
╭─HTTP Example──────────────────────────╮
|
||||
│ │
|
||||
│ URL: https://httpbin.org/get │
|
||||
│ │
|
||||
│ Status: error │
|
||||
│ │
|
||||
│ Request failed │
|
||||
│ Connection refused │
|
||||
│ │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
enter: fetch r: reset q: quit
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
1. **Custom async command**: Return a function `(fn [] result)`
|
||||
2. **State machine**: Track loading/success/error states
|
||||
3. **Error handling**: Wrap async operation in try/catch
|
||||
4. **Message types**: Use maps with `:type` key for complex messages
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: State Machine
|
||||
|
||||
```clojure
|
||||
(defn update-fn [model msg]
|
||||
(case (:state model)
|
||||
:menu (handle-menu model msg)
|
||||
:game (handle-game model msg)
|
||||
:paused (handle-paused model msg)
|
||||
:gameover (handle-gameover model msg)))
|
||||
```
|
||||
|
||||
### Pattern: Cursor Navigation
|
||||
|
||||
```clojure
|
||||
(defn move-up [model]
|
||||
(update model :cursor #(max 0 (dec %))))
|
||||
|
||||
(defn move-down [model items]
|
||||
(update model :cursor #(min (dec (count items)) (inc %))))
|
||||
```
|
||||
|
||||
### Pattern: Toggle Selection
|
||||
|
||||
```clojure
|
||||
(defn toggle [model item]
|
||||
(update model :selected
|
||||
#(if (contains? % item)
|
||||
(disj % item)
|
||||
(conj % item))))
|
||||
```
|
||||
|
||||
### Pattern: Confirmation Dialog
|
||||
|
||||
```clojure
|
||||
(defn update-fn [model msg]
|
||||
(if (:confirming model)
|
||||
(cond
|
||||
(tui/key= msg "y") [model tui/quit]
|
||||
(tui/key= msg "n") [(assoc model :confirming false) nil]
|
||||
:else [model nil])
|
||||
(if (tui/key= msg "q")
|
||||
[(assoc model :confirming true) nil]
|
||||
;; ... normal handling
|
||||
)))
|
||||
```
|
||||
|
||||
### Pattern: Loading States
|
||||
|
||||
```clojure
|
||||
(defn view [{:keys [loading error data]}]
|
||||
(cond
|
||||
loading [:text "Loading..."]
|
||||
error [:text {:fg :red} error]
|
||||
data [:text (str "Data: " data)]
|
||||
:else [:text "Press Enter to load"]))
|
||||
```
|
||||
@@ -0,0 +1,380 @@
|
||||
# Getting Started
|
||||
|
||||
This guide will walk you through creating your first TUI application with Clojure TUI.
|
||||
|
||||
## Installation
|
||||
|
||||
### Using deps.edn
|
||||
|
||||
Add the library to your `deps.edn`:
|
||||
|
||||
```clojure
|
||||
{:deps {io.github.yourname/clojure-tui {:git/tag "v0.1.0" :git/sha "..."}}}
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
- **Clojure 1.11+** for full async runtime (`tui.core`)
|
||||
- **Babashka** for simple sync runtime (`tui.simple`)
|
||||
- A terminal that supports ANSI escape codes (most modern terminals)
|
||||
|
||||
## Your First Application
|
||||
|
||||
Let's build a simple "Hello World" TUI application.
|
||||
|
||||
### Step 1: Create the Project
|
||||
|
||||
```bash
|
||||
mkdir my-tui-app
|
||||
cd my-tui-app
|
||||
```
|
||||
|
||||
Create a `deps.edn`:
|
||||
|
||||
```clojure
|
||||
{:deps {io.github.yourname/clojure-tui {:git/tag "v0.1.0" :git/sha "..."}}}
|
||||
```
|
||||
|
||||
### Step 2: Write the Application
|
||||
|
||||
Create `src/myapp/core.clj`:
|
||||
|
||||
```clojure
|
||||
(ns myapp.core
|
||||
(:require [tui.simple :as tui]))
|
||||
|
||||
;; 1. Model - the application state
|
||||
(def initial-model
|
||||
{:message "Hello, TUI!"})
|
||||
|
||||
;; 2. Update - handle messages and return [new-model command]
|
||||
(defn update-fn [model msg]
|
||||
(cond
|
||||
;; Quit on 'q' key
|
||||
(tui/key= msg "q")
|
||||
[model tui/quit]
|
||||
|
||||
;; Default: no change
|
||||
:else
|
||||
[model nil]))
|
||||
|
||||
;; 3. View - render the model as hiccup
|
||||
(defn view [{:keys [message]}]
|
||||
[:col
|
||||
[:text {:fg :cyan :bold true} message]
|
||||
[:space {:height 1}]
|
||||
[:text {:fg :gray} "Press q to quit"]])
|
||||
|
||||
;; Run the application
|
||||
(defn -main [& args]
|
||||
(tui/run {:init initial-model
|
||||
:update update-fn
|
||||
:view view}))
|
||||
```
|
||||
|
||||
### Step 3: Run It
|
||||
|
||||
```bash
|
||||
clojure -M -m myapp.core
|
||||
```
|
||||
|
||||
You'll see:
|
||||
|
||||
```
|
||||
Hello, TUI!
|
||||
|
||||
Press q to quit
|
||||
```
|
||||
|
||||
Press `q` to exit.
|
||||
|
||||
## Understanding the Elm Architecture
|
||||
|
||||
Clojure TUI uses the [Elm Architecture](https://guide.elm-lang.org/architecture/), a pattern for building interactive applications.
|
||||
|
||||
### The Three Parts
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Model │
|
||||
│ (state) │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ │
|
||||
v │
|
||||
┌─────────────┐ ┌──────┴──────┐
|
||||
│ View │ │ Update │
|
||||
│ (render UI) │ │(handle msg) │
|
||||
└──────┬──────┘ └──────┬──────┘
|
||||
│ ^
|
||||
│ │
|
||||
v │
|
||||
┌─────────────┐ ┌──────┴──────┐
|
||||
│ Screen │ │ Message │
|
||||
│ (output) │ │ (input) │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
1. **Model**: Your application state. Can be any Clojure data structure.
|
||||
|
||||
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.
|
||||
|
||||
### The Flow
|
||||
|
||||
1. The runtime renders the initial view
|
||||
2. User presses a key
|
||||
3. The key is parsed into a message like `[:key {:char \a}]`
|
||||
4. The `update` function receives the model and message
|
||||
5. `update` returns a new model and optional command
|
||||
6. The view is re-rendered with the new model
|
||||
7. If a command was returned, it's executed (may produce more messages)
|
||||
8. Repeat until `tui/quit` is returned
|
||||
|
||||
## Handling Input
|
||||
|
||||
Keys are represented as messages in the format `[:key ...]`.
|
||||
|
||||
### Key Message Examples
|
||||
|
||||
```clojure
|
||||
[:key {:char \a}] ;; Regular character
|
||||
[:key {:char \Z}] ;; Uppercase character
|
||||
[:key :enter] ;; Enter key
|
||||
[:key :up] ;; Arrow up
|
||||
[:key {:ctrl true :char \c}] ;; Ctrl+C
|
||||
[:key {:alt true :char \x}] ;; Alt+X
|
||||
```
|
||||
|
||||
### Matching Keys
|
||||
|
||||
Use `tui/key=` to match keys in your update function:
|
||||
|
||||
```clojure
|
||||
(defn update-fn [model msg]
|
||||
(cond
|
||||
;; Match character 'q'
|
||||
(tui/key= msg "q") [model tui/quit]
|
||||
|
||||
;; Match Enter key
|
||||
(tui/key= msg :enter) [(handle-enter model) nil]
|
||||
|
||||
;; Match arrow keys
|
||||
(tui/key= msg :up) [(move-up model) nil]
|
||||
(tui/key= msg :down) [(move-down model) nil]
|
||||
|
||||
;; Match Ctrl+C
|
||||
(tui/key= msg [:ctrl \c]) [model tui/quit]
|
||||
|
||||
;; Default
|
||||
:else [model nil]))
|
||||
```
|
||||
|
||||
### Special Keys
|
||||
|
||||
| Key | Pattern |
|
||||
|-----|---------|
|
||||
| Enter | `:enter` |
|
||||
| Escape | `:escape` |
|
||||
| Tab | `:tab` |
|
||||
| Backspace | `:backspace` |
|
||||
| Arrow Up | `:up` |
|
||||
| Arrow Down | `:down` |
|
||||
| Arrow Left | `:left` |
|
||||
| Arrow Right | `:right` |
|
||||
| Home | `:home` |
|
||||
| End | `:end` |
|
||||
| Page Up | `:page-up` |
|
||||
| Page Down | `:page-down` |
|
||||
| Delete | `:delete` |
|
||||
| Insert | `:insert` |
|
||||
| F1-F12 | `:f1` through `:f12` |
|
||||
|
||||
## Building a Counter
|
||||
|
||||
Let's build something more interactive: a counter.
|
||||
|
||||
```clojure
|
||||
(ns myapp.counter
|
||||
(:require [tui.simple :as tui]))
|
||||
|
||||
(defn update-fn [model msg]
|
||||
(cond
|
||||
;; Quit
|
||||
(tui/key= msg "q") [model tui/quit]
|
||||
|
||||
;; Increment with 'k' or up arrow
|
||||
(or (tui/key= msg "k")
|
||||
(tui/key= msg :up))
|
||||
[(inc model) nil]
|
||||
|
||||
;; Decrement with 'j' or down arrow
|
||||
(or (tui/key= msg "j")
|
||||
(tui/key= msg :down))
|
||||
[(dec model) nil]
|
||||
|
||||
;; Reset with 'r'
|
||||
(tui/key= msg "r") [0 nil]
|
||||
|
||||
:else [model nil]))
|
||||
|
||||
(defn view [count]
|
||||
[:col
|
||||
[:box {:border :rounded :padding [0 2]}
|
||||
[:row
|
||||
[:text "Count: "]
|
||||
[:text {:fg (cond
|
||||
(pos? count) :green
|
||||
(neg? count) :red
|
||||
:else :yellow)
|
||||
:bold true}
|
||||
(str count)]]]
|
||||
[:space {:height 1}]
|
||||
[:text {:fg :gray} "j/k or arrows: change value"]
|
||||
[:text {:fg :gray} "r: reset q: quit"]])
|
||||
|
||||
(defn -main [& args]
|
||||
(tui/run {:init 0
|
||||
:update update-fn
|
||||
:view view}))
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
╭────────────────╮
|
||||
│ Count: 0 │
|
||||
╰────────────────╯
|
||||
|
||||
j/k or arrows: change value
|
||||
r: reset q: quit
|
||||
```
|
||||
|
||||
After pressing `k` a few times:
|
||||
```
|
||||
╭────────────────╮
|
||||
│ Count: 3 │
|
||||
╰────────────────╯
|
||||
|
||||
j/k or arrows: change value
|
||||
r: reset q: quit
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
Commands are how your application performs side effects.
|
||||
|
||||
### Available Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `tui/quit` | Exit the application |
|
||||
| `(tui/tick ms)` | Send `:tick` message after delay |
|
||||
| `(tui/batch cmd1 cmd2 ...)` | Run commands in parallel |
|
||||
| `(tui/sequentially cmd1 cmd2 ...)` | Run commands in sequence |
|
||||
|
||||
### Returning Commands
|
||||
|
||||
Commands are returned as the second element of the update function's return vector:
|
||||
|
||||
```clojure
|
||||
(defn update-fn [model msg]
|
||||
(cond
|
||||
;; Return quit command
|
||||
(tui/key= msg "q")
|
||||
[model tui/quit]
|
||||
|
||||
;; Return tick command (async runtime only)
|
||||
(tui/key= msg "s")
|
||||
[{:started true} (tui/tick 1000)]
|
||||
|
||||
;; No command
|
||||
:else
|
||||
[model nil]))
|
||||
```
|
||||
|
||||
### Timer Example (Async Runtime)
|
||||
|
||||
```clojure
|
||||
(ns myapp.timer
|
||||
(:require [tui.core :as tui])) ;; Note: tui.core for async
|
||||
|
||||
(defn update-fn [model msg]
|
||||
(cond
|
||||
(tui/key= msg "q")
|
||||
[model tui/quit]
|
||||
|
||||
;; Handle tick message
|
||||
(= msg :tick)
|
||||
(let [new-count (dec (:count model))]
|
||||
(if (pos? new-count)
|
||||
[{:count new-count} (tui/tick 1000)]
|
||||
[{:count 0 :done true} nil]))
|
||||
|
||||
:else
|
||||
[model nil]))
|
||||
|
||||
(defn view [{:keys [count done]}]
|
||||
[:col
|
||||
(if done
|
||||
[:text {:fg :green :bold true} "Time's up!"]
|
||||
[:text {:bold true} (str "Countdown: " count)])
|
||||
[:text {:fg :gray} "q: quit"]])
|
||||
|
||||
(defn -main [& args]
|
||||
(tui/run {:init {:count 10 :done false}
|
||||
:update update-fn
|
||||
:view view
|
||||
: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:
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `:init` | Initial model (required) | - |
|
||||
| `:update` | Update function (required) | - |
|
||||
| `:view` | View function (required) | - |
|
||||
| `:init-cmd` | Initial command to run | `nil` |
|
||||
| `:fps` | Frames per second (core only) | `60` |
|
||||
| `:alt-screen` | Use alternate screen buffer | `true` |
|
||||
|
||||
### Alternate Screen
|
||||
|
||||
By default, applications use the alternate screen buffer. This means:
|
||||
- Your application gets a clean screen
|
||||
- When you quit, the original terminal content is restored
|
||||
|
||||
To disable this:
|
||||
|
||||
```clojure
|
||||
(tui/run {:init model
|
||||
:update update-fn
|
||||
:view view
|
||||
:alt-screen false})
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Hiccup Views](hiccup-views.md) - Learn about all view elements and styling
|
||||
- [API Reference](api-reference.md) - Complete API documentation
|
||||
- [Examples](examples.md) - Study annotated example applications
|
||||
@@ -0,0 +1,542 @@
|
||||
# Hiccup Views
|
||||
|
||||
Clojure TUI uses Hiccup-style syntax for declarative UI definitions. Views are pure functions that return nested data structures representing the UI.
|
||||
|
||||
## Basic Syntax
|
||||
|
||||
Views use vectors with keywords as element tags:
|
||||
|
||||
```clojure
|
||||
[:element-type {attributes} children...]
|
||||
```
|
||||
|
||||
Examples:
|
||||
```clojure
|
||||
[:text "Hello"] ;; Simple text
|
||||
[:text {:fg :red} "Error"] ;; Text with attributes
|
||||
[:col [:text "Line 1"] [:text "Line 2"]] ;; Nested elements
|
||||
```
|
||||
|
||||
## Elements
|
||||
|
||||
### `:text` - Styled Text
|
||||
|
||||
The basic building block for displaying text.
|
||||
|
||||
```clojure
|
||||
;; Simple text
|
||||
[:text "Hello, World!"]
|
||||
|
||||
;; Styled text
|
||||
[:text {:fg :cyan :bold true} "Important"]
|
||||
|
||||
;; Multiple style attributes
|
||||
[:text {:fg :white :bg :red :bold true :underline true} "Alert!"]
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
Hello, World!
|
||||
Important
|
||||
Alert!
|
||||
```
|
||||
|
||||
**Attributes:**
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `:fg` | keyword/int | Foreground color |
|
||||
| `:bg` | keyword/int | Background color |
|
||||
| `:bold` | boolean | Bold text |
|
||||
| `:dim` | boolean | Dimmed text |
|
||||
| `:italic` | boolean | Italic text |
|
||||
| `:underline` | boolean | Underlined text |
|
||||
| `:inverse` | boolean | Swap fg/bg colors |
|
||||
| `:strike` | boolean | Strikethrough text |
|
||||
|
||||
### `:row` - Horizontal Layout
|
||||
|
||||
Arranges children horizontally (left to right).
|
||||
|
||||
```clojure
|
||||
;; Basic row
|
||||
[:row "Left" "Middle" "Right"]
|
||||
|
||||
;; Row with gap
|
||||
[:row {:gap 2} "A" "B" "C"]
|
||||
|
||||
;; Nested elements in row
|
||||
[:row
|
||||
[:text {:fg :green} "Status:"]
|
||||
[:text {:bold true} "OK"]]
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
LeftMiddleRight
|
||||
|
||||
A B C
|
||||
|
||||
Status:OK
|
||||
```
|
||||
|
||||
**Attributes:**
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `:gap` | integer | Spaces between children (default: 0) |
|
||||
|
||||
### `:col` - Vertical Layout
|
||||
|
||||
Arranges children vertically (top to bottom).
|
||||
|
||||
```clojure
|
||||
;; Basic column
|
||||
[:col "Line 1" "Line 2" "Line 3"]
|
||||
|
||||
;; Column with gap
|
||||
[:col {:gap 1}
|
||||
[:text "Section 1"]
|
||||
[:text "Section 2"]]
|
||||
|
||||
;; Nested layouts
|
||||
[:col
|
||||
[:text {:bold true} "Header"]
|
||||
[:row "Col A" "Col B" "Col C"]
|
||||
[:text {:fg :gray} "Footer"]]
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
Line 1
|
||||
Line 2
|
||||
Line 3
|
||||
|
||||
Section 1
|
||||
|
||||
Section 2
|
||||
|
||||
Header
|
||||
Col ACol BCol C
|
||||
Footer
|
||||
```
|
||||
|
||||
**Attributes:**
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `:gap` | integer | Blank lines between children (default: 0) |
|
||||
|
||||
### `:box` - Bordered Container
|
||||
|
||||
Wraps content in a bordered box.
|
||||
|
||||
```clojure
|
||||
;; Simple box
|
||||
[:box "Content"]
|
||||
|
||||
;; Box with title
|
||||
[:box {:title "Settings"} "Options go here"]
|
||||
|
||||
;; Box with padding
|
||||
[:box {:padding 1} "Padded content"]
|
||||
|
||||
;; Box with custom border style
|
||||
[:box {:border :double} "Important!"]
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
╭─────────╮
|
||||
│Content │
|
||||
╰─────────╯
|
||||
|
||||
╭─Settings─╮
|
||||
│Options go here│
|
||||
╰──────────╯
|
||||
|
||||
╭──────────────────╮
|
||||
│ │
|
||||
│ Padded content │
|
||||
│ │
|
||||
╰──────────────────╯
|
||||
|
||||
╔═══════════╗
|
||||
║Important! ║
|
||||
╚═══════════╝
|
||||
```
|
||||
|
||||
**Attributes:**
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `:border` | keyword | Border style (see below) |
|
||||
| `:title` | string | Title in top border |
|
||||
| `:padding` | int/vec | Inner padding (see below) |
|
||||
| `:width` | integer | Fixed width |
|
||||
|
||||
**Border Styles:**
|
||||
|
||||
| Style | Characters | Example |
|
||||
|-------|------------|---------|
|
||||
| `:rounded` | `╭╮╰╯─│` | `╭───╮` (default) |
|
||||
| `:single` | `┌┐└┘─│` | `┌───┐` |
|
||||
| `:double` | `╔╗╚╝═║` | `╔═══╗` |
|
||||
| `:heavy` | `┏┓┗┛━┃` | `┏━━━┓` |
|
||||
| `:ascii` | `++--\|` | `+---+` |
|
||||
|
||||
**Padding:**
|
||||
|
||||
```clojure
|
||||
;; All sides
|
||||
[:box {:padding 2} "Content"]
|
||||
|
||||
;; Vertical and horizontal [v h]
|
||||
[:box {:padding [1 2]} "Content"]
|
||||
|
||||
;; Individual [top right bottom left]
|
||||
[:box {:padding [1 2 1 2]} "Content"]
|
||||
```
|
||||
|
||||
### `:space` - Empty Space
|
||||
|
||||
Creates empty space for layout purposes.
|
||||
|
||||
```clojure
|
||||
;; Default 1x1 space
|
||||
[:space]
|
||||
|
||||
;; Horizontal space
|
||||
[:row "Left" [:space {:width 10}] "Right"]
|
||||
|
||||
;; Vertical space
|
||||
[:col "Top" [:space {:height 3}] "Bottom"]
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
Left Right
|
||||
|
||||
Top
|
||||
|
||||
|
||||
|
||||
Bottom
|
||||
```
|
||||
|
||||
**Attributes:**
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `:width` | integer | Width in characters (default: 1) |
|
||||
| `:height` | integer | Height in lines (default: 1) |
|
||||
|
||||
## Colors
|
||||
|
||||
### Named Colors
|
||||
|
||||
Basic 16-color palette supported by all terminals:
|
||||
|
||||
| Color | Keyword | Bright Version |
|
||||
|-------|---------|----------------|
|
||||
| Black | `:black` | `:bright-black` |
|
||||
| Red | `:red` | `:bright-red` |
|
||||
| Green | `:green` | `:bright-green` |
|
||||
| Yellow | `:yellow` | `:bright-yellow` |
|
||||
| Blue | `:blue` | `:bright-blue` |
|
||||
| Magenta | `:magenta` | `:bright-magenta` |
|
||||
| Cyan | `:cyan` | `:bright-cyan` |
|
||||
| White | `:white` | `:bright-white` |
|
||||
| Default | `:default` | - |
|
||||
|
||||
Aliases: `:gray` and `:grey` map to `:bright-black`
|
||||
|
||||
```clojure
|
||||
[:text {:fg :red} "Error"]
|
||||
[:text {:fg :bright-green} "Success"]
|
||||
[:text {:bg :blue :fg :white} "Highlighted"]
|
||||
```
|
||||
|
||||
### 256 Colors
|
||||
|
||||
Use integers 0-255 for extended color support:
|
||||
|
||||
```clojure
|
||||
[:text {:fg 208} "Orange (256-color)"]
|
||||
[:text {:bg 236} "Dark gray background"]
|
||||
```
|
||||
|
||||
**Color ranges:**
|
||||
- 0-7: Standard colors
|
||||
- 8-15: Bright colors
|
||||
- 16-231: 6x6x6 color cube
|
||||
- 232-255: Grayscale (dark to light)
|
||||
|
||||
### True Color (24-bit)
|
||||
|
||||
For true color, use the `tui.ansi` namespace directly:
|
||||
|
||||
```clojure
|
||||
(require '[tui.ansi :as ansi])
|
||||
|
||||
[:text (ansi/fg-rgb 255 128 0 "Orange text")]
|
||||
[:text (ansi/bg-rgb 30 30 30 "Dark background")]
|
||||
```
|
||||
|
||||
## Text Styles
|
||||
|
||||
Combine multiple styles:
|
||||
|
||||
```clojure
|
||||
[:text {:bold true :underline true} "Bold and underlined"]
|
||||
[:text {:fg :red :bold true :inverse true} "Inverted error"]
|
||||
[:text {:dim true :italic true} "Subtle italic"]
|
||||
```
|
||||
|
||||
**Available styles:**
|
||||
|
||||
| Style | Attribute | Description |
|
||||
|-------|-----------|-------------|
|
||||
| Bold | `:bold true` | Heavier font weight |
|
||||
| Dim | `:dim true` | Lighter/faded text |
|
||||
| Italic | `:italic true` | Slanted text |
|
||||
| Underline | `:underline true` | Line under text |
|
||||
| Inverse | `:inverse true` | Swap foreground/background |
|
||||
| Strikethrough | `:strike true` | Line through text |
|
||||
|
||||
## Layout Examples
|
||||
|
||||
### Two-Column Layout
|
||||
|
||||
```clojure
|
||||
[:row {:gap 4}
|
||||
[:col
|
||||
[:text {:bold true} "Left Column"]
|
||||
[:text "Item 1"]
|
||||
[:text "Item 2"]]
|
||||
[:col
|
||||
[:text {:bold true} "Right Column"]
|
||||
[:text "Item A"]
|
||||
[:text "Item B"]]]
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
Left Column Right Column
|
||||
Item 1 Item A
|
||||
Item 2 Item B
|
||||
```
|
||||
|
||||
### Nested Boxes
|
||||
|
||||
```clojure
|
||||
[:box {:title "Outer" :padding 1}
|
||||
[:row {:gap 2}
|
||||
[:box {:border :single :title "Box A"}
|
||||
[:text "Content A"]]
|
||||
[:box {:border :single :title "Box B"}
|
||||
[:text "Content B"]]]]
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
╭─Outer────────────────────────────╮
|
||||
│ │
|
||||
│ ┌─Box A─────┐ ┌─Box B─────┐ │
|
||||
│ │Content A │ │Content B │ │
|
||||
│ └───────────┘ └───────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────╯
|
||||
```
|
||||
|
||||
### Status Bar
|
||||
|
||||
```clojure
|
||||
[:col
|
||||
[:box {:border :rounded :padding [0 1]}
|
||||
[:text {:bold true} "My Application"]]
|
||||
[:space {:height 1}]
|
||||
[:text "Main content here..."]
|
||||
[:space {:height 1}]
|
||||
[:row {:gap 2}
|
||||
[:text {:fg :gray} "Status: Ready"]
|
||||
[:text {:fg :gray} "|"]
|
||||
[:text {:fg :gray} "Press q to quit"]]]
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
╭──────────────────╮
|
||||
│ My Application │
|
||||
╰──────────────────╯
|
||||
|
||||
Main content here...
|
||||
|
||||
Status: Ready | Press q to quit
|
||||
```
|
||||
|
||||
### Menu with Selection
|
||||
|
||||
```clojure
|
||||
(defn menu-item [label selected?]
|
||||
[:row
|
||||
[:text (if selected? "> " " ")]
|
||||
[:text {:fg (if selected? :cyan :white)
|
||||
:bold selected?}
|
||||
label]])
|
||||
|
||||
(defn view [{:keys [items cursor]}]
|
||||
[:col
|
||||
[:text {:bold true} "Select an option:"]
|
||||
[:space {:height 1}]
|
||||
[:col {:gap 0}
|
||||
(for [[idx item] (map-indexed vector items)]
|
||||
(menu-item item (= idx cursor)))]])
|
||||
```
|
||||
|
||||
**Output (cursor on second item):**
|
||||
```
|
||||
Select an option:
|
||||
|
||||
First Option
|
||||
> Second Option
|
||||
Third Option
|
||||
```
|
||||
|
||||
### Progress Indicator
|
||||
|
||||
```clojure
|
||||
(defn progress-bar [percent width]
|
||||
(let [filled (int (* width (/ percent 100)))
|
||||
empty (- width filled)]
|
||||
[:row
|
||||
[:text "["]
|
||||
[:text {:fg :green} (apply str (repeat filled "="))]
|
||||
[:text {:fg :gray} (apply str (repeat empty "-"))]
|
||||
[:text "]"]
|
||||
[:text " "]
|
||||
[:text (str percent "%")]]))
|
||||
|
||||
(defn view [{:keys [progress]}]
|
||||
[:col
|
||||
[:text "Downloading..."]
|
||||
(progress-bar progress 20)])
|
||||
```
|
||||
|
||||
**Output (at 65%):**
|
||||
```
|
||||
Downloading...
|
||||
[=============-------] 65%
|
||||
```
|
||||
|
||||
## Helper Functions
|
||||
|
||||
The `tui.render` namespace provides helper functions:
|
||||
|
||||
```clojure
|
||||
(require '[tui.render :refer [text row col box]])
|
||||
|
||||
;; These are equivalent:
|
||||
[:text {:fg :red} "Error"]
|
||||
(text {:fg :red} "Error")
|
||||
|
||||
[:row {:gap 2} "A" "B"]
|
||||
(row {:gap 2} "A" "B")
|
||||
|
||||
[:col [:text "Line 1"] [:text "Line 2"]]
|
||||
(col (text "Line 1") (text "Line 2"))
|
||||
|
||||
[:box {:title "Info"} "Content"]
|
||||
(box {:title "Info"} "Content")
|
||||
```
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
Use standard Clojure conditionals:
|
||||
|
||||
```clojure
|
||||
(defn view [{:keys [loading? error data]}]
|
||||
[:col
|
||||
[:text {:bold true} "Status"]
|
||||
(cond
|
||||
loading?
|
||||
[:text {:fg :yellow} "Loading..."]
|
||||
|
||||
error
|
||||
[:text {:fg :red} (str "Error: " error)]
|
||||
|
||||
:else
|
||||
[:text {:fg :green} (str "Data: " data)])])
|
||||
```
|
||||
|
||||
## Dynamic Views with `for`
|
||||
|
||||
Generate repeated elements:
|
||||
|
||||
```clojure
|
||||
(defn view [{:keys [items selected]}]
|
||||
[:col
|
||||
(for [[idx item] (map-indexed vector items)]
|
||||
[:row
|
||||
[:text (if (= idx selected) "> " " ")]
|
||||
[:text {:fg (if (= idx selected) :cyan :white)} item]])])
|
||||
```
|
||||
|
||||
## String Shortcuts
|
||||
|
||||
Plain strings are automatically wrapped in `:text`:
|
||||
|
||||
```clojure
|
||||
;; These are equivalent:
|
||||
[:col "Line 1" "Line 2"]
|
||||
[:col [:text "Line 1"] [:text "Line 2"]]
|
||||
|
||||
;; In rows too:
|
||||
[:row "A" "B" "C"]
|
||||
[:row [:text "A"] [:text "B"] [:text "C"]]
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Styled Labels
|
||||
|
||||
```clojure
|
||||
(defn label [text]
|
||||
[:text {:fg :gray} (str text ": ")])
|
||||
|
||||
(defn value [text]
|
||||
[:text {:fg :white :bold true} text])
|
||||
|
||||
[:row (label "Name") (value "John")]
|
||||
```
|
||||
|
||||
### Conditional Styling
|
||||
|
||||
```clojure
|
||||
(defn status-text [status]
|
||||
[:text {:fg (case status
|
||||
:ok :green
|
||||
:warning :yellow
|
||||
:error :red
|
||||
:white)
|
||||
:bold (= status :error)}
|
||||
(name status)])
|
||||
```
|
||||
|
||||
### Reusable Components
|
||||
|
||||
```clojure
|
||||
(defn card [{:keys [title]} & children]
|
||||
[:box {:border :rounded :title title :padding [0 1]}
|
||||
(into [:col] children)])
|
||||
|
||||
;; Usage
|
||||
(card {:title "User Info"}
|
||||
[:row (label "Name") (value "Alice")]
|
||||
[:row (label "Email") (value "alice@example.com")])
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [API Reference](api-reference.md) - Complete API documentation
|
||||
- [Examples](examples.md) - Full example applications
|
||||
Reference in New Issue
Block a user