Ratatouille
Elmアーキテクチャ(TEA)を実装した宣言的なTUIキット。Model-Update-Viewパターンで状態管理を行い、HTMLライクなView DSLでUIを構築。Termbox APIベース。
GitHub概要
ndreynolds/ratatouille
A TUI (terminal UI) kit for Elixir
スター784
ウォッチ9
フォーク41
作成日:2019年1月13日
言語:Elixir
ライセンス:MIT License
トピックス
elixirterminaltui
スター履歴
データ取得日時: 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の活用
- コンポーネント再利用: 関数型アプローチでのコンポーネント再利用
- バリデーション: ビュー構造の早期エラー検出
制約事項
- 低レベル操作: カーソル位置、データインデックスの手動管理
- 機能限定: 高レベルウィジェットの不足
- パフォーマンス: 大量データでのパフォーマンス問題
- ドキュメント: 日本語資料が限定的
- コミュニティ: 他言語と比べてコミュニティが小さい
他のライブラリとの比較
項目 | Ratatouille | Garnish | ElementTui |
---|---|---|---|
アーキテクチャ | Elm | Ratatouilleベース | 独自 |
特化領域 | 一般的TUI | SSHアプリ | 一般的TUI |
基盤技術 | termbox | ssh + termbox | termbox2 |
成熟度 | 高 | 低(新しい) | 中 |
コミュニティ | 最大 | 小 | 小 |
まとめ
Ratatouilleは、Elixirエコシステムにおける最も成熟したTUIフレームワークです。Elmアーキテクチャの採用により予測可能でメンテナンスしやすいアプリケーション構築が可能であり、HTMLライクなView DSLで直感的なUI開発が実現できます。Elixirの並行性、フォールトトレランス、OTPの機能を活用できるため、堅牢でスケーラブルなTUIアプリケーション開発に最適です。