From a990076b034e4edcedbfc5ef314072b1ff517157 Mon Sep 17 00:00:00 2001 From: Adam Jeniski Date: Wed, 21 Jan 2026 01:16:37 -0500 Subject: [PATCH] init --- .gitignore | 1 + README.md | 152 +++++ bb.edn | 20 + bubbletea-guide.md | 1091 +++++++++++++++++++++++++++++++++++ deps.edn | 11 + examples/counter.clj | 56 ++ examples/http.clj | 111 ++++ examples/list_selection.clj | 92 +++ examples/spinner.clj | 90 +++ examples/timer.clj | 81 +++ examples/views.clj | 122 ++++ src/tui/ansi.clj | 157 +++++ src/tui/core.clj | 197 +++++++ src/tui/input.clj | 152 +++++ src/tui/render.clj | 185 ++++++ src/tui/simple.clj | 68 +++ src/tui/terminal.clj | 127 ++++ 17 files changed, 2713 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bb.edn create mode 100644 bubbletea-guide.md create mode 100644 deps.edn create mode 100644 examples/counter.clj create mode 100644 examples/http.clj create mode 100644 examples/list_selection.clj create mode 100644 examples/spinner.clj create mode 100644 examples/timer.clj create mode 100644 examples/views.clj create mode 100644 src/tui/ansi.clj create mode 100644 src/tui/core.clj create mode 100644 src/tui/input.clj create mode 100644 src/tui/render.clj create mode 100644 src/tui/simple.clj create mode 100644 src/tui/terminal.clj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd04223 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.cpcache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..79039d9 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# 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. + +## 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 +└─────────────────────────────────────┘ +``` + +## The Elm Architecture + +Every app has three parts: + +```clojure +;; Model - your application state +(def initial-model {:count 0}) + +;; 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])) + +;; 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`: + +```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}) +``` + +## Built-in Commands + +| 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 | + +## Key Matching + +```clojure +(tui/key= msg "q") ;; Character +(tui/key= msg :enter) ;; Special key +(tui/key= msg :up) ;; Arrow +(tui/key= msg [:ctrl \c]) ;; Control combo +(tui/key= msg [:alt \x]) ;; Alt combo +``` + +## Project Structure + +``` +src/ + tui/ + core.clj # Full async runtime (core.async) + simple.clj # Simple sync runtime (Babashka-compatible) + render.clj # Hiccup → ANSI + terminal.clj # Raw mode, input/output + input.clj # Key parsing + ansi.clj # ANSI codes, colors +examples/ + counter.clj + timer.clj + list_selection.clj + spinner.clj + views.clj + 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/bb.edn b/bb.edn new file mode 100644 index 0000000..0c88777 --- /dev/null +++ b/bb.edn @@ -0,0 +1,20 @@ +{:paths ["src" "examples"] + :tasks + {counter {:doc "Run counter example" + :task (do (require '[examples.counter]) + ((resolve 'examples.counter/-main)))} + timer {:doc "Run timer example" + :task (do (require '[examples.timer]) + ((resolve 'examples.timer/-main)))} + list {:doc "Run list selection example" + :task (do (require '[examples.list-selection]) + ((resolve 'examples.list-selection/-main)))} + spinner {:doc "Run spinner example" + :task (do (require '[examples.spinner]) + ((resolve 'examples.spinner/-main)))} + views {:doc "Run multi-view example" + :task (do (require '[examples.views]) + ((resolve 'examples.views/-main)))} + http {:doc "Run HTTP example" + :task (do (require '[examples.http]) + ((resolve 'examples.http/-main)))}}} diff --git a/bubbletea-guide.md b/bubbletea-guide.md new file mode 100644 index 0000000..e5b168d --- /dev/null +++ b/bubbletea-guide.md @@ -0,0 +1,1091 @@ +# Bubbletea TUI Framework Guide + +A comprehensive guide to Charm's Bubbletea - a Go framework for building terminal user interfaces based on The Elm Architecture. + +## Table of Contents + +1. [Overview](#overview) +2. [The Elm Architecture](#the-elm-architecture) +3. [Core Concepts](#core-concepts) +4. [Messages and Commands](#messages-and-commands) +5. [Built-in Commands](#built-in-commands) +6. [Input Handling](#input-handling) +7. [Rendering](#rendering) +8. [Program Options](#program-options) +9. [Examples](#examples) + +--- + +## Overview + +Bubbletea is a functional, Elm-inspired framework for building terminal UIs in Go. It enforces a unidirectional data flow pattern that makes applications predictable, testable, and maintainable. + +**Key characteristics:** +- Functional reactive programming model +- Immutable state updates +- Message-driven architecture +- All side effects handled through commands +- Frame-rate limited rendering (60 FPS default) +- Delta rendering for performance + +--- + +## The Elm Architecture + +Bubbletea implements the Elm Architecture pattern: + +``` +Input → Message → Update → Model → View → Render + ↓ + Command → Message → (repeat) +``` + +The core principle: **Your model never directly performs I/O.** All side effects flow through Commands, creating predictable, testable code. + +### The Three Core Methods + +Every Bubbletea program implements the `tea.Model` interface: + +```go +type Model interface { + Init() Cmd // Returns initial command + Update(Msg) (Model, Cmd) // Handles messages, returns new state + View() string // Renders current state as string +} +``` + +--- + +## Core Concepts + +### Model + +The model holds all application state. It should be a struct containing everything needed to render the UI: + +```go +type model struct { + cursor int // Current cursor position + choices []string // Available options + selected map[int]struct{} // Selected items + quitting bool +} +``` + +### Init + +Called once when the program starts. Returns an optional command to run: + +```go +func (m model) Init() tea.Cmd { + // Return nil for no initial command + return nil + + // Or return a command to start something + return tea.Batch( + spinner.Tick, + fetchInitialData, + ) +} +``` + +### Update + +Receives messages and returns updated model plus optional command: + +```go +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.choices)-1 { + m.cursor++ + } + } + } + return m, nil +} +``` + +### View + +Returns a string representation of the current state: + +```go +func (m model) View() string { + if m.quitting { + return "Goodbye!\n" + } + + s := "What do you want for lunch?\n\n" + + for i, choice := range m.choices { + cursor := " " + if m.cursor == i { + cursor = ">" + } + + checked := " " + if _, ok := m.selected[i]; ok { + checked = "x" + } + + s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) + } + + s += "\nPress q to quit.\n" + return s +} +``` + +--- + +## Messages and Commands + +### Messages (Msg) + +Messages are events that trigger state updates. They can be: + +**Built-in messages:** +- `tea.KeyMsg` - Keyboard input +- `tea.MouseMsg` - Mouse events +- `tea.WindowSizeMsg` - Terminal resize +- `tea.FocusMsg` - Terminal gained focus +- `tea.BlurMsg` - Terminal lost focus +- `tea.QuitMsg` - Program should exit + +**Custom messages:** + +```go +// Define your own message types +type tickMsg time.Time +type errMsg struct{ error } +type responseMsg struct { + status int + body string +} +``` + +### Commands (Cmd) + +Commands are functions that perform I/O and return messages: + +```go +// A Cmd is a function that returns a Msg +type Cmd func() Msg +``` + +**Creating commands:** + +```go +// Simple command returning a message +func tick() tea.Msg { + time.Sleep(time.Second) + return tickMsg{} +} + +// HTTP request command +func checkServer(url string) tea.Cmd { + return func() tea.Msg { + resp, err := http.Get(url) + if err != nil { + return errMsg{err} + } + defer resp.Body.Close() + return responseMsg{status: resp.StatusCode} + } +} +``` + +--- + +## Built-in Commands + +### Program Control + +```go +tea.Quit // Graceful shutdown (returns QuitMsg) +tea.Suspend // Suspend program (Unix, Ctrl+Z) +``` + +### Command Composition + +```go +// Run multiple commands concurrently +tea.Batch(cmd1, cmd2, cmd3) + +// Run commands sequentially (each waits for previous) +tea.Sequence(cmd1, cmd2, cmd3) +``` + +### Timers + +```go +// Fire once after duration +tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) +}) + +// Fire synchronized to system clock +tea.Every(time.Minute, func(t time.Time) tea.Msg { + return tickMsg(t) +}) +``` + +### Screen Control + +```go +tea.ClearScreen // Clear terminal +tea.EnterAltScreen // Full-window mode (no scrollback) +tea.ExitAltScreen // Return to normal mode +tea.ShowCursor // Make cursor visible +tea.HideCursor // Hide cursor +``` + +### Mouse Control + +```go +tea.EnableMouseCellMotion // Clicks, release, drag +tea.EnableMouseAllMotion // All motion including hover +tea.DisableMouse // Disable mouse +``` + +### External Process Execution + +```go +// Run external command (like $EDITOR) +cmd := exec.Command("vim", "file.txt") +tea.ExecProcess(cmd, func(err error) tea.Msg { + return editorFinishedMsg{err} +}) +``` + +--- + +## Input Handling + +### Keyboard Input (KeyMsg) + +```go +case tea.KeyMsg: + switch msg.String() { + // Character keys + case "a", "b", "c": + + // Special keys + case "enter", "tab", "backspace", "delete": + case "up", "down", "left", "right": + case "home", "end", "pgup", "pgdown": + + // Function keys + case "f1", "f2", "f12": + + // Control combinations + case "ctrl+c", "ctrl+d", "ctrl+z": + case "alt+enter", "shift+tab": + + // Escape + case "esc": + } + + // Check key type + switch msg.Type { + case tea.KeyRunes: + // Regular character input + char := string(msg.Runes) + case tea.KeyEnter: + case tea.KeyCtrlC: + } +``` + +### Mouse Input (MouseMsg) + +```go +case tea.MouseMsg: + switch msg.Button { + case tea.MouseButtonLeft: + if msg.Action == tea.MouseActionPress { + // Handle left click at msg.X, msg.Y + } + case tea.MouseButtonWheelUp: + // Scroll up + case tea.MouseButtonWheelDown: + // Scroll down + } +``` + +### Window Resize + +```go +case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height +``` + +--- + +## Rendering + +### How Rendering Works + +1. After each Update, View() is called +2. The returned string is queued for rendering +3. A ticker fires at configured FPS (default 60) +4. Renderer compares with previous frame (delta detection) +5. Only changed lines are written to terminal + +### Rendering Tips + +- Return plain strings from View() +- Use newlines to separate lines +- Use [Lipgloss](https://github.com/charmbracelet/lipgloss) for styling +- Terminal clears and redraws automatically + +```go +func (m model) View() string { + // Build your view as a string + var b strings.Builder + + b.WriteString("Header\n\n") + + for _, item := range m.items { + b.WriteString(item + "\n") + } + + b.WriteString("\nFooter") + + return b.String() +} +``` + +--- + +## Program Options + +Configure program behavior with options: + +```go +p := tea.NewProgram( + initialModel, + tea.WithAltScreen(), // Use alternate screen buffer + tea.WithMouseCellMotion(), // Enable mouse (click/drag) + tea.WithMouseAllMotion(), // Enable all mouse events + tea.WithoutSignalHandler(), // Handle Ctrl+C yourself + tea.WithFPS(120), // Custom frame rate + tea.WithReportFocus(), // Get focus/blur events + tea.WithInput(customReader), // Custom input source + tea.WithOutput(customWriter), // Custom output destination +) + +// Run the program +finalModel, err := p.Run() +``` + +--- + +## Examples + +### Example 1: Simple Counter + +A minimal example demonstrating the core pattern: + +```go +package main + +import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" +) + +type model struct { + count int +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "up", "k": + m.count++ + case "down", "j": + m.count-- + } + } + return m, nil +} + +func (m model) View() string { + return fmt.Sprintf( + "Count: %d\n\nPress up/down to change, q to quit.\n", + m.count, + ) +} + +func main() { + p := tea.NewProgram(model{count: 0}) + if _, err := p.Run(); err != nil { + fmt.Println("Error:", err) + } +} +``` + +### Example 2: Countdown Timer with Commands + +Demonstrates async commands and timers: + +```go +package main + +import ( + "fmt" + "time" + tea "github.com/charmbracelet/bubbletea" +) + +type tickMsg time.Time + +type model struct { + seconds int + done bool +} + +func (m model) Init() tea.Cmd { + return tick() +} + +func tick() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "q" { + return m, tea.Quit + } + case tickMsg: + m.seconds-- + if m.seconds <= 0 { + m.done = true + return m, tea.Quit + } + return m, tick() + } + return m, nil +} + +func (m model) View() string { + if m.done { + return "Time's up!\n" + } + return fmt.Sprintf("Countdown: %d seconds remaining...\n\nPress q to quit.\n", m.seconds) +} + +func main() { + p := tea.NewProgram(model{seconds: 10}) + if _, err := p.Run(); err != nil { + fmt.Println("Error:", err) + } +} +``` + +### Example 3: HTTP Request + +Async HTTP requests with error handling: + +```go +package main + +import ( + "fmt" + "net/http" + "time" + tea "github.com/charmbracelet/bubbletea" +) + +const url = "https://httpstat.us/200" + +type statusMsg int +type errMsg struct{ error } + +type model struct { + status int + err error + loading bool +} + +func checkServer() tea.Msg { + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(url) + if err != nil { + return errMsg{err} + } + defer resp.Body.Close() + return statusMsg(resp.StatusCode) +} + +func (m model) Init() tea.Cmd { + return checkServer +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "q" { + return m, tea.Quit + } + case statusMsg: + m.status = int(msg) + m.loading = false + case errMsg: + m.err = msg.error + m.loading = false + } + return m, nil +} + +func (m model) View() string { + if m.loading { + return "Checking server...\n" + } + if m.err != nil { + return fmt.Sprintf("Error: %v\n\nPress q to quit.\n", m.err) + } + return fmt.Sprintf("Server returned: %d\n\nPress q to quit.\n", m.status) +} + +func main() { + p := tea.NewProgram(model{loading: true}) + if _, err := p.Run(); err != nil { + fmt.Println("Error:", err) + } +} +``` + +### Example 4: Multiple Choice Selection + +Interactive list with cursor and selection: + +```go +package main + +import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" +) + +type model struct { + cursor int + choices []string + selected map[int]struct{} +} + +func initialModel() model { + return model{ + choices: []string{"Pizza", "Sushi", "Tacos", "Burger"}, + selected: make(map[int]struct{}), + } +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.choices)-1 { + m.cursor++ + } + case "enter", " ": + if _, ok := m.selected[m.cursor]; ok { + delete(m.selected, m.cursor) + } else { + m.selected[m.cursor] = struct{}{} + } + } + } + return m, nil +} + +func (m model) View() string { + s := "What do you want for lunch?\n\n" + + for i, choice := range m.choices { + cursor := " " + if m.cursor == i { + cursor = ">" + } + + checked := " " + if _, ok := m.selected[i]; ok { + checked = "x" + } + + s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) + } + + s += "\nPress space to select, q to quit.\n" + return s +} + +func main() { + p := tea.NewProgram(initialModel()) + if _, err := p.Run(); err != nil { + fmt.Println("Error:", err) + } +} +``` + +### Example 5: Multiple Views / State Machine + +Switch between different screens: + +```go +package main + +import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" +) + +type viewState int + +const ( + menuView viewState = iota + detailView +) + +type model struct { + state viewState + cursor int + items []string + selected string +} + +func initialModel() model { + return model{ + state: menuView, + items: []string{"Option A", "Option B", "Option C"}, + } +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "esc": + if m.state == detailView { + m.state = menuView + } + case "up", "k": + if m.state == menuView && m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.state == menuView && m.cursor < len(m.items)-1 { + m.cursor++ + } + case "enter": + if m.state == menuView { + m.selected = m.items[m.cursor] + m.state = detailView + } + } + } + return m, nil +} + +func (m model) View() string { + switch m.state { + case menuView: + return m.menuView() + case detailView: + return m.detailView() + } + return "" +} + +func (m model) menuView() string { + s := "Select an option:\n\n" + for i, item := range m.items { + cursor := " " + if m.cursor == i { + cursor = ">" + } + s += fmt.Sprintf("%s %s\n", cursor, item) + } + s += "\nPress enter to select, q to quit.\n" + return s +} + +func (m model) detailView() string { + return fmt.Sprintf( + "You selected: %s\n\nPress esc to go back, q to quit.\n", + m.selected, + ) +} + +func main() { + p := tea.NewProgram(initialModel()) + if _, err := p.Run(); err != nil { + fmt.Println("Error:", err) + } +} +``` + +### Example 6: Component Composition + +Using sub-components (like spinners): + +```go +package main + +import ( + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/spinner" +) + +type model struct { + spinner spinner.Model + loading bool + result string +} + +type doneMsg struct{ result string } + +func doWork() tea.Msg { + time.Sleep(3 * time.Second) + return doneMsg{result: "Work complete!"} +} + +func initialModel() model { + s := spinner.New() + s.Spinner = spinner.Dot + return model{ + spinner: s, + loading: true, + } +} + +func (m model) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + doWork, + ) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "q" { + return m, tea.Quit + } + case doneMsg: + m.loading = false + m.result = msg.result + return m, nil + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + return m, nil +} + +func (m model) View() string { + if m.loading { + return fmt.Sprintf("%s Loading...\n", m.spinner.View()) + } + return fmt.Sprintf("%s\n\nPress q to quit.\n", m.result) +} + +func main() { + p := tea.NewProgram(initialModel()) + if _, err := p.Run(); err != nil { + fmt.Println("Error:", err) + } +} +``` + +### Example 7: Text Input Form + +Multiple text inputs with focus management: + +```go +package main + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/textinput" +) + +type model struct { + inputs []textinput.Model + focused int + done bool +} + +func initialModel() model { + inputs := make([]textinput.Model, 3) + + inputs[0] = textinput.New() + inputs[0].Placeholder = "Name" + inputs[0].Focus() + inputs[0].CharLimit = 50 + + inputs[1] = textinput.New() + inputs[1].Placeholder = "Email" + inputs[1].CharLimit = 100 + + inputs[2] = textinput.New() + inputs[2].Placeholder = "Message" + inputs[2].CharLimit = 500 + + return model{inputs: inputs} +} + +func (m model) Init() tea.Cmd { + return textinput.Blink +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "tab", "shift+tab", "down", "up": + // Cycle focus + if msg.String() == "shift+tab" || msg.String() == "up" { + m.focused-- + if m.focused < 0 { + m.focused = len(m.inputs) - 1 + } + } else { + m.focused++ + if m.focused >= len(m.inputs) { + m.focused = 0 + } + } + for i := range m.inputs { + if i == m.focused { + cmds = append(cmds, m.inputs[i].Focus()) + } else { + m.inputs[i].Blur() + } + } + case "enter": + if m.focused == len(m.inputs)-1 { + m.done = true + return m, tea.Quit + } + m.focused++ + for i := range m.inputs { + if i == m.focused { + cmds = append(cmds, m.inputs[i].Focus()) + } else { + m.inputs[i].Blur() + } + } + } + } + + // Update all inputs + for i := range m.inputs { + var cmd tea.Cmd + m.inputs[i], cmd = m.inputs[i].Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m model) View() string { + if m.done { + return fmt.Sprintf( + "Submitted!\nName: %s\nEmail: %s\nMessage: %s\n", + m.inputs[0].Value(), + m.inputs[1].Value(), + m.inputs[2].Value(), + ) + } + + var b strings.Builder + b.WriteString("Contact Form\n\n") + + for i, input := range m.inputs { + b.WriteString(input.View()) + if i < len(m.inputs)-1 { + b.WriteString("\n") + } + } + + b.WriteString("\n\nTab to move, Enter to submit, Ctrl+C to quit.\n") + return b.String() +} + +func main() { + p := tea.NewProgram(initialModel()) + if _, err := p.Run(); err != nil { + fmt.Println("Error:", err) + } +} +``` + +### Example 8: Full-Screen with Alt Screen and Mouse + +Full-window application with mouse support: + +```go +package main + +import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" +) + +type model struct { + width int + height int + mouseX int + mouseY int + clicks int +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "q" || msg.String() == "ctrl+c" { + return m, tea.Quit + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case tea.MouseMsg: + m.mouseX = msg.X + m.mouseY = msg.Y + if msg.Action == tea.MouseActionPress { + m.clicks++ + } + } + return m, nil +} + +func (m model) View() string { + return fmt.Sprintf( + "Window: %dx%d\nMouse: (%d, %d)\nClicks: %d\n\nClick anywhere! Press q to quit.", + m.width, m.height, + m.mouseX, m.mouseY, + m.clicks, + ) +} + +func main() { + p := tea.NewProgram( + model{}, + tea.WithAltScreen(), // Full-screen mode + tea.WithMouseAllMotion(), // Track all mouse movement + ) + if _, err := p.Run(); err != nil { + fmt.Println("Error:", err) + } +} +``` + +--- + +## Key Architectural Patterns + +### 1. Unidirectional Data Flow +All data flows in one direction: Input → Message → Update → Model → View + +### 2. Immutability +Update returns a new model rather than mutating the existing one. + +### 3. Commands for Side Effects +All I/O happens through Commands - never in Update or View. + +### 4. Message Types for Events +Use custom message types to represent domain events. + +### 5. Component Delegation +Sub-components handle their own Update and View: + +```go +var cmd tea.Cmd +m.subComponent, cmd = m.subComponent.Update(msg) +return m, cmd +``` + +### 6. State Machine for Views +Use an enum to track which view is active: + +```go +switch m.state { +case loading: + return loadingView(m) +case ready: + return mainView(m) +case error: + return errorView(m) +} +``` + +--- + +## Related Libraries + +- **[Bubbles](https://github.com/charmbracelet/bubbles)** - Pre-built components (spinner, textinput, list, table, etc.) +- **[Lipgloss](https://github.com/charmbracelet/lipgloss)** - Styling and layout +- **[Harmonica](https://github.com/charmbracelet/harmonica)** - Smooth animations +- **[Glamour](https://github.com/charmbracelet/glamour)** - Markdown rendering + +--- + +## Resources + +- [Bubbletea GitHub](https://github.com/charmbracelet/bubbletea) +- [Official Examples](https://github.com/charmbracelet/bubbletea/tree/master/examples) +- [The Elm Architecture](https://guide.elm-lang.org/architecture/) +- [Charm Website](https://charm.sh) diff --git a/deps.edn b/deps.edn new file mode 100644 index 0000000..2e60629 --- /dev/null +++ b/deps.edn @@ -0,0 +1,11 @@ +{:paths ["src"] + :deps {org.clojure/clojure {:mvn/version "1.12.0"} + org.clojure/core.async {:mvn/version "1.6.681"}} + :aliases + {:dev {:extra-paths ["examples"]} + :counter {:main-opts ["-m" "examples.counter"]} + :timer {:main-opts ["-m" "examples.timer"]} + :list {:main-opts ["-m" "examples.list-selection"]} + :spinner {:main-opts ["-m" "examples.spinner"]} + :http {:main-opts ["-m" "examples.http"]} + :views {:main-opts ["-m" "examples.views"]}}} diff --git a/examples/counter.clj b/examples/counter.clj new file mode 100644 index 0000000..ba74e89 --- /dev/null +++ b/examples/counter.clj @@ -0,0 +1,56 @@ +(ns examples.counter + "Simple counter example - demonstrates basic Elm architecture. + Mirrors bubbletea's simple example." + (:require [tui.core :as tui])) + +;; === Model === +(def initial-model + {:count 0}) + +;; === Update === +(defn update-model [model msg] + (cond + ;; Quit on q or ctrl+c + (or (tui/key= msg "q") + (tui/key= msg [:ctrl \c])) + [model tui/quit] + + ;; Increment on up/k + (or (tui/key= msg :up) + (tui/key= msg "k")) + [(update model :count inc) nil] + + ;; Decrement on down/j + (or (tui/key= msg :down) + (tui/key= msg "j")) + [(update model :count dec) nil] + + ;; Reset on r + (tui/key= msg "r") + [(assoc model :count 0) nil] + + :else + [model nil])) + +;; === View === +(defn view [{:keys [count]}] + [:col {:gap 1} + [:box {:border :rounded :padding [0 1]} + [:col + [:text {:bold true} "Counter"] + [:text ""] + [:text {:fg (cond + (pos? count) :green + (neg? count) :red + :else :default)} + (str "Count: " count)]]] + [:text {:fg :gray} "j/k or up/down: change value"] + [:text {:fg :gray} "r: reset q: quit"]]) + +;; === Main === +(defn -main [& _args] + (println "Starting counter...") + (let [final-model (tui/run {:init initial-model + :update update-model + :view view})] + (println "Final count:" (:count final-model)))) diff --git a/examples/http.clj b/examples/http.clj new file mode 100644 index 0000000..51fd422 --- /dev/null +++ b/examples/http.clj @@ -0,0 +1,111 @@ +(ns examples.http + "HTTP request example - demonstrates async commands. + Mirrors bubbletea's http example." + (:require [tui.core :as tui] + [clojure.java.io :as io]) + (:import [java.net URL HttpURLConnection])) + +;; === HTTP Helpers === +(defn http-get + "Simple HTTP GET request. Returns {:status code :body string} or {:error msg}" + [url-str] + (try + (let [url (URL. url-str) + conn ^HttpURLConnection (.openConnection url)] + (.setRequestMethod conn "GET") + (.setConnectTimeout conn 5000) + (.setReadTimeout conn 5000) + (let [status (.getResponseCode conn) + body (with-open [r (io/reader (.getInputStream conn))] + (slurp r))] + {:status status :body body})) + (catch Exception e + {:error (.getMessage e)}))) + +;; === Model === +(def initial-model + {:state :idle ; :idle, :loading, :success, :error + :status nil + :error nil + :url "https://httpstat.us/200"}) + +;; === Commands === +(defn fetch-url [url] + (fn [] + (let [result (http-get url)] + (if (:error result) + [:http-error (:error result)] + [:http-success (:status result)])))) + +;; === Update === +(defn update-model [{:keys [url] :as model} msg] + (cond + ;; Quit + (or (tui/key= msg "q") + (tui/key= msg [:ctrl \c])) + [model tui/quit] + + ;; Enter - start request + (and (= (:state model) :idle) + (tui/key= msg :enter)) + [(assoc model :state :loading) (fetch-url url)] + + ;; r - retry/reset + (tui/key= msg "r") + [(assoc model :state :idle :status nil :error nil) nil] + + ;; HTTP success + (= (first msg) :http-success) + [(assoc model :state :success :status (second msg)) nil] + + ;; HTTP error + (= (first msg) :http-error) + [(assoc model :state :error :error (second msg)) nil] + + :else + [model nil])) + +;; === View === +(defn view [{:keys [state status error url]}] + [:col {:gap 1} + [:box {:border :rounded :padding [1 2]} + [:col {:gap 1} + [:text {:bold true} "HTTP Request Demo"] + [:text ""] + [:row {:gap 1} + [:text {:fg :gray} "URL:"] + [:text {:fg :cyan} url]] + [:text ""] + (case state + :idle + [:text {:fg :gray} "Press enter to fetch..."] + + :loading + [:row {:gap 1} + [:text {:fg :yellow} "⠋"] + [:text "Fetching..."]] + + :success + [:row {:gap 1} + [:text {:fg :green} "✓"] + [:text (str "Status: " status)]] + + :error + [:col + [:row {:gap 1} + [:text {:fg :red} "✗"] + [:text {:fg :red} "Error:"]] + [:text {:fg :red} error]])]] + [:text {:fg :gray} + (if (= state :idle) + "enter: fetch q: quit" + "r: retry q: quit")]]) + +;; === Main === +(defn -main [& _args] + (println "Starting HTTP demo...") + (let [final (tui/run {:init initial-model + :update update-model + :view view})] + (when (= (:state final) :success) + (println "Request completed with status:" (:status final))))) diff --git a/examples/list_selection.clj b/examples/list_selection.clj new file mode 100644 index 0000000..dbdca9b --- /dev/null +++ b/examples/list_selection.clj @@ -0,0 +1,92 @@ +(ns examples.list-selection + "List selection example - demonstrates cursor navigation and multi-select. + Mirrors bubbletea's list examples." + (:require [tui.core :as tui] + [clojure.string :as str])) + +;; === Model === +(def initial-model + {:cursor 0 + :items ["Pizza" "Sushi" "Tacos" "Burger" "Pasta" "Salad" "Soup" "Steak"] + :selected #{} + :submitted false}) + +;; === Update === +(defn update-model [{:keys [cursor items] :as model} msg] + (cond + ;; Quit + (or (tui/key= msg "q") + (tui/key= msg [:ctrl \c])) + [model tui/quit] + + ;; Move up + (or (tui/key= msg :up) + (tui/key= msg "k")) + [(update model :cursor #(max 0 (dec %))) nil] + + ;; Move down + (or (tui/key= msg :down) + (tui/key= msg "j")) + [(update model :cursor #(min (dec (count items)) (inc %))) nil] + + ;; Toggle selection + (tui/key= msg " ") + [(update model :selected + #(if (contains? % cursor) + (disj % cursor) + (conj % cursor))) + nil] + + ;; Submit + (tui/key= msg :enter) + [(assoc model :submitted true) tui/quit] + + :else + [model nil])) + +;; === View === +(defn view [{:keys [cursor items selected submitted]}] + (if submitted + [:col + [:text {:bold true :fg :green} "You selected:"] + [:text ""] + (if (empty? selected) + [:text {:fg :gray} "(nothing selected)"] + [:col + (for [idx (sort selected)] + [:text (str " - " (nth items idx))])])] + + [:col {:gap 1} + [:box {:border :rounded :padding [0 1] :title "What's for lunch?"} + [:col + (for [[idx item] (map-indexed vector items)] + (let [is-cursor (= idx cursor) + is-selected (contains? selected idx)] + [:row {:gap 1} + [:text {:fg (when is-cursor :cyan)} (if is-cursor ">" " ")] + [:text (if is-selected "[x]" "[ ]")] + [:text {:bold is-cursor + :fg (cond + is-selected :green + is-cursor :cyan + :else :default)} + item]]))]] + [:row {:gap 2} + [:text {:fg :gray} "j/k: move"] + [:text {:fg :gray} "space: select"] + [:text {:fg :gray} "enter: confirm"] + [:text {:fg :gray} "q: quit"]] + [:text {:fg :cyan} + (str (count selected) " item" (when (not= 1 (count selected)) "s") " selected")]])) + +;; === Main === +(defn -main [& _args] + (println "Starting list selection...") + (let [{:keys [items selected submitted]} (tui/run {:init initial-model + :update update-model + :view view})] + (when submitted + (println) + (println "Your order:") + (doseq [idx (sort selected)] + (println " -" (nth items idx)))))) diff --git a/examples/spinner.clj b/examples/spinner.clj new file mode 100644 index 0000000..5309367 --- /dev/null +++ b/examples/spinner.clj @@ -0,0 +1,90 @@ +(ns examples.spinner + "Spinner example - demonstrates animated loading states. + Mirrors bubbletea's spinner example." + (:require [tui.core :as tui])) + +;; === Spinner Frames === +(def spinner-styles + {:dots ["⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"] + :line ["|" "/" "-" "\\"] + :circle ["◐" "◓" "◑" "◒"] + :square ["◰" "◳" "◲" "◱"] + :triangle ["◢" "◣" "◤" "◥"] + :bounce ["⠁" "⠂" "⠄" "⠂"] + :dots2 ["⣾" "⣽" "⣻" "⢿" "⡿" "⣟" "⣯" "⣷"] + :arc ["◜" "◠" "◝" "◞" "◡" "◟"] + :moon ["🌑" "🌒" "🌓" "🌔" "🌕" "🌖" "🌗" "🌘"]}) + +;; === Model === +(def initial-model + {:frame 0 + :style :dots + :loading true + :message "Loading..." + :styles (keys spinner-styles) + :style-idx 0}) + +;; === Update === +(defn update-model [{:keys [styles style-idx] :as model} msg] + (cond + ;; Quit + (or (tui/key= msg "q") + (tui/key= msg [:ctrl \c])) + [model tui/quit] + + ;; Tick - advance frame + (= (first msg) :tick) + (if (:loading model) + [(update model :frame inc) (tui/tick 80)] + [model nil]) + + ;; Space - simulate completion + (tui/key= msg " ") + [(assoc model :loading false :message "Done!") nil] + + ;; Tab - change spinner style + (tui/key= msg :tab) + (let [new-idx (mod (inc style-idx) (count styles))] + [(assoc model + :style-idx new-idx + :style (nth styles new-idx)) + nil]) + + ;; r - restart + (tui/key= msg "r") + [(assoc model :loading true :frame 0 :message "Loading...") + (tui/tick 80)] + + :else + [model nil])) + +;; === View === +(defn spinner-view [{:keys [frame style]}] + (let [frames (get spinner-styles style) + idx (mod frame (count frames))] + (nth frames idx))) + +(defn view [{:keys [loading message style] :as model}] + [:col {:gap 1} + [:box {:border :rounded :padding [1 2]} + [:col {:gap 1} + [:text {:bold true} "Spinner Demo"] + [:text ""] + [:row {:gap 1} + (if loading + [:text {:fg :cyan} (spinner-view model)] + [:text {:fg :green} "✓"]) + [:text message]] + [:text ""] + [:text {:fg :gray} (str "Style: " (name style))]]] + [:col + [:text {:fg :gray} "tab: change style space: complete r: restart q: quit"]]]) + +;; === Main === +(defn -main [& _args] + (println "Starting spinner...") + (tui/run {:init initial-model + :update update-model + :view view + :init-cmd (tui/tick 80)}) + (println "Spinner demo finished.")) diff --git a/examples/timer.clj b/examples/timer.clj new file mode 100644 index 0000000..e0c1f94 --- /dev/null +++ b/examples/timer.clj @@ -0,0 +1,81 @@ +(ns examples.timer + "Countdown timer example - demonstrates async commands. + Mirrors bubbletea's stopwatch/timer examples." + (:require [tui.core :as tui])) + +;; === Model === +(def initial-model + {:seconds 10 + :running true + :done false}) + +;; === Update === +(defn update-model [model msg] + (cond + ;; Quit + (or (tui/key= msg "q") + (tui/key= msg [:ctrl \c])) + [model tui/quit] + + ;; Tick - decrement timer + (= (first msg) :tick) + (if (:running model) + (let [new-seconds (dec (:seconds model))] + (if (<= new-seconds 0) + ;; Timer done + [(assoc model :seconds 0 :done true :running false) nil] + ;; Continue countdown + [(assoc model :seconds new-seconds) (tui/tick 1000)])) + [model nil]) + + ;; Space - pause/resume + (tui/key= msg " ") + (let [new-running (not (:running model))] + [(assoc model :running new-running) + (when new-running (tui/tick 1000))]) + + ;; r - reset + (tui/key= msg "r") + [(assoc model :seconds 10 :done false :running true) + (tui/tick 1000)] + + :else + [model nil])) + +;; === View === +(defn format-time [seconds] + (let [mins (quot seconds 60) + secs (mod seconds 60)] + (format "%02d:%02d" mins secs))) + +(defn view [{:keys [seconds running done]}] + [:col {:gap 1} + [:box {:border :rounded :padding [1 2]} + [:col + [:text {:bold true} "Countdown Timer"] + [:text ""] + [:text {:fg (cond + done :green + (< seconds 5) :red + :else :cyan) + :bold true} + (if done + "Time's up!" + (format-time seconds))] + [:text ""] + [:text {:fg :gray} + (cond + done "Press r to restart" + running "Running..." + :else "Paused")]]] + [:text {:fg :gray} "space: pause/resume r: reset q: quit"]]) + +;; === Main === +(defn -main [& _args] + (println "Starting timer...") + (let [final-model (tui/run {:init initial-model + :update update-model + :view view + :init-cmd (tui/tick 1000)})] + (when (:done final-model) + (println "Timer completed!")))) diff --git a/examples/views.clj b/examples/views.clj new file mode 100644 index 0000000..8c56738 --- /dev/null +++ b/examples/views.clj @@ -0,0 +1,122 @@ +(ns examples.views + "Multiple views example - demonstrates state machine pattern. + Mirrors bubbletea's views example." + (:require [tui.core :as tui])) + +;; === Model === +(def initial-model + {:view :menu ; :menu, :detail, :confirm + :cursor 0 + :items [{:name "Profile" :desc "View and edit your profile settings"} + {:name "Settings" :desc "Configure application preferences"} + {:name "Help" :desc "Get help and documentation"} + {:name "About" :desc "About this application"}] + :selected nil}) + +;; === Update === +(defn update-model [{:keys [view cursor items] :as model} msg] + (case view + ;; Menu view + :menu + (cond + (or (tui/key= msg "q") + (tui/key= msg [:ctrl \c])) + [model tui/quit] + + (or (tui/key= msg :up) + (tui/key= msg "k")) + [(update model :cursor #(max 0 (dec %))) nil] + + (or (tui/key= msg :down) + (tui/key= msg "j")) + [(update model :cursor #(min (dec (count items)) (inc %))) nil] + + (tui/key= msg :enter) + [(assoc model + :view :detail + :selected (nth items cursor)) + nil] + + :else + [model nil]) + + ;; Detail view + :detail + (cond + (or (tui/key= msg "q") + (tui/key= msg [:ctrl \c])) + [(assoc model :view :confirm) nil] + + (or (tui/key= msg :escape) + (tui/key= msg "b")) + [(assoc model :view :menu :selected nil) nil] + + :else + [model nil]) + + ;; Confirm quit dialog + :confirm + (cond + (tui/key= msg "y") + [model tui/quit] + + (or (tui/key= msg "n") + (tui/key= msg :escape)) + [(assoc model :view :detail) nil] + + :else + [model nil]))) + +;; === Views === +(defn menu-view [{:keys [cursor items]}] + [:col {:gap 1} + [:box {:border :rounded :padding [0 1] :title "Main Menu"} + [:col + (for [[idx item] (map-indexed vector items)] + (let [is-cursor (= idx cursor)] + [:row {:gap 1} + [:text {:fg (when is-cursor :cyan)} (if is-cursor ">" " ")] + [:text {:bold is-cursor + :fg (when is-cursor :cyan)} + (:name item)]]))]] + [:text {:fg :gray} "j/k: navigate enter: select q: quit"]]) + +(defn detail-view [{:keys [selected]}] + [:col {:gap 1} + [:box {:border :double :padding [1 2]} + [:col {:gap 1} + [:text {:bold true :fg :cyan} (:name selected)] + [:text ""] + [:text (:desc selected)] + [:text ""] + [:text {:fg :gray :italic true} + "This is a detailed view of the selected item."] + [:text {:fg :gray :italic true} + "You could show forms, settings, or other content here."]]] + [:text {:fg :gray} "b/esc: back q: quit"]]) + +(defn confirm-view [_model] + [:col {:gap 1} + [:box {:border :rounded :padding [1 2]} + [:col + [:text {:bold true :fg :yellow} "Quit?"] + [:text ""] + [:text "Are you sure you want to quit?"] + [:text ""] + [:row {:gap 2} + [:text {:fg :green} "[y] Yes"] + [:text {:fg :red} "[n] No"]]]]]) + +(defn view [{:keys [view] :as model}] + (case view + :menu (menu-view model) + :detail (detail-view model) + :confirm (confirm-view model))) + +;; === Main === +(defn -main [& _args] + (println "Starting views demo...") + (tui/run {:init initial-model + :update update-model + :view view}) + (println "Views demo finished.")) diff --git a/src/tui/ansi.clj b/src/tui/ansi.clj new file mode 100644 index 0000000..5bbbfb6 --- /dev/null +++ b/src/tui/ansi.clj @@ -0,0 +1,157 @@ +(ns tui.ansi + "ANSI escape codes for terminal styling and control.") + +;; === Escape Sequences === +(def esc "\u001b") +(def csi (str esc "[")) + +;; === Screen Control === +(def clear-screen (str csi "2J")) +(def clear-line (str csi "2K")) +(def clear-to-end (str csi "0J")) +(def cursor-home (str csi "H")) +(def hide-cursor (str csi "?25l")) +(def show-cursor (str csi "?25h")) + +;; Alternate screen buffer +(def enter-alt-screen (str csi "?1049h")) +(def exit-alt-screen (str csi "?1049l")) + +;; === Cursor Movement === +(defn cursor-to [row col] + (str csi row ";" col "H")) + +(defn cursor-up [n] + (str csi n "A")) + +(defn cursor-down [n] + (str csi n "B")) + +(defn cursor-forward [n] + (str csi n "C")) + +(defn cursor-back [n] + (str csi n "D")) + +(def cursor-save (str csi "s")) +(def cursor-restore (str csi "u")) + +;; === Colors === +(def reset (str csi "0m")) + +;; Foreground colors +(def fg-colors + {:black 30 :red 31 :green 32 :yellow 33 + :blue 34 :magenta 35 :cyan 36 :white 37 + :default 39 + ;; Bright variants + :bright-black 90 :bright-red 91 :bright-green 92 :bright-yellow 93 + :bright-blue 94 :bright-magenta 95 :bright-cyan 96 :bright-white 97 + ;; Aliases + :gray 90 :grey 90}) + +;; Background colors +(def bg-colors + {:black 40 :red 41 :green 42 :yellow 43 + :blue 44 :magenta 45 :cyan 46 :white 47 + :default 49 + ;; Bright variants + :bright-black 100 :bright-red 101 :bright-green 102 :bright-yellow 103 + :bright-blue 104 :bright-magenta 105 :bright-cyan 106 :bright-white 107}) + +;; Text attributes +(def attrs + {:bold 1 :dim 2 :italic 3 :underline 4 + :blink 5 :inverse 7 :hidden 8 :strike 9}) + +(defn sgr + "Generate SGR (Select Graphic Rendition) sequence." + [& codes] + (str csi (clojure.string/join ";" codes) "m")) + +(defn style + "Apply style attributes to text. + Options: :fg :bg :bold :dim :italic :underline :inverse :strike" + [text & {:keys [fg bg bold dim italic underline inverse strike]}] + (let [codes (cond-> [] + fg (conj (get fg-colors fg fg)) + bg (conj (get bg-colors bg bg)) + bold (conj 1) + dim (conj 2) + italic (conj 3) + underline (conj 4) + inverse (conj 7) + strike (conj 9))] + (if (empty? codes) + text + (str (apply sgr codes) text reset)))) + +(defn fg + "Set foreground color." + [color text] + (style text :fg color)) + +(defn bg + "Set background color." + [color text] + (style text :bg color)) + +;; 256-color support +(defn fg-256 [n text] + (str csi "38;5;" n "m" text reset)) + +(defn bg-256 [n text] + (str csi "48;5;" n "m" text reset)) + +;; True color (24-bit) support +(defn fg-rgb [r g b text] + (str csi "38;2;" r ";" g ";" b "m" text reset)) + +(defn bg-rgb [r g b text] + (str csi "48;2;" r ";" g ";" b "m" text reset)) + +;; === Box Drawing Characters === +(def 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 "|"}}) + +;; === String Utilities === +(defn visible-length + "Get visible length of string (excluding ANSI codes)." + [s] + (count (clojure.string/replace s #"\u001b\[[0-9;]*m" ""))) + +(defn pad-right + "Pad string to width with spaces." + [s width] + (let [vlen (visible-length s) + padding (max 0 (- width vlen))] + (str s (apply str (repeat padding " "))))) + +(defn pad-left + "Pad string to width with spaces on left." + [s width] + (let [vlen (visible-length s) + padding (max 0 (- width vlen))] + (str (apply str (repeat padding " ")) s))) + +(defn pad-center + "Center string within width." + [s width] + (let [vlen (visible-length s) + total-padding (max 0 (- width vlen)) + left-padding (quot total-padding 2) + right-padding (- total-padding left-padding)] + (str (apply str (repeat left-padding " ")) + s + (apply str (repeat right-padding " "))))) + +(defn truncate + "Truncate string to max width, adding ellipsis if needed." + [s max-width] + (if (<= (visible-length s) max-width) + s + (str (subs s 0 (max 0 (- max-width 1))) "…"))) diff --git a/src/tui/core.clj b/src/tui/core.clj new file mode 100644 index 0000000..51ac196 --- /dev/null +++ b/src/tui/core.clj @@ -0,0 +1,197 @@ +(ns tui.core + "Core TUI framework - Elm architecture runtime." + (:require [tui.terminal :as term] + [tui.input :as input] + [tui.render :as render] + [tui.ansi :as ansi] + [clojure.core.async :as async :refer [go go-loop chan ! >!! ! msg-chan [:tick (System/currentTimeMillis)]))) + + ;; Batch - run all in parallel + (and (vector? cmd) (= (first cmd) :batch)) + (doseq [c (rest cmd)] + (execute-cmd! c msg-chan)) + + ;; Sequence - run one after another + (and (vector? cmd) (= (first cmd) :seq)) + (go-loop [[c & rest-cmds] (rest cmd)] + (when c + (let [result-chan (chan 1)] + (execute-cmd! c result-chan) + (when-let [msg (! msg-chan msg) + (recur rest-cmds))))) + + ;; Function - execute and send result + (fn? cmd) + (go + (let [msg (cmd)] + (when msg + (>! msg-chan msg)))) + + :else + nil))) + +;; === Input Loop === +(defn- start-input-loop! + "Start goroutine that reads input and puts messages on channel." + [msg-chan running?] + (go-loop [] + (when @running? + (when-let [key-msg (input/read-key)] + (>! msg-chan key-msg)) + (recur)))) + +;; === Main Run Loop === +(defn run + "Run a TUI application. + + Options: + - :init - Initial model (required) + - :update - (fn [model msg] [new-model cmd]) (required) + - :view - (fn [model] hiccup) (required) + - :init-cmd - Initial command to run + - :fps - Target frames per second (default 60) + - :alt-screen - Use alternate screen buffer (default false) + + Returns the final model." + [{:keys [init update view init-cmd fps alt-screen] + :or {fps 60 alt-screen false}}] + (let [msg-chan (chan 256) + running? (atom true) + frame-time (/ 1000 fps)] + + ;; Setup terminal + (term/init-input!) + (term/raw-mode!) + (when alt-screen (term/alt-screen!)) + (term/clear!) + + (try + ;; Start input loop + (start-input-loop! msg-chan running?) + + ;; Execute initial command + (when init-cmd + (execute-cmd! init-cmd msg-chan)) + + ;; Initial render + (let [initial-view (render/render (view init))] + (term/render! initial-view)) + + ;; Main loop + (loop [model init + last-render (System/currentTimeMillis)] + (let [;; Wait for message with timeout for frame limiting + remaining (max 1 (- frame-time (- (System/currentTimeMillis) last-render))) + msg (alt! + msg-chan ([v] v) + (timeout remaining) nil)] + + (if (or (nil? msg) (not @running?)) + ;; No message, just continue + (recur model (System/currentTimeMillis)) + + ;; Process message + (if (= msg [:quit]) + ;; Quit - return final model + model + + ;; Update model + (let [[new-model cmd] (update model msg) + new-view (render/render (view new-model)) + now (System/currentTimeMillis)] + + ;; Execute command + (when cmd + (execute-cmd! cmd msg-chan)) + + ;; Render + (term/render! new-view) + + (recur new-model now)))))) + + (finally + ;; Cleanup + (reset! running? false) + (close! msg-chan) + (when alt-screen (term/exit-alt-screen!)) + (term/restore!) + (term/close-input!) + (println))))) + +;; === Convenience Macros === +(defmacro defapp + "Define a TUI application. + + (defapp my-app + :init {:count 0} + :update (fn [model msg] ...) + :view (fn [model] ...))" + [name & {:keys [init update view init-cmd]}] + `(def ~name + {:init ~init + :update ~update + :view ~view + :init-cmd ~init-cmd})) + +;; === Key Matching Helpers === +(defn key= + "Check if message is a specific key." + [msg key-pattern] + (input/key-match? msg key-pattern)) + +(defn key-str + "Get string representation of key." + [msg] + (input/key->str msg)) + +;; Re-export render function +(def render render/render) diff --git a/src/tui/input.clj b/src/tui/input.clj new file mode 100644 index 0000000..d6326da --- /dev/null +++ b/src/tui/input.clj @@ -0,0 +1,152 @@ +(ns tui.input + "Parse terminal input into key messages." + (:require [tui.terminal :as term])) + +;; === Key Message Structure === +;; [:key {:type :rune :char \a}] +;; [:key {:type :special :key :up}] +;; [:key {:type :special :key :enter}] +;; [:key {:type :ctrl :char \c}] + +(def ^:private ctrl-keys + {0 [:ctrl \space] ; Ctrl+Space / Ctrl+@ + 1 [:ctrl \a] 2 [:ctrl \b] 3 [:ctrl \c] + 4 [:ctrl \d] 5 [:ctrl \e] 6 [:ctrl \f] + 7 [:ctrl \g] 8 :backspace 9 :tab + 10 :enter 11 [:ctrl \k] 12 [:ctrl \l] + 13 :enter 14 [:ctrl \n] 15 [:ctrl \o] + 16 [:ctrl \p] 17 [:ctrl \q] 18 [:ctrl \r] + 19 [:ctrl \s] 20 [:ctrl \t] 21 [:ctrl \u] + 22 [:ctrl \v] 23 [:ctrl \w] 24 [:ctrl \x] + 25 [:ctrl \y] 26 [:ctrl \z] 27 :escape + 28 [:ctrl \\] 29 [:ctrl \]] 30 [:ctrl \^] + 31 [:ctrl \_] 127 :backspace}) + +(def ^:private csi-sequences + {"A" :up "B" :down "C" :right "D" :left + "H" :home "F" :end "Z" :shift-tab + "1~" :home "2~" :insert "3~" :delete + "4~" :end "5~" :page-up "6~" :page-down + "7~" :home "8~" :end + ;; Function keys + "11~" :f1 "12~" :f2 "13~" :f3 "14~" :f4 + "15~" :f5 "17~" :f6 "18~" :f7 "19~" :f8 + "20~" :f9 "21~" :f10 "23~" :f11 "24~" :f12 + ;; xterm-style function keys + "OP" :f1 "OQ" :f2 "OR" :f3 "OS" :f4}) + +(defn- read-escape-sequence + "Read and parse an escape sequence." + [] + (let [c2 (term/read-char-timeout 50)] + (cond + (nil? c2) + [:key :escape] + + (= c2 \[) + ;; CSI sequence + (loop [buf []] + (let [c (term/read-char-timeout 50)] + (cond + (nil? c) + [:key :escape] + + ;; Parameters and intermediates + (or (<= 0x30 (int c) 0x3F) ; 0-9:;<=>? + (<= 0x20 (int c) 0x2F)) ; space to / + (recur (conj buf c)) + + ;; Final byte + (<= 0x40 (int c) 0x7E) + (let [seq-str (str (apply str buf) c)] + (if-let [key (get csi-sequences seq-str)] + [:key key] + [:key :unknown seq-str])) + + :else + [:key :unknown (str "[" (apply str buf) c)]))) + + (= c2 \O) + ;; SS3 sequence (F1-F4 on some terminals) + (let [c3 (term/read-char-timeout 50)] + (if c3 + (if-let [key (get csi-sequences (str "O" c3))] + [:key key] + [:key :unknown (str "O" c3)]) + [:key :escape])) + + :else + ;; Alt+key + [:key {:alt true :char c2}]))) + +(defn read-key + "Read a single key event. Returns [:key ...] message." + [] + (when-let [c (term/read-char)] + (let [code (int c)] + (cond + ;; Escape sequence + (= code 27) + (read-escape-sequence) + + ;; Control characters + (<= 0 code 31) + (let [key (get ctrl-keys code)] + (if (vector? key) + [:key {:ctrl true :char (second key)}] + [:key key])) + + ;; DEL (Ctrl+Backspace on some terminals) + (= code 127) + [:key :backspace] + + ;; Normal character + :else + [:key {:char c}])))) + +(defn key-match? + "Check if a key message matches a pattern. + Patterns: :enter, :up, \"q\", [:ctrl \\c], etc." + [msg pattern] + (when (= (first msg) :key) + (let [key (second msg)] + (cond + ;; Simple keyword match + (keyword? pattern) + (or (= key pattern) + (= (:key key) pattern)) + + ;; String match (single char) + (string? pattern) + (and (map? key) + (= (:char key) (first pattern)) + (not (:ctrl key)) + (not (:alt key))) + + ;; Vector pattern [:ctrl \c] + (vector? pattern) + (let [[mod ch] pattern] + (and (map? key) + (case mod + :ctrl (and (:ctrl key) (= (:char key) ch)) + :alt (and (:alt key) (= (:char key) ch)) + false))) + + :else false)))) + +(defn key->str + "Convert key message to human-readable string." + [msg] + (when (= (first msg) :key) + (let [key (second msg)] + (cond + (keyword? key) + (name key) + + (map? key) + (str (when (:ctrl key) "ctrl+") + (when (:alt key) "alt+") + (:char key)) + + :else + (str key))))) diff --git a/src/tui/render.clj b/src/tui/render.clj new file mode 100644 index 0000000..ab2da62 --- /dev/null +++ b/src/tui/render.clj @@ -0,0 +1,185 @@ +(ns tui.render + "Render hiccup to ANSI strings." + (:require [tui.ansi :as ansi] + [clojure.string :as str])) + +;; === Hiccup Parsing === +(defn- parse-element + "Parse hiccup element into [tag attrs children]." + [elem] + (cond + (string? elem) [:text {} [elem]] + (number? elem) [:text {} [(str elem)]] + (nil? elem) [:text {} [""]] + (vector? elem) + (let [[tag & rest] elem + [attrs children] (if (map? (first rest)) + [(first rest) (vec (next rest))] + [{} (vec rest)])] + [tag attrs children]) + :else [:text {} [(str elem)]])) + +;; === Text Rendering === +(defn- apply-style + "Apply style attributes to text." + [text {:keys [fg bg bold dim italic underline inverse strike]}] + (if (or fg bg bold dim italic underline inverse strike) + (ansi/style text + :fg fg :bg bg + :bold bold :dim dim :italic italic + :underline underline :inverse inverse :strike strike) + text)) + +(defn- render-text + "Render :text element." + [attrs children] + (let [content (apply str (flatten children))] + (apply-style content attrs))) + +;; === Layout Primitives === +(declare render-element) + +(defn- render-children + "Render all children and return list of rendered strings." + [children ctx] + (mapv #(render-element % ctx) children)) + +(defn- render-row + "Render :row - horizontal layout." + [{:keys [gap justify align] :or {gap 0}} children ctx] + (let [rendered (render-children children ctx) + separator (apply str (repeat gap " "))] + (str/join separator rendered))) + +(defn- render-col + "Render :col - vertical layout." + [{:keys [gap] :or {gap 0}} children ctx] + (let [rendered (render-children children ctx) + separator (str/join (repeat gap "\n"))] + (str/join (str "\n" separator) rendered))) + +(defn- render-box + "Render :box - bordered container." + [{:keys [border title padding width] + :or {border :rounded padding 0}} + children ctx] + (let [chars (get ansi/box-chars border (:rounded ansi/box-chars)) + content (str/join "\n" (render-children children ctx)) + lines (str/split content #"\n" -1) + + ;; Calculate padding + [pad-top pad-right pad-bottom pad-left] + (cond + (number? padding) [padding padding padding padding] + (vector? padding) + (case (count padding) + 1 (let [p (first padding)] [p p p p]) + 2 (let [[v h] padding] [v h v h]) + 4 padding + [0 0 0 0]) + :else [0 0 0 0]) + + ;; Calculate content width + max-content-width (apply max 0 (map ansi/visible-length lines)) + inner-width (+ max-content-width pad-left pad-right) + box-width (or width (+ inner-width 2)) + content-width (- box-width 2) + + ;; Pad lines + padded-lines (for [line lines] + (str (apply str (repeat pad-left " ")) + (ansi/pad-right line (- content-width pad-left pad-right)) + (apply str (repeat pad-right " ")))) + + ;; Add vertical padding + empty-line (apply str (repeat content-width " ")) + all-lines (concat + (repeat pad-top empty-line) + padded-lines + (repeat pad-bottom empty-line)) + + ;; Build box + top-line (str (:tl chars) + (if title + (str " " title " " + (apply str (repeat (- content-width (count title) 3) (:h chars)))) + (apply str (repeat content-width (:h chars)))) + (:tr chars)) + bottom-line (str (:bl chars) + (apply str (repeat content-width (:h chars))) + (:br chars)) + body-lines (for [line all-lines] + (str (:v chars) + (ansi/pad-right line content-width) + (:v chars)))] + (str/join "\n" (concat [top-line] body-lines [bottom-line])))) + +(defn- render-space + "Render :space - empty space." + [{:keys [width height] :or {width 1 height 1}} _ _] + (let [line (apply str (repeat width " "))] + (str/join "\n" (repeat height line)))) + +;; === Main Render Function === +(defn render-element + "Render a hiccup element to ANSI string." + [elem ctx] + (cond + ;; Raw string - just return it + (string? elem) elem + + ;; Number - convert to string + (number? elem) (str elem) + + ;; Nil - empty string + (nil? elem) "" + + ;; Vector - hiccup element + (vector? elem) + (let [[tag attrs children] (parse-element elem)] + (case tag + :text (render-text attrs children) + :row (render-row attrs children ctx) + :col (render-col attrs children ctx) + :box (render-box attrs children ctx) + :space (render-space attrs children ctx) + ;; Default: just render children + (apply str (render-children children ctx)))) + + ;; Anything else - convert to string + :else (str elem))) + +(defn render + "Render hiccup to ANSI string." + ([hiccup] (render hiccup {})) + ([hiccup ctx] + (render-element hiccup ctx))) + +;; === Convenience Components === +(defn text + "Create a text element." + [& args] + (if (map? (first args)) + (into [:text (first args)] (rest args)) + (into [:text {}] args))) + +(defn row + "Create a row (horizontal) layout." + [& args] + (if (map? (first args)) + (into [:row (first args)] (rest args)) + (into [:row {}] args))) + +(defn col + "Create a col (vertical) layout." + [& args] + (if (map? (first args)) + (into [:col (first args)] (rest args)) + (into [:col {}] args))) + +(defn box + "Create a bordered box." + [& args] + (if (map? (first args)) + (into [:box (first args)] (rest args)) + (into [:box {}] args))) diff --git a/src/tui/simple.clj b/src/tui/simple.clj new file mode 100644 index 0000000..0ba6e16 --- /dev/null +++ b/src/tui/simple.clj @@ -0,0 +1,68 @@ +(ns tui.simple + "Simplified TUI runtime - no core.async, works with Babashka. + Synchronous event loop, no timers/async commands." + (:require [tui.terminal :as term] + [tui.input :as input] + [tui.render :as render] + [tui.ansi :as ansi])) + +;; === Commands === +(def quit [:quit]) + +;; === Key Matching === +(defn key= + "Check if message is a specific key." + [msg key-pattern] + (input/key-match? msg key-pattern)) + +(defn key-str + "Get string representation of key." + [msg] + (input/key->str msg)) + +;; === Simple Run Loop === +(defn run + "Run a TUI application (synchronous, no async commands). + + Options: + - :init - Initial model (required) + - :update - (fn [model msg] [new-model cmd]) (required) + - :view - (fn [model] hiccup) (required) + - :alt-screen - Use alternate screen buffer (default false) + + Returns the final model." + [{:keys [init update view alt-screen] + :or {alt-screen false}}] + + ;; Setup terminal + (term/init-input!) + (term/raw-mode!) + (when alt-screen (term/alt-screen!)) + (term/clear!) + + (try + ;; Initial render + (term/render! (render/render (view init))) + + ;; Main loop - simple synchronous + (loop [model init] + (if-let [key-msg (input/read-key)] + (let [[new-model cmd] (update model key-msg)] + ;; Render + (term/render! (render/render (view new-model))) + + ;; Check for quit + (if (= cmd [:quit]) + new-model + (recur new-model))) + (recur model))) + + (finally + ;; Cleanup + (when alt-screen (term/exit-alt-screen!)) + (term/restore!) + (term/close-input!) + (println)))) + +;; Re-export render +(def render render/render) diff --git a/src/tui/terminal.clj b/src/tui/terminal.clj new file mode 100644 index 0000000..a89157b --- /dev/null +++ b/src/tui/terminal.clj @@ -0,0 +1,127 @@ +(ns tui.terminal + "Terminal management: raw mode, size, input/output." + (:require [tui.ansi :as ansi] + [clojure.java.io :as io] + [clojure.java.shell :refer [sh]]) + (:import [java.io BufferedReader InputStreamReader])) + +;; === Terminal State === +(def ^:private original-stty (atom nil)) + +(defn- stty [& args] + (let [result (apply sh "stty" (concat args [:in (io/file "/dev/tty")]))] + (when (zero? (:exit result)) + (clojure.string/trim (:out result))))) + +(defn get-terminal-size + "Get terminal dimensions as [width height]." + [] + (try + (let [result (stty "size")] + (when result + (let [[rows cols] (map parse-long (clojure.string/split result #"\s+"))] + {:width cols :height rows}))) + (catch Exception _ + {:width 80 :height 24}))) + +(defn raw-mode! + "Enter raw terminal mode (no echo, no line buffering)." + [] + (reset! original-stty (stty "-g")) + (stty "raw" "-echo" "-icanon" "min" "1") + (print ansi/hide-cursor) + (flush)) + +(defn restore! + "Restore terminal to original state." + [] + (when @original-stty + (stty @original-stty) + (reset! original-stty nil)) + (print ansi/show-cursor) + (print ansi/reset) + (flush)) + +(defn alt-screen! + "Enter alternate screen buffer." + [] + (print ansi/enter-alt-screen) + (flush)) + +(defn exit-alt-screen! + "Exit alternate screen buffer." + [] + (print ansi/exit-alt-screen) + (flush)) + +(defn clear! + "Clear screen and move cursor home." + [] + (print ansi/clear-screen) + (print ansi/cursor-home) + (flush)) + +(defn render! + "Render string to terminal." + [s] + (print ansi/cursor-home) + (print ansi/clear-to-end) + (print s) + (flush)) + +;; === Input Handling === +(def ^:private tty-reader (atom nil)) + +(defn init-input! + "Initialize input reader from /dev/tty." + [] + (reset! tty-reader + (BufferedReader. + (InputStreamReader. + (java.io.FileInputStream. "/dev/tty"))))) + +(defn close-input! + "Close input reader." + [] + (when-let [r @tty-reader] + (.close r) + (reset! tty-reader nil))) + +(defn read-char + "Read a single character. Blocking." + [] + (when-let [r @tty-reader] + (let [c (.read r)] + (when (>= c 0) + (char c))))) + +(defn read-available + "Read all available characters without blocking." + [] + (when-let [r @tty-reader] + (loop [chars []] + (if (.ready r) + (let [c (.read r)] + (if (>= c 0) + (recur (conj chars (char c))) + chars)) + chars)))) + +(defn read-char-timeout + "Read char with timeout in ms. Returns nil on timeout." + [timeout-ms] + (when-let [r @tty-reader] + (let [deadline (+ (System/currentTimeMillis) timeout-ms)] + (loop [] + (cond + (.ready r) + (let [c (.read r)] + (when (>= c 0) (char c))) + + (> (System/currentTimeMillis) deadline) + nil + + :else + (do + (Thread/sleep 1) + (recur)))))))