Ratatouille
A declarative TUI kit implementing The Elm Architecture (TEA). Manages state with Model-Update-View pattern and builds UI with HTML-like View DSL. Based on Termbox API.
GitHub Overview
ndreynolds/ratatouille
A TUI (terminal UI) kit for Elixir
Topics
Star History
Ratatouille
Ratatouille is a declarative terminal UI kit for Elixir. It implements The Elm Architecture (TEA) and allows you to build rich text-based terminal applications with HTML-like syntax. It's built on the termbox API (using Elixir bindings to ex_termbox).
Features
The Elm Architecture (TEA)
- Model: The application state
- Update: How to update the state
- View: How to render the state as a
%Ratatouille.Element{}
tree
Core Callbacks
init/1
: Define the initial model, optionally using contextupdate/2
: Process messages and return a new modelrender/1
: Receive a model and build a view (element tree)
View DSL
- HTML-like: Element builder functions similar to HTML tags like view, row, table, label
- Declarative: Declarative UI construction approach
- Validation: Validates view tree structure and raises errors
- Components: Build reusable components
Foundation Technology
- Termbox: Uses ex_termbox bindings to the C termbox library
- Runtime: Application runtime handles startup, execution, and event processing
- Event-driven: Handles keyboard, mouse, and timer events
Basic Usage
Installation
# mix.exs
defp deps do
[
{:ratatouille, "~> 0.5.1"}
]
end
mix deps.get
Hello World
defmodule HelloWorld do
@behaviour Ratatouille.App
import Ratatouille.View
def init(_context) do
%{message: "Hello, Ratatouille!"}
end
def update(model, _msg) do
model
end
def render(model) do
view do
row do
column(size: 12) do
panel(title: "Welcome") do
label(content: model.message)
end
end
end
end
end
end
# Start the application
Ratatouille.run(HelloWorld)
Counter App
defmodule CounterApp do
@behaviour Ratatouille.App
import Ratatouille.View
# Initial state
def init(_context) do
%{count: 0}
end
# State updates
def update(%{count: count} = model, msg) do
case msg do
{:event, %{ch: ?+}} -> %{model | count: count + 1}
{:event, %{ch: ?-}} -> %{model | count: count - 1}
{:event, %{ch: ?r}} -> %{model | count: 0}
{:event, %{key: key}} when key in [:q, :ctrl_c] ->
Ratatouille.Runtime.Supervisor.stop()
model
_ -> model
end
end
# UI rendering
def render(%{count: count}) do
view do
panel(title: "Counter App", height: :fill) do
row do
column(size: 12) do
label(content: "Current count: #{count}")
end
end
row do
column(size: 12) do
label(content: "Commands: [+] Increment, [-] Decrement, [r] Reset, [q] Quit")
end
end
end
end
end
end
# Start the application
Ratatouille.run(CounterApp)
Table Display App
defmodule TableApp do
@behaviour Ratatouille.App
import Ratatouille.View
def init(_context) do
%{
people: [
%{name: "John Doe", age: 30, city: "New York"},
%{name: "Jane Smith", age: 25, city: "Los Angeles"},
%{name: "Bob Johnson", age: 35, city: "Chicago"},
%{name: "Alice Brown", age: 28, city: "San Francisco"},
%{name: "Tom Wilson", age: 32, city: "Seattle"}
],
selected_row: 0
}
end
def update(%{people: people} = model, msg) do
case msg do
{:event, %{key: :arrow_up}} ->
new_row = max(0, model.selected_row - 1)
%{model | selected_row: new_row}
{:event, %{key: :arrow_down}} ->
new_row = min(length(people) - 1, model.selected_row + 1)
%{model | selected_row: new_row}
{:event, %{key: key}} when key in [:q, :ctrl_c] ->
Ratatouille.Runtime.Supervisor.stop()
model
_ -> model
end
end
def render(%{people: people, selected_row: selected_row}) do
rows = people
|> Enum.with_index()
|> Enum.map(fn {person, index} ->
attributes = if index == selected_row, do: [color: :yellow], else: []
table_row(attributes) do
[
table_cell(content: person.name),
table_cell(content: to_string(person.age)),
table_cell(content: person.city)
]
end
end)
view do
panel(title: "Person List (Use ↑↓ to navigate, q to quit)") do
table do
table_row do
[
table_cell(content: "Name", attributes: [color: :cyan]),
table_cell(content: "Age", attributes: [color: :cyan]),
table_cell(content: "City", attributes: [color: :cyan])
]
end
rows
end
if selected_row < length(people) do
person = Enum.at(people, selected_row)
label(content: "Selected: #{person.name} (#{person.age}yo) - #{person.city}")
end
end
end
end
end
Ratatouille.run(TableApp)
Form Input App
defmodule FormApp do
@behaviour Ratatouille.App
import Ratatouille.View
def init(_context) do
%{
form: %{
name: "",
email: "",
message: ""
},
current_field: :name,
submitted: false
}
end
def update(model, msg) do
case msg do
{:event, %{key: :tab}} ->
next_field = case model.current_field do
:name -> :email
:email -> :message
:message -> :name
end
%{model | current_field: next_field}
{:event, %{key: :enter}} when model.current_field == :message ->
%{model | submitted: true}
{:event, %{key: :backspace}} ->
update_current_field(model, &String.slice(&1, 0..-2))
{:event, %{ch: ch}} when ch > 0 ->
update_current_field(model, &(&1 <> <<ch::utf8>>))
{:event, %{key: key}} when key in [:q, :ctrl_c] ->
Ratatouille.Runtime.Supervisor.stop()
model
_ -> model
end
end
defp update_current_field(%{form: form, current_field: field} = model, update_fn) do
new_value = update_fn.(Map.get(form, field))
new_form = Map.put(form, field, new_value)
%{model | form: new_form}
end
def render(%{form: form, current_field: current_field, submitted: submitted}) do
view do
if submitted do
panel(title: "Submission Complete") do
label(content: "Name: #{form.name}")
label(content: "Email: #{form.email}")
label(content: "Message: #{form.message}")
label(content: "")
label(content: "[q] to quit")
end
else
panel(title: "Contact Form") do
row do
column(size: 3) do
label(content: "Name:")
end
column(size: 9) do
content = form.name <> if current_field == :name, do: "_", else: ""
color = if current_field == :name, do: :yellow, else: :white
label(content: content, attributes: [color: color])
end
end
row do
column(size: 3) do
label(content: "Email:")
end
column(size: 9) do
content = form.email <> if current_field == :email, do: "_", else: ""
color = if current_field == :email, do: :yellow, else: :white
label(content: content, attributes: [color: color])
end
end
row do
column(size: 3) do
label(content: "Message:")
end
column(size: 9) do
content = form.message <> if current_field == :message, do: "_", else: ""
color = if current_field == :message, do: :yellow, else: :white
label(content: content, attributes: [color: color])
end
end
label(content: "")
label(content: "[Tab] Switch field, [Enter] Submit (when message selected), [q] Quit")
end
end
end
end
end
Ratatouille.run(FormApp)
Real-time Data Updates
defmodule RealtimeApp do
@behaviour Ratatouille.App
import Ratatouille.View
def init(_context) do
# Send update message every second
:timer.send_interval(1000, self(), :tick)
%{
current_time: DateTime.utc_now(),
counter: 0,
random_number: :rand.uniform(100)
}
end
def update(model, msg) do
case msg do
:tick ->
%{model |
current_time: DateTime.utc_now(),
counter: model.counter + 1,
random_number: :rand.uniform(100)
}
{:event, %{ch: ?r}} ->
%{model | counter: 0, random_number: :rand.uniform(100)}
{:event, %{key: key}} when key in [:q, :ctrl_c] ->
Ratatouille.Runtime.Supervisor.stop()
model
_ -> model
end
end
def render(%{current_time: time, counter: counter, random_number: random}) do
formatted_time = Calendar.strftime(time, "%Y-%m-%d %H:%M:%S UTC")
view do
panel(title: "Real-time Dashboard") do
row do
column(size: 6) do
panel(title: "Time") do
label(content: formatted_time)
end
end
column(size: 6) do
panel(title: "Counter") do
label(content: "Elapsed seconds: #{counter}")
end
end
end
row do
column(size: 6) do
panel(title: "Random Number") do
label(content: "#{random}")
end
end
column(size: 6) do
panel(title: "Controls") do
label(content: "[r] Reset")
label(content: "[q] Quit")
end
end
end
end
end
end
end
Ratatouille.run(RealtimeApp)
Advanced Features
Component Reusability
defmodule Components do
import Ratatouille.View
def progress_bar(percentage) when percentage >= 0 and percentage <= 100 do
filled_width = div(percentage, 5)
empty_width = 20 - filled_width
filled = String.duplicate("█", filled_width)
empty = String.duplicate("░", empty_width)
row do
column(size: 12) do
label(content: "[#{filled}#{empty}] #{percentage}%")
end
end
end
def status_indicator(status) do
{color, symbol, text} = case status do
:ok -> {:green, "✓", "OK"}
:warning -> {:yellow, "⚠", "Warning"}
:error -> {:red, "✗", "Error"}
_ -> {:white, "?", "Unknown"}
end
label(content: "#{symbol} #{text}", attributes: [color: color])
end
def info_card(title, content, color \\ :white) do
panel(title: title, attributes: [color: color]) do
if is_list(content) do
Enum.map(content, &label(content: &1))
else
label(content: content)
end
end
end
end
defmodule ComponentsApp do
@behaviour Ratatouille.App
import Ratatouille.View
import Components
def init(_context) do
%{
progress: 75,
status: :ok,
info: [
"System running normally",
"CPU usage: 45%",
"Memory usage: 62%"
]
}
end
def update(model, msg) do
case msg do
{:event, %{ch: ?+}} ->
new_progress = min(100, model.progress + 5)
%{model | progress: new_progress}
{:event, %{ch: ?-}} ->
new_progress = max(0, model.progress - 5)
%{model | progress: new_progress}
{:event, %{ch: ?s}} ->
new_status = case model.status do
:ok -> :warning
:warning -> :error
:error -> :ok
end
%{model | status: new_status}
{:event, %{key: key}} when key in [:q, :ctrl_c] ->
Ratatouille.Runtime.Supervisor.stop()
model
_ -> model
end
end
def render(%{progress: progress, status: status, info: info}) do
view do
panel(title: "Component Demo") do
row do
column(size: 6) do
panel(title: "Progress Bar") do
progress_bar(progress)
end
end
column(size: 6) do
panel(title: "Status") do
status_indicator(status)
end
end
end
row do
column(size: 12) do
info_card("System Information", info, :cyan)
end
end
label(content: "")
label(content: "[+/-] Change progress, [s] Toggle status, [q] Quit")
end
end
end
end
Ratatouille.run(ComponentsApp)
Command Line Arguments and Configuration
defmodule ConfigurableApp do
@behaviour Ratatouille.App
import Ratatouille.View
def init(context) do
# Load configuration from command line arguments
config = case context.argv do
["--theme", theme | _] -> %{theme: String.to_atom(theme)}
["--debug" | _] -> %{debug: true}
_ -> %{theme: :default, debug: false}
end
%{
config: config,
message: "Application started"
}
end
def update(model, msg) do
case msg do
{:event, %{ch: ?d}} ->
new_debug = !model.config.debug
new_config = %{model.config | debug: new_debug}
message = if new_debug, do: "Debug mode ON", else: "Debug mode OFF"
%{model | config: new_config, message: message}
{:event, %{ch: ?t}} ->
new_theme = case model.config.theme do
:default -> :dark
:dark -> :light
:light -> :default
end
new_config = %{model.config | theme: new_theme}
%{model | config: new_config, message: "Theme changed: #{new_theme}"}
{:event, %{key: key}} when key in [:q, :ctrl_c] ->
Ratatouille.Runtime.Supervisor.stop()
model
_ -> model
end
end
def render(%{config: config, message: message}) do
theme_color = case config.theme do
:dark -> :blue
:light -> :yellow
_ -> :white
end
view do
panel(title: "Configurable App", attributes: [color: theme_color]) do
label(content: "Current theme: #{config.theme}")
label(content: "Debug mode: #{if config.debug, do: "ON", else: "OFF"}")
label(content: "")
label(content: "Message: #{message}")
label(content: "")
if config.debug do
panel(title: "Debug Information", attributes: [color: :red]) do
label(content: "PID: #{inspect(self())}")
label(content: "Time: #{DateTime.utc_now() |> DateTime.to_string()}")
end
end
label(content: "[t] Toggle theme, [d] Debug mode, [q] Quit")
end
end
end
end
# Start with command line arguments
# mix run -e "Ratatouille.run(ConfigurableApp, argv: [\"--theme\", \"dark\"])"
Ecosystem
Related Projects
- Garnish: TUI framework for SSH-based applications
- ElementTui: Simple TUI library based on termbox2
- lazyasdf: TUI for asdf version manager
Foundation Libraries
- ex_termbox: Elixir bindings for the C termbox library
- OTP: Leverages Erlang/OTP GenServer and Supervisor patterns
Community
- Elixir Forum: Active community support
- GitHub: Open source development
- Hex.pm: Official package manager
Advantages
- Elm Architecture: Predictable and maintainable architecture
- Declarative UI: HTML-like intuitive UI construction
- Elixir Power: Leverages concurrency, fault tolerance, and OTP
- Component Reusability: Functional approach to component reuse
- Validation: Early error detection for view structures
Limitations
- Low-level Operations: Manual management of cursor position and data indices
- Limited Features: Lack of high-level widgets
- Performance: Performance issues with large amounts of data
- Documentation: Limited Japanese documentation
- Community: Smaller community compared to other languages
Comparison with Other Libraries
Feature | Ratatouille | Garnish | ElementTui |
---|---|---|---|
Architecture | Elm | Ratatouille-based | Custom |
Specialization | General TUI | SSH apps | General TUI |
Foundation | termbox | ssh + termbox | termbox2 |
Maturity | High | Low (new) | Medium |
Community | Largest | Small | Small |
Summary
Ratatouille is the most mature TUI framework in the Elixir ecosystem. By adopting The Elm Architecture, it enables predictable and maintainable application construction, and the HTML-like View DSL allows for intuitive UI development. It can leverage Elixir's concurrency, fault tolerance, and OTP features, making it ideal for building robust and scalable TUI applications.