BlueskyのTUI Client Appを作り始めてしまった

memo.sugyan.com

の続き…?

I've published `tuisky`, a TUI Client for Bluesky, as v0.0.1. (It's still a work in progress.) Were there already other clients available for use in the terminal? #atdev #bluesky-client #tui crates.io/crates/tuisky

[image or embed]

— すぎゃーん (@sugyan.com) Jul 1, 2024 at 12:12 AM

経緯

TauriでのDesktop Clientはある程度動くところまで出来たが、問題点も発覚してきた。

大きくはmulti columnへの対応問題。React Routerで画面管理していたが、複数の画面を管理することになると厄介そう。 そもそもの問題点として、フロントエンドとバックエンドで画面状態やデータをどう持つか、が上手く役割分担できていなくて中途半端で複雑になってしまっていた。

なので、まずはバックエンド側での状態保持やデータ更新の仕組みを整理して作り直してみよう、と思い立った。

だが、その動作確認にいちいちTauriビルドしてフロントエンドで表示して…というのが面倒だなと思い。 一旦Tauriから離れてターミナル上だけでどうにかしたくなった。

しかしunit testだけで完結するのも難しく、動作確認のための何らかの表示UIは欲しい。

そこで TUI(terminal user interfaces)。

最近のTUIフレームワークなど全然知らなかったが、それなりに高機能なものも作れそうで面白そうだったので、やってみることにした。

RatatuiによるTUI開発

選択したのは Ratatui。

ratatui.rs

詳しい経緯は知らないが、元々は tui-rs という個人で作られた人気ライブラリがあったが、メンテナンスできなくなってきた結果コミュニティでforkして引き続き開発されている、というもののようだ。いい話(?)。

有名どころでは bottom などで使われているっぽい。

Ratatuiの主な特徴としては、複数のターミナルライブラリをbackendとして選択でき、UI widgetを配置して描画するための枠組みを提供している(そして基本的なwidgetがbuilt-inされている)、というところだろうか。

ターミナルのbackendは crossterm, termion, そして termwiz の3つから選択できるようだが、基本的にはcrosstermを使っておくのが無難そうだ。

Comparison of Backends | Ratatui

Asynchronous Event Handling

Ratatuiで簡単なTUIアプリケーションを作る場合、以下のようなループ処理を書くだけで実装できる。

    loop {
        terminal.draw(|frame| {
            // draw the UI
        })?;
        match event::read()? {
            Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
                // handle key events
            }
            _ => {}
        };
    }

メインのループの中で描画処理があり、キー操作などのEventを待ち受けて、そのEventによって状態変更するなどして描画内容を更新する、という基本的な流れ。

だが、このような作りだとEventを受け取るまでblockingして待ち続けるしかないし、バックグラウンドで処理されたものをUIに反映させるのも難しい。

より複雑なアプリケーションを作る際には、非同期Event処理を導入する。

Full Async Events | Ratatui

crossterm には event-stream featureがあり、これを有効にすることで非同期でEventを受け取ることができるようになる。

use futures::{FutureExt, StreamExt};
use tokio::{sync::mpsc, task::JoinHandle};

fn async_events() {
    let task = tokio::spawn(async move {
        let mut reader = crossterm::event::EventStream::new();
        let mut interval = tokio::time::interval(std::time::Duration::from_millis(250));
        loop {
            let delay = interval.tick();
            let crossterm_event = reader.next().fuse();
            tokio::select! {
                maybe_event = crossterm_event => {
                    match maybe_event {
                        Some(Ok(evt)) => {
                            ...
                        }
                        _ => { ... }
                    }
                },
                _ = delay => {
                    ...
                },
            }
        }
    });
    ...
}

このようにして、 crossterm からのEventを非同期で待ち受けることができるし、定期的なEventなども tokio::select! によって非同期で受け付けられるようになる。あとはここから tokio::sync::mpsc::unbounded_channel() などを使ってメインのUIスレッドに通信することでそれらを処理できるようになる。 キー操作など受け付けつつ定期的に描画するEventを発火させることで安定した画面更新もできるし、逆に特定のEventが発生しない限り無駄な描画をしない、といった調整も可能だ。

Components Architecture

様々なUI widgetを使用して複雑なアプリケーションを作る場合の設計パターンとして、幾つかのアーキテクチャが提案されている。その中の一つとして、「Component Architecture」というものがある。

Component Architecture | Ratatui

Component というTraitを定義し、これが

  • 初期化
  • 設定やAction handlerの登録
  • Event handling
  • Actionを受けての状態更新
  • Rendering

などのメソッドを持つ。メインのApp内でこれを実装した componentsVec<Box<dyn Component>> で持つようにして、メインループ内で「Eventを渡しActionを受ける、そのActionを処理して状態を更新、そして描画」という流れをそれぞれのComponentに対し透過的に行うようにする設計だ。

impl App {
  pub async fn run(&mut self) -> Result<()> {
    ...
    // componentsの初期化など 事前準備
    for component in self.components.iter_mut() {
      component.register_action_handler(action_tx.clone())?;
    }
    for component in self.components.iter_mut() {
      component.register_config_handler(self.config.clone())?;
    }
    for component in self.components.iter_mut() {
      component.init(tui.size()?)?;
    }
    // メインループ
    loop {
      // Event処理 (Actionへの変換)
      if let Some(e) = tui.next().await {
        ... // メインのEvent処理
        for component in self.components.iter_mut() {
          if let Some(action) = component.handle_events(Some(e.clone()))? {
            action_tx.send(action)?;
          }
        }
      }
      // Action処理
      while let Ok(action) = action_rx.try_recv() {
        match action {
          ...
          Action::Render => {
            // 各Componentの描画
            tui.draw(|f| {
              for component in self.components.iter_mut() {
                component.draw(f, f.size());
              }
            })?;
          }
        }
        for component in self.components.iter_mut() {
          if let Some(action) = component.update(action.clone())? {
            action_tx.send(action)?
          };
        }
      }
      if self.should_quit {
        break;
      }
    }
    Ok(())
  }
}

それぞれのComponentが独立したAppとして動作する感じになり、新しい要素を追加する場合も Component Traitを実装して components に追加するだけで良い。

自作Clientでは、これを参考にアレンジして設計してみた。

自作Client用の設計

機能

まずは盛り込みたかった機能について。

完全に分離された Multi Column

まずやりたかったのが、Multi Columnでの分割表示。ログインセッションなども完全に分離し、互いに干渉しない。あるColumnに対して操作をする際はそれ以外のColumnは表示のみで同時に操作することは無い。

履歴を保持しての画面遷移

Feedの一覧からPost詳細を見て、ThreadのReplyなどを辿り、またPost詳細を見てそのユーザのAuthorFeedを見て…、そしてまた最初のFeedまで戻って、と ブラウザで遷移して履歴から戻るような操作を各Columnで

Feedなどの自動更新

自分で操作しなくても定期的に新しいFeedを取得して自動で更新してくれる機能。

FocusとColumnを管理する MainComponent

まず適当に画面を縦に分割して複数のColumnを作るが、前述のComponents Architectureでそのまま各Columnを実装すると、どのColumnもすべて同じEventを受けて同じActionを処理してしまうことになる。

なので、このMainComponentでそこを整理して各ColumnへのEventやActionの伝達を行うようにした。

struct State {
    selected: Option<usize>,
}

pub struct MainComponent {
    columns: Vec<ColumnComponent>,
    state: State,
}


impl Component for MainComponent {
    ...

    fn handle_key_events(&mut self, key: KeyEvent) -> Result<Option<Action>> {
        // 選択中のColumnに対してのみEventを渡す
        if let Some(selected) = self.state.selected {
            self.columns[selected].handle_key_events(key)
        } else {
            Ok(None)
        }
    }
    fn update(&mut self, action: Action) -> Result<Option<Action>> {
        match action {
            Action::NextFocus => {
                self.state.selected = ...;
                return Ok(Some(Action::Render));
            }
            ...
            _ => {
                for column in self.columns.iter_mut() {
                    if let Some(action) = column.update(action.clone())? {
                        return Ok(Some(action));
                    }
                }
            }
        }
    }
}

Eventは選択中のColumnだけに渡すが、Actionは基本的にすべてのColumnに対して伝播させる。操作はしていないが非同期的にデータが更新される、といった場合に通知する必要があるからだ。 そういったもの以外は、受け取るColumn側でIDを保持し、Action発行時に自分のIDを載せるようにした。そして自分のIDと異なるIDのActionは無視するようにしている。

履歴を保持する ColumnComponent

各Columnでは、 ViewComponent Traitを実装したものを Vec<Box<dyn ViewComponent>> で保持し、その末尾要素に対してのみEvent/Actionを渡し、Renderする。 画面遷移するごとに新しいViewComponentを push() し、戻るときには pop() する。 ViewComponent Traitは概ね Component と同じだが、activate() deactivate() というメソッドを追加している。末尾のViewComponentのみActiveな状態として機能し、それ以外は非Activeな状態としてバックグラウンドでのデータ更新などを停止する。

    pub(crate) fn transition(&mut self, transition: &Transition) -> Result<Option<Action>> {
        match transition {
            Transition::Push(view) => {
                if let Some(current) = self.views.last_mut() {
                    current.deactivate()?;
                }
                let mut next = self.view(view)?;
                next.as_mut().activate()?;
                self.views.push(next);
            }
            Transition::Pop => {
                if let Some(mut view) = self.views.pop() {
                    view.deactivate()?;
                }
                if let Some(current) = self.views.last_mut() {
                    current.activate()?;
                }
            }
            ...
        }
        Ok(Some(Action::Render))
    }

設計まとめ

Component Architectureを倣って各Componentsで独立してEvent処理やRenderをできるようにしつつ、

  • Mainでは分割されたColumnsのFocus中のものだけにEventを渡し、またActionの伝播を行う
  • 各Columnでは履歴で最新のものだけをActiveなものとしてEvent処理/描画などを行う

という制御をいれることでMulti Columnで画面遷移を可能にした。

IndexMapを使ったFeed管理

ちょっとだけアルゴリズムとデータ構造的な話を。

Following feedでのtimelineに限った話ではあるけれど、ここではFeedの内容としてfollowingからのPostが流れてくる。 基本的には同じものが流れることは無いが、一つだけ例外があって、followingユーザがRepostしたものだけは同じものが複数回出現し得る。この場合は .post の内容は同一だが .reason が異なるものになる。

で、公式と同様の挙動を実現しようと思うと、表示すべきfeedの配列は

  • 基本的には出現した順に表示される
  • 一意なID(cidなど)で区別され、同一のものは出現しない
  • 既に存在するが.reasonだけ異なるものが出現した場合は、先頭に挿入(移動)される

という形であることが望まれる。 つまり A,B,C,D,B,E という順で出現した場合は、 A,B,C,D の後に B の再出現により A,C,D,B という並びになり、最終的な出力は A,C,D,B,E という表示になる。

基本的には Vec で管理して、既に存在するか否かを毎回 .contains() で検索するか または HashSet でID管理しておけば判定できる。しかし末尾に移動することになると 一旦 .remove() して .pop() する必要があり、そもそも何番目にそれが存在するのかを調べる必要も出てくる。 HashMap でindexを管理するようにすれば良さそうだが 移動によってそのindexも変化するので厳しそうだ。

別に何百万とか何億とかのデータを扱うわけでもないのでそんなにパフォーマンスを気にせず O(N)で処理しても困ることは無さそうではあるが、できるなら効率的に処理したい。

…ってことでどうするのが良いかChatGPTに相談したところ、「Pythonなら OrderedDict を使うことで効率的に処理できます」ということだった。Rustで同等な機能を持つものとして IndexMap があるようだったので、それを使うことにした。

    let mut feed_map: IndexMap<Cid, FeedViewPost> = Default::default();
    for post in feed {
        if let Some(entry) = feed_map.get_mut(&post.post.cid) {
            // Is the feed view a new repost?
            if ... {
                // Remove the old entry
                feed_map.swap_remove(&post.post.cid);
            } else {
                continue;
            }
        }
        feed_map.insert(post.post.cid.clone(), post.clone());
    }

ほぼ HashMap と同様の使い方で、こうして更新されたものから feed_map.values().rev().cloned().collect::<Vec<_>>() といった感じで出現順に並べられたFeedの配列を取得できる。

これなら重複時の移動も O(1)で実現できている、はず?

まとめ

  • Ratatuiを使ってTUIアプリケーションを作ってみた
    • TUIアプリちゃんと作ったことなかったので色々な知見を得られて楽しい
    • ターミナルで色々動かせるとテンション上がる
  • Clientのバックエンド処理を整理してブラッシュアップできそう
  • Blueskyクライアント欲しいだけなのにどんどん寄り道してる