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.

TUITerminalElixirElm-ArchitectureDeclarative

GitHub Overview

ndreynolds/ratatouille

A TUI (terminal UI) kit for Elixir

Stars784
Watchers9
Forks41
Created:January 13, 2019
Language:Elixir
License:MIT License

Topics

elixirterminaltui

Star History

ndreynolds/ratatouille Star History
Data as of: 7/25/2025, 11:09 AM

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 context
  • update/2: Process messages and return a new model
  • render/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

FeatureRatatouilleGarnishElementTui
ArchitectureElmRatatouille-basedCustom
SpecializationGeneral TUISSH appsGeneral TUI
Foundationtermboxssh + termboxtermbox2
MaturityHighLow (new)Medium
CommunityLargestSmallSmall

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.