Ratatouille

Elmアーキテクチャ(TEA)を実装した宣言的なTUIキット。Model-Update-Viewパターンで状態管理を行い、HTMLライクなView DSLでUIを構築。Termbox APIベース。

TUITerminalElixirElm-ArchitectureDeclarative

GitHub概要

ndreynolds/ratatouille

A TUI (terminal UI) kit for Elixir

スター784
ウォッチ9
フォーク41
作成日:2019年1月13日
言語:Elixir
ライセンス:MIT License

トピックス

elixirterminaltui

スター履歴

ndreynolds/ratatouille Star History
データ取得日時: 2025/7/25 11:09

Ratatouille

Ratatouilleは、Elixir用の宣言的ターミナルUIキットです。Elmアーキテクチャ(TEA)を実装し、HTMLを書くようにリッチなテキストベースのターミナルアプリケーションを構築できます。termbox API(ex_termboxのElixirバインディングを使用)を基盤としています。

特徴

Elmアーキテクチャ(TEA)

  • Model: アプリケーションの状態
  • Update: 状態を更新する方法
  • View: 状態を%Ratatouille.Element{}ツリーとして表示する方法

コアコールバック

  • init/1: 初期モデルを定義、必要に応じてコンテキストを使用
  • update/2: メッセージを処理し、新しいモデルを返す
  • render/1: モデルを受け取り、ビュー(要素ツリー)を構築

View DSL

  • HTMLライク: view、row、table、labelなどのHTMLタグライクな要素ビルダー関数
  • 宣言的: 宣言的なUI構築方式
  • バリデーション: ビューツリーの構造を検証し、エラーを発生
  • コンポーネント: 再利用可能なコンポーネント構築

基盤技術

  • Termbox: ex_termboxを使用したC termboxライブラリへのバインディング
  • ランタイム: アプリケーションランタイムが起動、実行、イベント処理を管理
  • イベント駆動: キーボード、マウス、タイマーイベントの処理

基本的な使用方法

インストール

# 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

# アプリケーションを起動
Ratatouille.run(HelloWorld)

カウンターアプリ

defmodule CounterApp do
  @behaviour Ratatouille.App

  import Ratatouille.View

  # 初期状態
  def init(_context) do
    %{count: 0}
  end

  # 状態更新
  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レンダリング
  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

# アプリケーションを起動
Ratatouille.run(CounterApp)

テーブル表示アプリ

defmodule TableApp do
  @behaviour Ratatouille.App

  import Ratatouille.View

  def init(_context) do
    %{
      people: [
        %{name: "山田太郎", age: 30, city: "東京"},
        %{name: "佐藤花子", age: 25, city: "大阪"},
        %{name: "鈴木一郎", age: 35, city: "名古屋"},
        %{name: "田中美奈", age: 28, city: "福岡"},
        %{name: "高橋健太", age: 32, city: "京都"}
      ],
      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: "名前", attributes: [color: :cyan]),
              table_cell(content: "年齢", attributes: [color: :cyan]),
              table_cell(content: "都市", attributes: [color: :cyan])
            ]
          end
          
          rows
        end
        
        if selected_row < length(people) do
          person = Enum.at(people, selected_row)
          label(content: "選択中: #{person.name} (#{person.age}歳) - #{person.city}")
        end
      end
    end
  end
end

Ratatouille.run(TableApp)

フォーム入力アプリ

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: "送信完了") do
          label(content: "お名前: #{form.name}")
          label(content: "メール: #{form.email}")
          label(content: "メッセージ: #{form.message}")
          label(content: "")
          label(content: "[q] で終了")
        end
      else
        panel(title: "お問い合わせフォーム") do
          row do
            column(size: 3) do
              label(content: "お名前:")
            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: "メール:")
            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: "メッセージ:")
            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] フィールド切り替え, [Enter] 送信 (メッセージ選択時), [q] 終了")
        end
      end
    end
  end
end

Ratatouille.run(FormApp)

リアルタイムデータ更新

defmodule RealtimeApp do
  @behaviour Ratatouille.App

  import Ratatouille.View

  def init(_context) do
    # 1秒ごとに更新メッセージを送信
    :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: "リアルタイムダッシュボード") do
        row do
          column(size: 6) do
            panel(title: "時刻") do
              label(content: formatted_time)
            end
          end
          
          column(size: 6) do
            panel(title: "カウンター") do
              label(content: "経過秒数: #{counter}")
            end
          end
        end
        
        row do
          column(size: 6) do
            panel(title: "ランダム数") do
              label(content: "#{random}")
            end
          end
          
          column(size: 6) do
            panel(title: "コントラクション") do
              label(content: "[r] リセット")
              label(content: "[q] 終了")
            end
          end
        end
      end
    end
  end
end

Ratatouille.run(RealtimeApp)

高度な機能

コンポーネントの再利用

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: [
        "システム正常動作中",
        "CPU使用率: 45%",
        "メモリ使用率: 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: "コンポーネントデモ") do
        row do
          column(size: 6) do
            panel(title: "プログレスバー") do
              progress_bar(progress)
            end
          end
          
          column(size: 6) do
            panel(title: "ステータス") do
              status_indicator(status)
            end
          end
        end
        
        row do
          column(size: 12) do
            info_card("システム情報", info, :cyan)
          end
        end
        
        label(content: "")
        label(content: "[+/-] プログレス変更, [s] ステータス切り替え, [q] 終了")
      end
    end
  end
end

Ratatouille.run(ComponentsApp)

コマンドライン引数と設定

defmodule ConfigurableApp do
  @behaviour Ratatouille.App

  import Ratatouille.View

  def init(context) do
    # コマンドライン引数から設定を読み込み
    config = case context.argv do
      ["--theme", theme | _] -> %{theme: String.to_atom(theme)}
      ["--debug" | _] -> %{debug: true}
      _ -> %{theme: :default, debug: false}
    end
    
    %{
      config: config,
      message: "アプリケーションが起動しました"
    }
  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: "デバッグモードON", else: "デバッグモード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: "テーマ変更: #{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: "設定可能アプリ", attributes: [color: theme_color]) do
        label(content: "現在のテーマ: #{config.theme}")
        label(content: "デバッグモード: #{if config.debug, do: "ON", else: "OFF"}")
        label(content: "")
        label(content: "メッセージ: #{message}")
        label(content: "")
        
        if config.debug do
          panel(title: "デバッグ情報", attributes: [color: :red]) do
            label(content: "PID: #{inspect(self())}")
            label(content: "時刻: #{DateTime.utc_now() |> DateTime.to_string()}")
          end
        end
        
        label(content: "[t] テーマ切り替え, [d] デバッグモード, [q] 終了")
      end
    end
  end
end

# コマンドライン引数付きで起動
# mix run -e "Ratatouille.run(ConfigurableApp, argv: [\"--theme\", \"dark\"])"

エコシステム

関連プロジェクト

  • Garnish: SSHベースアプリケーション用TUIフレームワーク
  • ElementTui: termbox2ベースのシンプルTUIライブラリ
  • lazyasdf: asdfバージョンマネージャー用TUI

基盤ライブラリ

  • ex_termbox: C termboxライブラリのElixirバインディング
  • OTP: Erlang/OTPのGenServer、Supervisorパターンを活用

コミュニティ

  • Elixir Forum: アクティブなコミュニティサポート
  • GitHub: オープンソース開発
  • Hex.pm: 公式パッケージマネージャー

利点

  • Elmアーキテクチャ: 予測可能でメンテナンスしやすいアーキテクチャ
  • 宣言的UI: HTMLライクな直感的なUI構築
  • Elixirの力: 並行性、フォールトトレランス、OTPの活用
  • コンポーネント再利用: 関数型アプローチでのコンポーネント再利用
  • バリデーション: ビュー構造の早期エラー検出

制約事項

  • 低レベル操作: カーソル位置、データインデックスの手動管理
  • 機能限定: 高レベルウィジェットの不足
  • パフォーマンス: 大量データでのパフォーマンス問題
  • ドキュメント: 日本語資料が限定的
  • コミュニティ: 他言語と比べてコミュニティが小さい

他のライブラリとの比較

項目RatatouilleGarnishElementTui
アーキテクチャElmRatatouilleベース独自
特化領域一般的TUISSHアプリ一般的TUI
基盤技術termboxssh + termboxtermbox2
成熟度低(新しい)
コミュニティ最大

まとめ

Ratatouilleは、Elixirエコシステムにおける最も成熟したTUIフレームワークです。Elmアーキテクチャの採用により予測可能でメンテナンスしやすいアプリケーション構築が可能であり、HTMLライクなView DSLで直感的なUI開発が実現できます。Elixirの並行性、フォールトトレランス、OTPの機能を活用できるため、堅牢でスケーラブルなTUIアプリケーション開発に最適です。