Zenn
🐀

Ratatui で階層メニューをつくってみる

2025/03/23に公開
4

背景と概要

日頃から、業務で使うちょっとしたツールを Rust 製に置き換えられないか、常に機を伺っております。
とはいえ、Windows 上で動く GUI アプリを Rust 製に置き換えるのは (.NET エコシステムと比べると) まだまだ難しいような気がします。
しかし、そこまでリッチなインタラクションが求められていなければ、TUI (Text-based User Interface) という選択肢はアリなのでは?と思い、Ratatui というクレートについて調べ始めた次第です。

手始めに Ratatui Template から始めて、階層メニューを実装してみました。

Ratatui について

Ratatui は 2023 年に tui-rs からフォークされたクレートらしいです。
ターミナル上にグラフィカルな UI を表示するためのものですね。

どうでもいいですが、料理ネズミがトレードマークなのはピクサーの「レミーのおいしいレストラン」の影響かと思われます。
その原題はズバリ "Ratatouille" で、そもそもネズミ ("Rat") とかけてあるんだってさ。

Ratatui Template について

Ratatui にはプロジェクトテンプレートが用意されています。

cargo install cargo-generate
cargo generate ratatui/templates

今お使いの cargo で、この2行を実行するだけ。
よく使われるクレートが初めから追加されたアプリケーションとしての骨子がある程度整ったプロジェクトが用意されるので、実装したい機能に集中することができそうです。
といっても、生成されたプロジェクトをビルドしても "hello world" と表示されるだけですので、どこから手をつければいいのか正直かなり戸惑いました。

今回つくったもの

矢印キーで階層メニューを操作して、五十音から名前を選択するインターフェースをつくりました。
今のところ実用性は皆無です。

ratatui hierarchy menu
こんにちは!

実装する

それでは、Ratatui Template から始めて、上掲の画像のような階層メニューを実装していきます。

名前リストの実装

いきなりですが、これはあまり Ratatui そのものとは関係ない部分です。
メニュー階層のより抽象的なデータを表現するために構造体をいくつか定義しています。
実装は新規ファイル "src/models/name_list.rs" の中で行いました。
"子音行 → 名前の先頭文字 → 名前リスト" という階層構造が、入れ子になった Vec で表現されています。

なお、人名リストは Copilot くんが生成してくれたものをそのまま採用しました。
大目に見てあげてください。

"src/models/name_list.rs" ソースコード全文
src/models/name_list.rs
/// ひらがなの人名リスト
pub struct Namelist {
  pub head_char: String,
  pub items: Vec<NameListItem>,
}

/// ひらがなの人名リストのアイテム
pub struct NameListItem {
  pub name: String,
  pub selected: bool,
}

/// 人名の頭文字(ひらがな一文字)のリスト
/// たとえば `"あ行"`なら `["あ", "い", "う", "え", "お"]` に対応する
pub struct NameHeadCharList {
  pub name_head_char_row: String,
  pub items: Vec<NameHeadCharListItem>,
}

/// 人名の頭文字(ひらがな一文字)のリストのアイテム
pub struct NameHeadCharListItem {
  pub name_head_char: String,
  pub selected: bool,
  pub names: Namelist,
}

/// 人名の頭文字(ひらがな一文字)を含む行のリスト
/// `"あ行", "か行", ...` を含む
pub struct NameHeadCharRowList {
  pub items: Vec<NameHeadCharRowListItem>,
}

/// 人名の頭文字(ひらがな一文字)を含む行のリストのアイテム
pub struct NameHeadCharRowListItem {
  pub name_head_char_row: String,
  pub selected: bool,
  pub chars: NameHeadCharList,
}

impl Namelist {
  fn new(head_char: &str, items: Vec<&str>) -> Self {
    Self {
      head_char: head_char.to_string(),
      items: items.into_iter().map(|name| NameListItem { name: name.to_string(), selected: false }).collect(),
    }
  }
}

impl NameHeadCharListItem {
  fn new(names: Namelist) -> Self {
    Self {
      name_head_char: names.head_char.clone(),
      selected: false,
      names,
    }
  }
}

impl NameHeadCharList {
  fn new(name_head_char_row: &str, items: Vec<NameHeadCharListItem>) -> Self {
    Self {
      name_head_char_row: name_head_char_row.to_string(),
      items,
    }
  }
}

impl NameHeadCharRowListItem {
  fn new(chars: NameHeadCharList) -> Self {
    Self {
      name_head_char_row: chars.name_head_char_row.clone(),
      selected: false,
      chars,
    }
  }
}

impl Default for NameHeadCharRowList {
  fn default() -> Self {
    let names_a = Namelist::new("あ", vec!["あいこ", "あきひろ", "あけみ", "あさみ", "あすか", "あみ", "あやこ", "あゆみ"]);
    let names_i = Namelist::new("い", vec!["いおり", "いくみ", "いさむ", "いちか", "いつき", "いなほ", "いまる", "いよ"]);
    let names_u = Namelist::new("う", vec!["うい", "うえの", "うきょう", "うさぎ", "うた", "うみ", "うらら"]);
    let names_e = Namelist::new("え", vec!["えいこ", "えつこ", "えみ", "えり", "えの", "えま"]);
    let names_o = Namelist::new("お", vec!["おうじ", "おさむ", "おと", "おの", "おはる", "おみ", "おり"]);
    let names_ka = Namelist::new("か", vec!["かえで", "かおる", "かずみ", "かつみ", "かなえ", "かのん", "かほ", "かりん"]);
    let names_ki = Namelist::new("き", vec!["きお", "きく", "きさき", "きずな", "きよし", "きらら"]);
    let names_ku = Namelist::new("く", vec!["くう", "くが", "くじら", "くに", "くみ", "くれは"]);
    let names_ke = Namelist::new("け", vec!["けいこ", "けつ", "けみ", "ける", "けん"]);
    let names_ko = Namelist::new("こ", vec!["こう", "こえ", "こがね", "こじろう", "こなつ", "こはる", "こまち"]);
    let names_sa = Namelist::new("さ", vec!["さえ", "さおり", "さき", "さくら", "さとし", "さなえ", "さやか"]);
    let names_shi = Namelist::new("し", vec!["しいな", "しおり", "しげる", "しずか", "しのぶ", "しほ", "しんじ"]);
    let names_su = Namelist::new("す", vec!["すい", "すえこ", "すぐる", "すず", "すみれ"]);
    let names_se = Namelist::new("せ", vec!["せいこ", "せいじ", "せつこ", "せな", "せん"]);
    let names_so = Namelist::new("そ", vec!["そう", "そえ", "そが", "そら", "そよ"]);
    let names_ta = Namelist::new("た", vec!["たいが", "たえ", "たかし", "たけし", "たつや", "たまき", "たろう"]);
    let names_chi = Namelist::new("ち", vec!["ちえ", "ちか", "ちさと", "ちづる", "ちなつ", "ちひろ", "ちよ"]);
    let names_tsu = Namelist::new("つ", vec!["つかさ", "つき", "つぐみ", "つとむ", "つばさ", "つゆ"]);
    let names_te = Namelist::new("て", vec!["ていこ", "てつや", "てる", "てん"]);
    let names_to = Namelist::new("と", vec!["とうこ", "とおる", "とき", "としお", "とみ", "ともこ"]);
    let names_na = Namelist::new("な", vec!["なお", "なぎさ", "なつき", "ななみ", "なほ", "なみ", "なり"]);
    let names_ni = Namelist::new("に", vec!["にいな", "にし", "にちか", "にの", "にほ"]);
    let names_nu = Namelist::new("ぬ", vec!["ぬい", "ぬか", "ぬま", "ぬり"]);
    let names_ne = Namelist::new("ね", vec!["ねいろ", "ねね", "ねの", "ねむ"]);
    let names_no = Namelist::new("の", vec!["のあ", "のい", "のえ", "のぞみ", "のり"]);
    let names_ha = Namelist::new("は", vec!["はく", "はな", "はるか", "はるみ", "はやと", "はる"]);
    let names_hi = Namelist::new("ひ", vec!["ひいろ", "ひかる", "ひさし", "ひとみ", "ひな", "ひろし"]);
    let names_fu = Namelist::new("ふ", vec!["ふうか", "ふじこ", "ふたば", "ふみ", "ふゆ"]);
    let names_he = Namelist::new("へ", vec!["へい", "へいじ", "へきる", "へん"]);
    let names_ho = Namelist::new("ほ", vec!["ほう", "ほし", "ほたる", "ほのか", "ほまれ"]);
    let names_ma = Namelist::new("ま", vec!["まい", "まこと", "まさし", "まちこ", "まどか", "まなみ"]);
    let names_mi = Namelist::new("み", vec!["みお", "みか", "みさき", "みちる", "みなみ", "みほ"]);
    let names_mu = Namelist::new("む", vec!["むぎ", "むさし", "むつみ", "むね"]);
    let names_me = Namelist::new("め", vec!["めい", "めぐみ", "めの", "めり"]);
    let names_mo = Namelist::new("も", vec!["もえ", "もも", "もり", "もとこ", "もな"]);
    let names_ya = Namelist::new("や", vec!["やえ", "やすこ", "やちよ", "やまと", "やよい"]);
    let names_yu = Namelist::new("ゆ", vec!["ゆい", "ゆう", "ゆかり", "ゆき", "ゆず", "ゆり"]);
    let names_yo = Namelist::new("よ", vec!["ようこ", "よしこ", "よしの", "よね", "より"]);
    let names_ra = Namelist::new("ら", vec!["らい", "らん", "らんこ", "らんま"]);
    let names_ri = Namelist::new("り", vec!["りえ", "りか", "りさ", "りつこ", "りな", "りょう"]);
    let names_ru = Namelist::new("る", vec!["るい", "るか", "るな", "るみ"]);
    let names_re = Namelist::new("れ", vec!["れい", "れいこ", "れな", "れん"]);
    let names_ro = Namelist::new("ろ", vec!["ろあ", "ろく", "ろみ", "ろむ"]);
    let names_wa = Namelist::new("わ", vec!["わか", "わこ", "わたる", "わら"]);

    let head_chars_a = NameHeadCharList::new("あ行", vec![
      NameHeadCharListItem::new(names_a),
      NameHeadCharListItem::new(names_i),
      NameHeadCharListItem::new(names_u),
      NameHeadCharListItem::new(names_e),
      NameHeadCharListItem::new(names_o),
    ]);
    let head_chars_k = NameHeadCharList::new("か行", vec![
      NameHeadCharListItem::new(names_ka),
      NameHeadCharListItem::new(names_ki),
      NameHeadCharListItem::new(names_ku),
      NameHeadCharListItem::new(names_ke),
      NameHeadCharListItem::new(names_ko),
    ]);
    let head_chars_s = NameHeadCharList::new("さ行", vec![
      NameHeadCharListItem::new(names_sa),
      NameHeadCharListItem::new(names_shi),
      NameHeadCharListItem::new(names_su),
      NameHeadCharListItem::new(names_se),
      NameHeadCharListItem::new(names_so),
    ]);
    let head_chars_t = NameHeadCharList::new("た行", vec![
      NameHeadCharListItem::new(names_ta),
      NameHeadCharListItem::new(names_chi),
      NameHeadCharListItem::new(names_tsu),
      NameHeadCharListItem::new(names_te),
      NameHeadCharListItem::new(names_to),
    ]);
    let head_chars_n = NameHeadCharList::new("な行", vec![
      NameHeadCharListItem::new(names_na),
      NameHeadCharListItem::new(names_ni),
      NameHeadCharListItem::new(names_nu),
      NameHeadCharListItem::new(names_ne),
      NameHeadCharListItem::new(names_no),
    ]);
    let head_chars_h = NameHeadCharList::new("は行", vec![
      NameHeadCharListItem::new(names_ha),
      NameHeadCharListItem::new(names_hi),
      NameHeadCharListItem::new(names_fu),
      NameHeadCharListItem::new(names_he),
      NameHeadCharListItem::new(names_ho),
    ]);
    let head_chars_m = NameHeadCharList::new("ま行", vec![
      NameHeadCharListItem::new(names_ma),
      NameHeadCharListItem::new(names_mi),
      NameHeadCharListItem::new(names_mu),
      NameHeadCharListItem::new(names_me),
      NameHeadCharListItem::new(names_mo),
    ]);
    let head_chars_y = NameHeadCharList::new("や行", vec![
      NameHeadCharListItem::new(names_ya),
      NameHeadCharListItem::new(names_yu),
      NameHeadCharListItem::new(names_yo),
    ]);
    let head_chars_r = NameHeadCharList::new("ら行", vec![
      NameHeadCharListItem::new(names_ra),
      NameHeadCharListItem::new(names_ri),
      NameHeadCharListItem::new(names_ru),
      NameHeadCharListItem::new(names_re),
      NameHeadCharListItem::new(names_ro),
    ]);
    let head_chars_w = NameHeadCharList::new("わ行", vec![
      NameHeadCharListItem::new(names_wa),
    ]);

    Self {
      items: vec![
        NameHeadCharRowListItem::new(head_chars_a),
        NameHeadCharRowListItem::new(head_chars_k),
        NameHeadCharRowListItem::new(head_chars_s),
        NameHeadCharRowListItem::new(head_chars_t),
        NameHeadCharRowListItem::new(head_chars_n),
        NameHeadCharRowListItem::new(head_chars_h),
        NameHeadCharRowListItem::new(head_chars_m),
        NameHeadCharRowListItem::new(head_chars_y),
        NameHeadCharRowListItem::new(head_chars_r),
        NameHeadCharRowListItem::new(head_chars_w),
      ],
    }
  }
}

Ratatui Template の攻略

モデルデータらしきものが準備できたので、これを TUI 上の表示と操作に対応付けていきます。

Template の改造は概ね以下のような流れになるんじゃないかと思われます。

  1. Action の追加
  2. キー入力の設定
  3. Action に応じた処理の実装
  4. 画面描画の実装

なお今回の実装で Template に含まれるファイルのうち主要な変更があったのは、"src/action.rs", ".config/config.json5", "src/components/home.rs" だけでした。
(ほかのファイルへの変更は mod 文の追加くらい)
なので、この Template をベースにしてより本格的なアプリケーションを作る場合にも、基本的には ActionComponent トレイトの追加や修正だけで事足りそうです。

Action の追加

Action は列挙型です。細かいことを置いておけば「キー入力 (Event) に応じて実行される処理の種類」くらいの理解で良いような気がします。
"action.rs" というファイルで定義されますが、enum Action が定義されているだけの非常に小さなファイルです。

action.rs
use serde::{Deserialize, Serialize};
use strum::Display;

#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)]
pub enum Action {
    Tick,
    Render,
    Resize(u16, u16),
    Suspend,
    Resume,
    Quit,
    ClearScreen,
    Error(String),
    Help,
    Previous, // <- 追加
    Next, // <- 追加
    Leave, // <- 追加
    Visit // <- 追加
}

上記の通り、新しく 4 つの Action を追加しました。これらを今度はキー入力に対応させます。

キー入力の設定

キー入力と Action との対応付け (keybindings) は "config.rs" 内の config::Config の中で行われていますが、コードファイルを編集する必要はなく、
外部ファイル ".config/config.json5" だけで設定を行うことが可能になっています。

以下のような行を追加して、矢印キーの入力に応じて先ほど追加した Action が要求されるようにしました。

.config/config.json5
{
  "keybindings": {
    "Home": {
      "<q>": "Quit", // Quit the application
      "<Ctrl-d>": "Quit", // Another way to quit
      "<Ctrl-c>": "Quit", // Yet another way to quit
      "<Ctrl-z>": "Suspend", // Suspend the application
      "<up>": "Previous", // <- 追加
      "<down>": "Next", // <- 追加
      "<left>": "Leave", // <- 追加
      "<right>": "Visit", // <- 追加
    },
  }
}

なお config::Config は JSON5 だけでなく、

  • config.json5
  • config.json
  • config.yaml
  • config.toml
  • config.ini

といった複数のフォーマットに対応しているようです。
あと設定ファイルを置くディレクトリは環境変数で指定することも可能なようです (未検証)。

Action に応じた処理の実装

Template に含まれる "src/components/home.rs" を編集して、追加した Action に応じた処理が実行されるようにします。
App で発行された Actiontokio を経由しつつ最終的に Component トレイトの update() 関数に渡されます。

後追いになりますが、今回追加定義した Action が要求する処理は次のようなものです。

  • Previous : ↑ キー入力に応じてリストの上の項目を選ぶ
  • Next : ↓ キー入力に応じてリストの下の項目を選ぶ
  • Leave : ← キー入力に応じてメニュー階層を一つ上る
  • Visit : → キー入力に応じてメニュー階層を一つ下る

ということで、struct Home には以下のメンバーを追加しました。

src/components/home.rs
#[derive(Default)]
pub struct Home {
    command_tx: Option<UnboundedSender<Action>>,
    config: Config,
    last_action: String, // <- 追加
    list: NameHeadCharRowList, // <- 追加
    hierarchy_selected: ListHierarchy, // <- 追加
    list_state_rows: ListState, // <- 追加
    list_state_chars: ListState, // <- 追加
    list_state_names: ListState, // <- 追加
}
  • last_action : デバッグ用。直前に受け取った Action を表示するために。
  • list : 名前の階層リスト。「子音行」のリストが一番上の階層に来ます。
  • hierarchy_selected : 現在操作しているメニュー階層を enum ListHierarchy でを示します。
  • list_state_rows :
  • list_state_chars :
  • list_state_names : 各 ratatui::widgets::ListStateratatui::widgets::List のステート管理を行います。

また enum ListHierarchy は以下のようなものです。

src/components/home.rs
#[derive(Default)]
enum ListHierarchy {
    #[default]
    /// どの階層も選択していない状態
    None, 
    /// 「子音行」を選択している状態
    NameHeadCharRows,
    /// 「名前の先頭の文字」を選択している状態
    NameHeadChars,
    /// 「名前」を選択している状態
    Names,
    /// 「こんにちは」を表示している状態
    Greet,
}

つまり、各 Action に応じて行うべき具体的な処理は、以下のようなものになります。

  • Previous : hierarchy_selected に応じて適切な list_state_*select_previous() を呼び出す
  • Next : hierarchy_selected に応じて適切な list_state_*select_next() を呼び出す
  • Leave : hierarchy_selected を上位 (左) の階層に移動させ、移動前の階層に対応した list_state_* の選択インデックスを None に戻す
  • Visit : hierarchy_selected を下位 (右) の階層に移動させ、移動先の階層に対応した list_state_*select_first() を呼び出す

Home に対する Component::updaate() の実装は以下のように変更しました。

src/components/home.rs
    fn update(&mut self, action: Action) -> Result<Option<Action>> {
        match action {
            Action::Tick | Action::Render => { }
            _ => { self.last_action = format!("{:?}", action) }
        }
        match action {
            Action::Tick => {
                // add any logic here that should run on every tick
            }
            Action::Render => {
                // add any logic here that should run on every render
            }
            Action::Previous => {
                self.select_previous();
            }
            Action::Next => {
                self.select_next();
            }
            Action::Leave => {
                self.select_hierarcy_previous();
            }
            Action::Visit => {
                self.select_hierarchy_next();
            }
            _ => {}
        }
        Ok(None)
    }

ちなみに self.last_actionTick,Render を記録していないのは、ほぼ常時入ってくるためです。

select_previous(),select_next(),select_hierarcy_previous(),select_hierarchy_next() の実装は少々長くなるので折りたたみます。
まあ self.hierarchy_selected のマッチングに応じた素直な処理だと思います。

ソースコード
src/components/home.rs
    fn selected_row(&mut self) -> Option<&mut NameHeadCharRowListItem> {
        let item = self
            .list_state_rows
            .selected()
            .and_then(|idx| self.list.items.get_mut(idx));
        item
    }

    fn selected_char(&mut self) -> Option<&mut NameHeadCharListItem> {
        let idx = self.list_state_chars.selected();
        if let Some(row) = self.selected_row() {
            let item = idx.and_then(|idx| row.chars.items.get_mut(idx));
            item
        } else {
            None
        }
    }

    fn selected_name(&mut self) -> Option<&mut NameListItem> {
        let idx = self.list_state_names.selected();
        if let Some(char) = self.selected_char() {
            let item = idx.and_then(|idx| char.names.items.get_mut(idx));
            item
        } else {
            None
        }
    }

    fn select_next(&mut self) {
        match self.hierarchy_selected {
            ListHierarchy::None => {
                return;
            }
            ListHierarchy::NameHeadCharRows => {
                self.list_state_rows.select_next();
            }
            ListHierarchy::NameHeadChars => {
                if let Some(_row) = self.selected_row() {
                    self.list_state_chars.select_next();
                }
            }
            ListHierarchy::Names => {
                if let Some(_char) = self.selected_char() {
                    self.list_state_names.select_next();
                }
            }
            ListHierarchy::Greet => {
                // do nothing
            }
        }
    }

    fn select_hierarcy_previous(&mut self) {
        match self.hierarchy_selected {
            ListHierarchy::None => (),
            ListHierarchy::NameHeadCharRows => {
                self.list_state_rows.select(None);
                self.hierarchy_selected = ListHierarchy::None;
            }
            ListHierarchy::NameHeadChars => {
                if let Some(_row) = self.selected_row() {
                    self.list_state_chars.select(None);
                };
                self.hierarchy_selected = ListHierarchy::NameHeadCharRows;
            }
            ListHierarchy::Names => {
                if let Some(_char) = self.selected_char() {
                    self.list_state_names.select(None);
                };
                self.hierarchy_selected = ListHierarchy::NameHeadChars;
            }
            ListHierarchy::Greet => {
                self.hierarchy_selected = ListHierarchy::Names;
            }
        }
    }   
    
    fn select_hierarchy_next(&mut self) {
        match self.hierarchy_selected {
            ListHierarchy::None => {
                self.hierarchy_selected = ListHierarchy::NameHeadCharRows;
                self.list_state_rows.select_first();
            }
            ListHierarchy::NameHeadCharRows => {
                self.hierarchy_selected = ListHierarchy::NameHeadChars;
                if let Some(_row) = self.selected_row() { 
                    self.list_state_chars.select_first();
                };
            }
            ListHierarchy::NameHeadChars => {
                self.hierarchy_selected = ListHierarchy::Names;
                if let Some(_char) = self.selected_char() {
                    self.list_state_names.select_first(); 
                };
            }
            ListHierarchy::Names => {
                self.hierarchy_selected = ListHierarchy::Greet;
            }
            ListHierarchy::Greet => {
                // do nothing
            }
        }
    }

    fn select_previous(&mut self) {
        match self.hierarchy_selected {
            ListHierarchy::None => {
                return;
            }
            ListHierarchy::NameHeadCharRows => {
                self.list_state_rows.select_previous();
            }
            ListHierarchy::NameHeadChars => {
                if let Some(_row) = self.selected_row() {
                    self.list_state_chars.select_previous();
                }
            }
            ListHierarchy::Names => {
                if let Some(_char) = self.selected_char() {
                    self.list_state_names.select_previous();
                }
            }
            ListHierarchy::Greet => {
                // do nothing
            }
        }
    }

(Rust 初心者的に) ちょっと面白かったのは以下の部分。

    fn selected_char(&mut self) -> Option<&mut NameHeadCharListItem> {
        let idx = self.list_state_chars.selected();
        if let Some(row) = self.selected_row() {
            let item = idx.and_then(|idx| row.chars.items.get_mut(idx));
            item
        } else {
            None
        }
    }

当初は let idx = ... 分を if let ブロックの中に入れていたのですが、借用順の問題でエラーになりました。
self.selected_row() において self が可変借用されているためで、row の生存期間中には self を不変借用することは許可されませんでした。
C 言語であれば (純粋に可読性のためだけに) idx のスコープを最小にするところですが、Rust では可変参照のライフタイムを最小にすることがより重要になるのですね。

// C で実装したらこんな感じ?
NameHeadCharListItem * selected_char(Home *self) {
  NameHeadCharRowListItem *row = selected_row(self);
  if (row != NULL) {
    // `row` が NULL なら `idx` は不要なのでスコープがより小さい
    const uint8_t idx = self->list_state_chars.selected();
    if (idx >= 0) {
      return row->chars.items[idx];
    }
  }
  return NULL;
}

画面描画の実装

さて、あとは Action が実行された後の struct Home の状態に応じた画面描画の実装を行っていきます。

HomeComponent::draw() 実装は元々はこんな素っ気ない実装でした。

src/components/home.rs
    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
        frame.render_widget(Paragraph::new("hello world"), area);
        Ok(())
    }

これを以下のようにします。(このレイアウトは "List" サンプルほぼそのまま)

src/components/home.rs
    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
        let [header_area, main_area, footer_area] = Layout::vertical([
            Constraint::Length(2),
            Constraint::Fill(1),
            Constraint::Length(1),
        ])
        .areas(area);

        self.draw_header(frame, header_area);
        self.draw_list(frame, main_area);
        self.draw_footer(frame, footer_area);
        
        Ok(())
    }

draw_header(), draw_footer() は何となく置いてみただけです。 footer にはデバッグ用に self.last_action を表示させることにしました。

src/components/home.rs
    fn draw_header(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Paragraph::new("Header"), area);
    }

    fn draw_footer(&self, frame: &mut Frame, area: Rect) {
        frame.render_widget(Paragraph::new(format!("Last Action : {}", &self.last_action)), area);
    }

draw_list() はほぼ "List" サンプル そのままです。
こちらも折りたたんでおきます。

ソースコード
src/components/home.rs

const MENU_HEADER_STYLE: Style = Style::new().fg(SLATE.c100).bg(SLATE.c950);
const NORMAL_ROW_BG: Color = SLATE.c800;
const ALT_ROW_BG_COLOR: Color = SLATE.c900;
const SELECTED_STYLE: Style = Style::new().bg(SLATE.c700).add_modifier(Modifier::BOLD);
const TEXT_FG_COLOR: Color = SLATE.c200;

impl Home {
    
    // . . .

    fn draw_list(&mut self, frame: &mut Frame, area: Rect) {
        let [area_row, area_char, area_name, area_greet ] = Layout::horizontal([
            Constraint::Percentage(25),
            Constraint::Percentage(25),
            Constraint::Percentage(25),
            Constraint::Percentage(25),
        ])
        .areas(area);
        
        let menu_block = Block::new()
            .borders(Borders::ALL)
            .border_set(symbols::border::ROUNDED)
            .border_style(MENU_HEADER_STYLE)
            .bg(NORMAL_ROW_BG);

        let rows: Vec<ListItem> = self
            .list
            .items
            .iter()
            .enumerate()
            .map(|(i, item)| {
                let color = alternate_colors(i);
                ListItem::from(item)
                .bg(color)
            })
            .collect();

        let list_rows = List::new(rows)
            .block(menu_block.clone().title(Line::raw(" 子音行 ").centered()))
            .highlight_style(SELECTED_STYLE)
            .highlight_symbol(">")
            .highlight_spacing(HighlightSpacing::Always)
            ;
        
        frame.render_stateful_widget(list_rows, area_row, &mut self.list_state_rows);
        
        let row_selected = self.selected_row();
        if let Some(row) = row_selected {
            let chars: Vec<ListItem> = row
                .chars
                .items
                .iter()
                .enumerate()
                .map(|(i, item)| {
                    let color = alternate_colors(i);
                    ListItem::from(item)
                    .bg(color)
                })
                .collect();
            let list_chars = List::new(chars)
                .block(menu_block.clone().title(Line::raw(" さいしょのもじ ").centered()))
                .highlight_style(SELECTED_STYLE)
                .highlight_symbol(">")
                .highlight_spacing(HighlightSpacing::Always)
                ;
            frame.render_stateful_widget(list_chars, area_char, &mut self.list_state_chars);

            let char_selected = self.selected_char();
            if let Some(char) = char_selected {
                let names: Vec<ListItem> = char
                    .names
                    .items
                    .iter()
                    .enumerate()
                    .map(|(i, item)| {
                        let color = alternate_colors(i);
                        ListItem::from(item)
                        .bg(color)
                    })
                    .collect();
                let list_names = List::new(names)
                    .block(menu_block.clone().title(Line::raw(" なまえ ").centered()))
                    .highlight_style(SELECTED_STYLE)
                    .highlight_symbol(">")
                    .highlight_spacing(HighlightSpacing::Always)
                    ;
                frame.render_stateful_widget(list_names, area_name, &mut self.list_state_names);
            }
        }

        if let ListHierarchy::Greet = self.hierarchy_selected {
            if let Some(name) = self.selected_name() {
                let greet = Paragraph::new(
                    vec![
                        Line::raw(""),
                        Line::raw(""),
                        Line::raw(format!("こんにちは、{} さん!", name.name)),
                    ]
                )
                .centered();
                frame.render_widget(greet, area_greet);
            }
        }
    }
}

const fn alternate_colors(i: usize) -> Color {
    if i % 2 == 0 {
        NORMAL_ROW_BG
    } else {
        ALT_ROW_BG_COLOR
    }
}

impl From<&NameHeadCharRowListItem> for ListItem<'_> {
    fn from(value: &NameHeadCharRowListItem) -> Self {
        let line = Line::styled(format!("{}", value.name_head_char_row), TEXT_FG_COLOR);
        ListItem::new(line)
    }
}

impl From<&NameHeadCharListItem> for ListItem<'_> {
    fn from(value: &NameHeadCharListItem) -> Self {
        let line = Line::styled(format!("{}", value.name_head_char), TEXT_FG_COLOR);
        ListItem::new(line)
    }
}

impl From<&NameListItem> for ListItem<'_> {
    fn from(value: &NameListItem) -> Self {
        let line = Line::styled(format!("{}", value.name), TEXT_FG_COLOR);
        ListItem::new(line)
    }
}

draw_list() 内のネストが深くなりすぎたので、もう少し構造化してもよかったかも知れませんが、今回はこのままにしておきましょう。

注目すべきは (やはり "List" サンプル そのままなのですが…) 、"name_list.rs" で定義した各リストの子要素を ratatui::widgets::ListItem に変換するために From<T> トレイトの実装が必要になっているところでしょうか。

impl From<&NameHeadCharRowListItem> for ListItem<'_> {
    fn from(value: &NameHeadCharRowListItem) -> Self {
        let line = Line::styled(format!("{}", value.name_head_char_row), TEXT_FG_COLOR);
        ListItem::new(line)
    }
}

単に文字列を Widget のラベルに変換するだけでなく、状態に応じて表示スタイルを変更したりといった処理も from() の中で行うことができます。

まとめ

階層メニューの実装を通して「Ratatui Template のどこから手を付けたらいいのか」についてまとめてみました。
次回は、もう少し実用的な TUI ツールをつくってみたいと思います。

4

Discussion

ログインするとコメントできます