Skip to content

The Elm Architecture (TEA)

ratatui を使用してターミナルユーザーインターフェイス (TUI) を構築する場合、アプリケーションを整理するためのしっかりした構造を持つことが役立ちます。実証済みのアーキテクチャの1つは、単にエルムアーキテクチャ ( TEA ) として知られるエルム言語に由来しています。

このセクションでは、ELMアーキテクチャの原則を ratatui TUIアプリに適用する方法について説明します。

エルムアーキテクチャ: 簡単な概要

そのコアでは、 TEA は3つの主要なコンポーネントに分割されます。

  • モデル: これはアプリケーションの状態です。アプリケーションが使用するすべてのデータが含まれています。
  • 更新: 変更がある場合 (ユーザー入力など) 、更新関数は現在のモデルと入力を取得し、新しいモデルを生成します。
  • 表示: この関数は、モデルをユーザーに表示する責任があります。ELMでは、HTMLを生成します。私たちの場合、ターミナルUI要素を生成します。
TUI ApplicationUserTUI ApplicationUserInput/Event/MessageUpdate (based on Model and Message)Render View (from Model)Display UI

ELMアーキテクチャを ratatui に適用する

TEA の原則に従うには、通常、次のことを行うことが含まれます。

  1. モデル2を定義します
  2. 更新の取り扱い
  3. ビューのレンダリング

1.モデルを定義する

ratatui では、通常、 struct を使用してモデルを表します。

struct Model {
//... your application's data goes here
}

カウンターアプリの場合、私たちのモデルは次のようになるかもしれません:

#[derive(Debug, Default)]
struct Model {
counter: i32,
running_state: RunningState,
}
#[derive(Debug, Default, PartialEq, Eq)]
enum RunningState {
#[default]
Running,
Done,
}

2。更新の処理

TEA の更新は、ユーザー入力などのイベントによってトリガーされるアクションです。核となるアイデアは、これらのアクションまたはイベントのそれぞれをメッセージにマッピングすることです。これは、メッセージを追跡するために列挙を作成することで実現できます。受信したメッセージに基づいて、モデルの現在の状態を使用して次の状態を決定します。

** Message enumの定義**

enum Message {
//... various inputs or actions that your app cares about
// e.g., ButtonPressed, TextEntered, etc.
}

カウンターアプリの場合、 Message enumは次のようになる場合があります。

#[derive(PartialEq)]
enum Message {
Increment,
Decrement,
Reset,
Quit,
}

** update() 機能**

更新機能は、このプロセスの中心にあります。現在のモデルとメッセージを受け取り、そのメッセージに応じてモデルをどのように変更するかを決定します。

TEA の重要な特徴は不変性です。したがって、更新機能はモデルの直接的な突然変異を避ける必要があります。代わりに、望ましい変化を反映したモデルの新しいインスタンスを作成するはずです。

fn update(model: &Model, msg: Message) -> Model {
match msg {
// Match each possible message and decide how the model should change
// Return a new model reflecting those changes
}
}

TEA では、データ (モデル) とそれを変えるロジック (更新) との明確な分離を維持することが重要です。この不変性の原則は、予測可能性を保証し、アプリケーションを推論しやすくします。

TEA では、 update() 関数は、 Message に基づいてモデルを変更するだけでなく、別の Message を返すこともできます。このデザインは、メッセージをチェーンしたり、更新して別の更新にリードしたりする場合に特に役立ちます。

たとえば、これは update() 関数がカウンターアプリのように見えるかもしれないものです。

fn update(model: &mut Model, msg: Message) -> Option<Message> {
match msg {
Message::Increment => {
model.counter += 1;
if model.counter > 50 {
return Some(Message::Reset);
}
}
Message::Decrement => {
model.counter -= 1;
if model.counter < -50 {
return Some(Message::Reset);
}
}
Message::Reset => model.counter = 0,
Message::Quit => {
// You can handle cleanup and exit here
model.running_state = RunningState::Done;
}
};
None
}

この設計の選択は、 main ループが返されたメッセージを処理する必要があることを意味することを忘れないでください。

update() 関数から Message を返すことにより、開発者はコードについて「有限状態マシン」と推論できます。有限状態マシンは、定義された状態と移行で動作します。ここでは、初期状態とイベント (この場合は Message ) がその後の状態につながります。このカスケードアプローチにより、一連の相互接続されたイベントを処理した後、システムが一貫した予測可能な状態にとどまることが保証されます。

上からのカウンターの例の状態遷移図は次のとおりです。

Running
Increment
if counter > 50 Reset
if counter <= 50
Decrement
if counter >= -50
if counter < -50 Reset
Quit
Counting
counter = 0
counter += 1
counter -= 1
Done

TEAは有限の状態マシンの用語を使用していないか、そのパラダイムを厳密に施行していませんが、アプリケーションの状態を状態マシンとして考えることで、開発者が複雑な状態の移行をより小さく、より管理しやすいステップに分解できるようにすることができます。これにより、アプリケーションのロジックをより明確に設計し、コードの保守性を向上させることができます。

3。ビューをレンダリングする

ELMアーキテクチャのビュー関数は、現在のモデルを取得し、ユーザーの視覚的表現を作成することを担当しています。Ratatuiの場合、モデルをターミナルUI要素に変換します。ビュー関数が純粋な関数のままであることが不可欠です。モ​​デルの特定の状態については、常に同じUI表現を生成する必要があります。

fn view(model: &Model) {
//... use `ratatui` functions to draw your UI based on the model's state
}

モデルが更新されるたびに、ビュー関数は、ターミナルUIでそれらの変更を正確に反映できる必要があります。

シンプルなカウンターアプリのビューは次のようになります。

fn view(model: &mut Model, frame: &mut Frame) {
frame.render_widget(
Paragraph::new(format!("Counter: {}", model.counter)),
frame.area(),
);
}

TEA では、ビュー機能が副作用がないことを確認することが期待されています。 view() 関数は、グローバル状態を変更したり、他のアクションを実行したりしてはなりません。その唯一の仕事は、モデルを視覚的表現にマッピングすることです。

モデルの特定の状態では、ビュー関数は常に同じ視覚出力を生成する必要があります。この予測可能性により、TUIアプリケーションが推論やデバッグが容易になります。

ratatui には、レンダリング中に状態に可変参照を必要とする [StatefulWidgets](https://docs.rs/ratatui/latest/ratatui/widgets/trait .StatefulWidget.html) があります。

このため、 view 不変性の原則を控えることを選択できます。たとえば、 List のレンダリングに興味がある場合、 view 関数は次のようになる場合があります。

fn view(model: &mut Model, f: &mut Frame) {
let items = model.items.items.iter().map(|element| ListItem::new(element)).collect();
f.render_stateful_widget(List::new(items), f.area(), &mut model.items.state);
}
fn main() {
loop {
...
terminal.draw(|f| view(&mut model, f) )?;
...
}
}

view() 関数で Frame にアクセスできるもう 1 つの利点は、カーソル位置の設定にアクセスできることです。これは、テキスト フィールドを表示する場合に便利です。たとえば、tui-input を使用して入力フィールドを描画したい場合は、次のように見える view がある場合があります。

fn view(model: &mut Model, f: &mut Frame) {
let area = f.area();
let input = Paragraph::new(app.input.value());
f.render_widget(input, area);
if model.mode == Mode::Insert {
f.set_cursor(
(area.x + 1 + self.input.cursor() as u16).min(area.x + area.width - 2),
area.y + 1
)
}
}

それをすべてまとめる

あなたがそれをすべてまとめると、あなたのメインアプリケーションループは次のように見えるかもしれません:

  • ユーザーの入力を聞いてください。- Message に入力をマップします - そのメッセージを更新関数に渡します。- ビュー関数でUIを描画します。

このサイクルは繰り返され、TUIが常にユーザーのインタラクションで最新の状態になるようにします。

実例として、茶を使用してリファクタリングされた Counter App を以下に示します。

以前との顕著な違いは、アプリの状態をキャプチャする Model structと、アプリが実行できるさまざまなアクションをキャプチャする Message enumがあることです。

use std::time::Duration;
use ratatui::{
crossterm::event::{self, Event, KeyCode},
widgets::Paragraph,
Frame,
};
#[derive(Debug, Default)]
struct Model {
counter: i32,
running_state: RunningState,
}
#[derive(Debug, Default, PartialEq, Eq)]
enum RunningState {
#[default]
Running,
Done,
}
#[derive(PartialEq)]
enum Message {
Increment,
Decrement,
Reset,
Quit,
}
fn main() -> color_eyre::Result<()> {
tui::install_panic_hook();
let mut terminal = tui::init_terminal()?;
let mut model = Model::default();
while model.running_state != RunningState::Done {
// Render the current view
terminal.draw(|f| view(&mut model, f))?;
// Handle events and map to a Message
let mut current_msg = handle_event(&model)?;
// Process updates as long as they return a non-None message
while current_msg.is_some() {
current_msg = update(&mut model, current_msg.unwrap());
}
}
tui::restore_terminal()?;
Ok(())
}
fn view(model: &mut Model, frame: &mut Frame) {
frame.render_widget(
Paragraph::new(format!("Counter: {}", model.counter)),
frame.area(),
);
}
/// Convert Event to Message
///
/// We don't need to pass in a `model` to this function in this example
/// but you might need it as your project evolves
fn handle_event(_: &Model) -> color_eyre::Result<Option<Message>> {
if event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? {
if key.kind == event::KeyEventKind::Press {
return Ok(handle_key(key));
}
}
}
Ok(None)
}
fn handle_key(key: event::KeyEvent) -> Option<Message> {
match key.code {
KeyCode::Char('j') => Some(Message::Increment),
KeyCode::Char('k') => Some(Message::Decrement),
KeyCode::Char('q') => Some(Message::Quit),
_ => None,
}
}
fn update(model: &mut Model, msg: Message) -> Option<Message> {
match msg {
Message::Increment => {
model.counter += 1;
if model.counter > 50 {
return Some(Message::Reset);
}
}
Message::Decrement => {
model.counter -= 1;
if model.counter < -50 {
return Some(Message::Reset);
}
}
Message::Reset => model.counter = 0,
Message::Quit => {
// You can handle cleanup and exit here
model.running_state = RunningState::Done;
}
};
None
}
mod tui {
use ratatui::{
backend::{Backend, CrosstermBackend},
crossterm::{
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
ExecutableCommand,
},
Terminal,
};
use std::{io::stdout, panic};
pub fn init_terminal() -> color_eyre::Result<Terminal<impl Backend>> {
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
Ok(terminal)
}
pub fn restore_terminal() -> color_eyre::Result<()> {
stdout().execute(LeaveAlternateScreen)?;
disable_raw_mode()?;
Ok(())
}
pub fn install_panic_hook() {
let original_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic_info| {
stdout().execute(LeaveAlternateScreen).unwrap();
disable_raw_mode().unwrap();
original_hook(panic_info);
}));
}
}