Current section
Files
Jump to
Current section
Files
README.md
> [!WARNING]
> This project is a work in progress. APIs may change without notice.
# Courgette
[](https://hex.pm/packages/courgette)
[](https://hexdocs.pm/courgette)
[](https://github.com/LoamStudios/courgette/blob/main/LICENSE)
A declarative TUI framework for Elixir, built on OTP.
Courgette gives you a component model inspired by Phoenix LiveView — stateful
components with `mount`, `render`, and `handle_event` callbacks — but targeting
the terminal instead of the browser. Layout uses a CSS inspired Flexbox engine.
Rendering is incremental via double-buffered diffing.
## Quick Start
```elixir
defmodule HelloApp do
use Courgette.App
def mount(_assigns), do: {:ok, %{count: 0}}
def render(assigns) do
box border: :rounded, padding: 1 do
text color: :cyan, bold: true do
"Count: #{assigns.count}"
end
end
end
def handle_event({:key, :arrow_up}, assigns) do
{:noreply, update(assigns, :count, &(&1 + 1))}
end
def handle_event({:key, {:char, "q"}}, _assigns) do
Courgette.stop()
{:noreply, %{}}
end
def handle_event(_event, assigns), do: {:noreply, assigns}
end
Courgette.run(HelloApp)
```
## Installation
> [!WARNING]
> v0.1.0 has not been released yet.
Add `courgette` to your dependencies in `mix.exs`:
```elixir
def deps do
[
{:courgette, "~> 0.1.0"}
]
end
```
Then `mix deps.get`.
## Features
- **Declarative DSL** — `box`, `text`, `input`, and friends compose into element
trees, just like HEEx templates but for terminal output
- **Stateful components** — `use Courgette.LiveComponent` gives you `mount/1`,
`render/1`, `handle_event/2`, and `handle_info/2` backed by a GenServer
- **Function components** — `use Courgette.Component` for stateless, reusable
UI functions with declared attributes and slots
- **CSS Flexbox layout** — `flex_direction`, `flex_grow`, `justify_content`,
`align_items`, `gap`, `min_width`/`max_width`, and more
- **Incremental rendering** — double-buffered diff produces minimal ANSI escape
sequences at ~60 FPS
- **Input handling** — full keyboard (including Kitty protocol), mouse (SGR),
bracketed paste, focus tracking, and resize events
- **Focus management** — Tab/Shift-Tab cycling with auto-focus, event bubbling
- **Theming** — semantic color tokens (`primary`, `danger`, `muted`, etc.)
referenced by components instead of hard-coded colors
- **Animation** — tweens with easing functions and frame-based spinners
- **Built-in components** — `TextInput`, `Textarea`, `Select`, `Spinner`,
`ProgressBar`, plus utility components (`badge`, `heading`, `divider`,
`key_value`, `empty_state`)
- **Test helpers** — headless renderer for fast, deterministic component tests
- **Zero dependencies** — pure Elixir/OTP, no NIFs, no C bindings
## Components
### Live Components (Stateful)
Live components are process-backed and manage their own state:
```elixir
defmodule Counter do
use Courgette.LiveComponent
def mount(_assigns) do
{:ok, %{count: 0}}
end
def render(assigns) do
box border: :rounded do
text do
"Count: #{assigns.count}"
end
end
end
def handle_event({:key, :arrow_up}, assigns) do
{:noreply, update(assigns, :count, &(&1 + 1))}
end
def handle_event({:key, :arrow_down}, assigns) do
{:noreply, update(assigns, :count, &(&1 - 1))}
end
def handle_event(_event, assigns), do: {:noreply, assigns}
end
```
Embed a live component inside another component's render:
```elixir
live_component(Counter, id: "main", initial_count: 5)
```
### Function Components (Stateless)
Function components are pure functions with declared attributes:
```elixir
defmodule MyComponents do
use Courgette.Component
attr :name, :string, default: "World"
attr :color, :atom, default: :green
def greeting(assigns) do
assigns = assigns(assigns)
text color: assigns.color do
"Hello, #{assigns.name}!"
end
end
end
```
### Built-in Components
```elixir
import Courgette.Components
badge(label: "OK", color: :green)
heading(text: "Dashboard")
divider(orientation: :horizontal)
key_value(label: "Status", value: "Running")
empty_state(message: "No items found")
```
Interactive components are live components:
- `Courgette.Components.TextInput` — single-line text input with cursor and Ctrl shortcuts
- `Courgette.Components.Textarea` — multi-line text area with scrolling
- `Courgette.Components.Select` — dropdown-style selector with arrow navigation
- `Courgette.Components.Spinner` — animated loading indicator (20+ styles)
- `Courgette.Components.ProgressBar` — animated progress bar with labels
## Layout
Courgette uses a port of the [taffy engine](https://lib.rs/crates/taffy) for
layout. It's been adapted for terminal layouts.
Style props go directly on elements:
```elixir
box flex_direction: :row, gap: 1 do
box flex_grow: 1, border: :single, padding: 1 do
text do: "Sidebar"
end
box flex_grow: 3, border: :rounded, padding: 1 do
text do: "Main content"
end
end
```
Supported layout props include: `flex_direction`, `flex_wrap`, `flex_grow`,
`flex_shrink`, `flex_basis`, `justify_content`, `align_items`, `align_content`,
`align_self`, `gap`, `width`, `height`, `min_width`, `max_width`, `min_height`,
`max_height`, `padding`, `padding_h`, `padding_v`, `margin`, `margin_h`,
`margin_v`, `border`, `border_color`, `overflow`.
## Theming
Components reference semantic tokens rather than hard-coding colors:
```elixir
color = theme(assigns, :primary) # :blue from default theme
muted = theme(assigns, :muted) # :bright_black
text color: color, do: "Important"
```
Default tokens: `bg`, `fg`, `primary`, `secondary`, `success`, `warning`,
`danger`, `muted`, `border`, `surface`.
Custom themes are maps from token names to color values (named atoms, 0-255
integers, or `{r, g, b}` tuples).
## Animation
### Tweens
Interpolate values over time with easing:
```elixir
def mount(_assigns) do
tween = Tween.new(0, 100, duration: 500, easing: :ease_out)
{:ok, %{tween: tween} |> Tween.start_timer()}
end
def handle_info(:tween_tick, assigns) do
case Tween.step(assigns.tween) do
{:running, value, tween} ->
{:noreply, assigns |> assign(tween: tween, progress: value) |> Tween.start_timer()}
{:done, value} ->
{:noreply, assigns |> assign(progress: value) |> Tween.stop_timer()}
end
end
```
Easing functions: `linear`, `ease_in`, `ease_out`, `ease_in_out`,
`ease_in_cubic`, `ease_out_cubic`, `bounce_out`.
### Frame-based Animation
For spinners and cyclic animations:
```elixir
live_component(Courgette.Components.Spinner, id: "loading", style: :dots)
```
## Testing
Courgette includes headless test helpers that run the full component lifecycle
without a real terminal:
```elixir
defmodule MyAppTest do
use ExUnit.Case
use Courgette.ComponentTestHelpers
test "renders initial state" do
view = mount(MyApp)
assert render_text(view) =~ "Count: 0"
end
test "handles keyboard events" do
view = mount(MyApp)
send_event(view, {:key, :arrow_up})
assert render_text(view) =~ "Count: 1"
end
test "tab cycles focus" do
view = mount(MyApp, width: 80, height: 24)
send_tab(view)
tree = render_tree(view)
# assert on tree structure
end
end
```
## Architecture
The rendering pipeline flows in one direction:
```
Element Tree → Layout (Flexbox) → Paint (Buffer) → Diff → ANSI → Terminal
```
1. Components return **element trees** — pure data describing what to render
2. The **layout engine** computes absolute positions and sizes using Flexbox
3. The **painter** fills a 2D cell buffer with styled graphemes
4. The **diff** compares front and back buffers to find changed cells
5. The **writer** emits minimal ANSI escape sequences for the changes
6. The **terminal** writes to stdout inside synchronized output markers
The renderer runs at ~60 FPS with frame batching — multiple state changes between
ticks are collapsed into a single render.
See the [Architecture Guide](guides/architecture.md) for a deep dive.
## Demos
18 runnable examples are included in `examples/`:
```bash
# Terminal + key parser demo
mix run examples/key_parser.exs
# Interactive counter (minimal live app)
mix run examples/counter.exs
# Full task tracker app exercising all components
mix run examples/task_tracker.exs
```
See the full list with `ls examples/`.
## License
MIT