diff --git a/README.md b/README.md index 79039d9..434bde1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..a78f47a --- /dev/null +++ b/docs/api-reference.md @@ -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 | diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..aec371f --- /dev/null +++ b/docs/examples.md @@ -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"])) +``` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..9156223 --- /dev/null +++ b/docs/getting-started.md @@ -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 diff --git a/docs/hiccup-views.md b/docs/hiccup-views.md new file mode 100644 index 0000000..0f2504a --- /dev/null +++ b/docs/hiccup-views.md @@ -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