App.rs
最後に、すべてのピースをまとめると、 Run
structを取得する準備ができています。私たちが行う前に、私たちはTUIのプロセスについて議論する必要があります。
ほとんどのTUIは、単一のプロセス、単一のスレッドアプリケーションです。
アプリケーションがこのように構成されている場合、TUIは各ステップでブロックしています。
- イベントを待機します。
- 250 ミリ秒以内にキーまたはマウス イベントがない場合は、
Tick
を送信します。
event
またはaction
に基づいてアプリの状態を更新します。ratatui
を使用してアプリの状態をターミナルにdraw
します。
これは小規模なアプリケーションでは問題なく機能し、最初はこれを使用することをお勧めします。 ほとんどの TUI では、このプロセス方法論から卒業する必要はありません。
通常、draw
と get_events
は十分に高速なので、問題にはなりません。ただし、状態の更新中に計算負荷の高いタスクや I/O 集中型のタスク (データベースの読み取り、計算、Web リクエストの作成など) を実行する必要がある場合、実行中にアプリが「ハング」する可能性があります。
ユーザーがリストをスクロールするために j
を押すとします。ユーザーが j
を押すたびに、Web をチェックしてリストに追加する項目を探します。
ユーザーが j
を押したままにするとどうなるでしょうか。その場合の TUI アプリケーションの動作は、ユーザーが決めることができます。
リストの新しい要素をダウンロードしている間はアプリがハングし、アプリがハングしている間のすべてのキー押下は、ダウンロードが完了したら「即座に」受信され、処理されるようにすることがアプリの望ましい動作であると判断できます。
または、すべてのキーボード イベントを flush
してバッファリングしないようにし、次のようなものを実装することもできます。
let mut app = App::new();loop { // ... let before_draw = Instant::now(); t.terminal.draw(|f| self.render(f))?; // If drawing to the terminal is slow, flush all keyboard events so they're not buffered. if before_draw.elapsed() > Duration::from_millis(20) { while let Ok(_) = events.try_next() {} } // ...}
あるいは、アプリをバックグラウンドで更新し、アプリが新しい要素をダウンロードしている間にユーザーが既存のリストをスクロールできるようにしたいと決めるかもしれません。
私の経験では、ここでのトレードオフは通常、開発者の複雑さとユーザーの人間工学です。
複雑さを気にせず、計算量の多いタスクや I/O を集中的に行うタスクをバックグラウンドで実行することに関心があるとします。この例では、5
秒間スリープした後にカウンターの増分をトリガーするとします。
つまり、5 秒間スリープする「タスク」を開始し、次にディスパッチする別の Action
を送信する必要があります。
これで、update()
メソッドは次のようになります。
fn update(&mut self, action: Action) -> Option<Action> { match action { Action::Tick => self.tick(), Action::ScheduleIncrement => self.schedule_increment(1), Action::ScheduleDecrement => self.schedule_decrement(1), Action::Increment(i) => self.increment(i), Action::Decrement(i) => self.decrement(i), _ => (), } None }
schedule_increment()
および schedule_decrement()
両方が短命の tokio
タスクを作ります。
pub fn schedule_increment(&mut self, i: i64) { let tx = self.action_tx.clone().unwrap(); tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(5)).await; tx.send(Action::Increment(i)).unwrap(); }); }
pub fn schedule_decrement(&mut self, i: i64) { let tx = self.action_tx.clone().unwrap(); tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(5)).await; tx.send(Action::Decrement(i)).unwrap(); }); }
pub fn increment(&mut self, i: i64) { self.counter += i; }
pub fn decrement(&mut self, i: i64) { self.counter -= i; }
これを行うには、 App
structで action_tx
を設定したいと考えています。
#[derive(Default)]struct App { counter: i64, should_quit: bool, action_tx: Option<UnboundedSender<Action>>}
これが私たちがやりたいことです:
pub async fn run(&mut self) -> Result<()> { let (action_tx, mut action_rx) = mpsc::unbounded_channel(); let t = Tui::new(); t.enter();
tokio::spawn(async move { let mut event = EventHandler::new(250); loop { let event = event.next().await; let action = self.handle_events(event); // ERROR: self is moved to this tokio task action_tx.send(action); } })
loop { if let Some(action) = action_rx.recv().await { self.update(action); } t.terminal.draw(|f| self.render(f))?; if self.should_quit { break } } t.exit(); Ok(()) }
ただし、self
、つまり App
を event -> action
マッピング、つまり self.handle_events()
に移動して、後で self.update()
に使用することはできないため、これはうまくいきません。
これを解決する 1 つの方法は、Arc<Mutex<App>
インスタンスを event -> action
マッピング ループに渡すことです。
このループでは、lock()
を使用してオブジェクトへの参照を取得し、obj.handle_events()
を呼び出します。
メイン ループでも同じ lock()
機能を使用して、obj.update()
を呼び出す必要があります。
pub struct App { pub component: Arc<Mutex<App>>, pub should_quit: bool,}
impl App { pub async fn run(&mut self) -> Result<()> { let (action_tx, mut action_rx) = mpsc::unbounded_channel();
let tui = Tui::new(); tui.enter();
tokio::spawn(async move { let component = self.component.clone(); let mut event = EventHandler::new(250); loop { let event = event.next().await; let action = component.lock().await.handle_events(event); action_tx.send(action); } })
loop { if let Some(action) = action_rx.recv().await { match action { Action::Render => { let c = self.component.lock().await; t.terminal.draw(|f| c.render(f))?; }; Action::Quit => self.should_quit = true, _ => self.component.lock().await.update(action), } } self.should_quit { break; } }
tui.exit(); Ok(()) }}
これで、 App
は、ビジネスロジックに依存しない一般的なボイラープレートです。アプリケーションを前進させるだけで、つまり適切な関数を呼び出す責任があります。
さらに一歩進んで、レンダリングループを独自の tokio
タスクにすることができます。
pub struct App { pub component: Arc<Mutex<Home>>, pub should_quit: bool,}
impl App { pub async fn run(&mut self) -> Result<()> { let (render_tx, mut render_rx) = mpsc::unbounded_channel();
tokio::spawn(async move { let component = self.component.clone(); let tui = Tui::new(); tui.enter(); loop { if let Some(_) = render_rx.recv() { let c = self.component.lock().await; tui.terminal.draw(|f| c.render(f))?; } } tui.exit() })
let (action_tx, mut action_rx) = mpsc::unbounded_channel();
tokio::spawn(async move { let component = self.component.clone(); let mut event = EventHandler::new(250); loop { let event = event.next().await; let action = component.lock().await.handle_events(event); action_tx.send(action); } })
loop { if let Some(action) = action_rx.recv().await { match action { Action::Render => { render_tx.send(()); }; Action::Quit => self.should_quit = true, _ => self.component.lock().await.update(action), } } self.should_quit { break; } }
Ok(()) }}
今、私たちの最終的なアーキテクチャは次のようになります:
必要に応じて、アプリケーション内で “thread” または “task” が実行する処理を変更できます。
このパターンが価値があるかどうかは、あなた次第です。このテンプレートでは、物事を少しシンプルにします。すべての Event
を処理するために、1 つのスレッドまたはタスクだけを使用します。
すべてのビジネスロジックは、 App
構造体に配置されます。
#[derive(Default)]struct App { counter: i64,}
impl App { fn handle_events(&mut self, event: Option<Event>) -> Action { match event { Some(Event::Quit) => Action::Quit, Some(Event::AppTick) => Action::Tick, Some(Event::Render) => Action::Render, Some(Event::Key(key_event)) => { if let Some(key) = event { match key.code { KeyCode::Char('j') => Action::Increment, KeyCode::Char('k') => Action::Decrement _ => {} } } }, Some(_) => Action::Noop, None => Action::Noop, } }
fn update(&mut self, action: Action) { match action { Action::Tick => self.tick(), Action::Increment => self.increment(), Action::Decrement => self.decrement(), }
fn increment(&mut self) { self.counter += 1; }
fn decrement(&mut self) { self.counter -= 1; }
fn render(&mut self, f: &mut Frame<'_>) { f.render_widget( Paragraph::new(format!( "Press j or k to increment or decrement.
Counter: {}", self.counter )) ) }}
それで、 App
はもう少しシンプルになります:
pub struct App { pub tick_rate: (u64, u64), pub component: Home, pub should_quit: bool,}
impl Component { pub fn new(tick_rate: (u64, u64)) -> Result<Self> { let component = Home::new(); Ok(Self { tick_rate, component, should_quit: false, should_suspend: false }) }
pub async fn run(&mut self) -> Result<()> { let (action_tx, mut action_rx) = mpsc::unbounded_channel();
let mut tui = Tui::new(); tui.enter()
loop { if let Some(e) = tui.next().await { if let Some(action) = self.component.handle_events(Some(e.clone())) { action_tx.send(action)?; } }
while let Ok(action) = action_rx.try_recv().await { match action { Action::Render => tui.draw(|f| self.component.render(f, f.size()))?, Action::Quit => self.should_quit = true, _ => self.component.update(action), } } if self.should_quit { tui.stop()?; break; } } tui.exit() Ok(()) }}
Component
は現在、1 つのこと (カウンターの増分と減分) だけを実行します。しかし、より複雑なことを実行し、Component
を興味深い方法で組み合わせる必要がある場合があります。たとえば、テキスト入力フィールドを追加したり、TUI アプリケーションから条件に応じてログを表示したりする必要がある場合があります。
次のセクションでは、Home
と呼ばれる 1 つのルート コンポーネントを使用して、アプリをさまざまなコンポーネントに分割する方法について説明します。また、Component
trait を導入して、TUI 固有のコードがどこで終了し、アプリのビジネス ロジックがどこから始まるかを理解しやすくなります。