Skip to content

App.rs

最後に、すべてのピースをまとめると、 Run structを取得する準備ができています。私たちが行う前に、私たちはTUIのプロセスについて議論する必要があります。

ほとんどのTUIは、単一のプロセス、単一のスレッドアプリケーションです。

Get Key Event Update State Draw

アプリケーションがこのように構成されている場合、TUIは各ステップでブロックしています。

  1. イベントを待機します。
  • 250 ミリ秒以内にキーまたはマウス イベントがない場合は、Tick を送信します。
  1. event または action に基づいてアプリの状態を更新します。
  2. ratatui を使用してアプリの状態をターミナルに draw します。

これは小規模なアプリケーションでは問題なく機能し、最初はこれを使用することをお勧めします。 ほとんどの TUI では、このプロセス方法論から卒業する必要はありません。

通常、d​​rawget_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、つまり Appevent -> 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(())
}
}

今、私たちの最終的なアーキテクチャは次のようになります:

Render Thread Event Thread Main Thread Get Key Event Map Event to Action Send Action on action tx Recv Action Recv on render rx Dispatch Action Render Component Update Component

必要に応じて、アプリケーション内で “thread” または “task” が実行する処理を変更できます。

このパターンが価値があるかどうかは、あなた次第です。このテンプレートでは、物事を少しシンプルにします。すべての Event を処理するために、1 つのスレッドまたはタスクだけを使用します。

Event Thread Main Thread Get Event Send Event on event tx Recv Event and Map to Action Update Component

すべてのビジネスロジックは、 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 固有のコードがどこで終了し、アプリのビジネス ロジックがどこから始まるかを理解しやすくなります。