Ratatui で階層メニューをつくってみる
背景と概要
日頃から、業務で使うちょっとしたツールを 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 Template から始めて、上掲の画像のような階層メニューを実装していきます。
名前リストの実装
いきなりですが、これはあまり Ratatui そのものとは関係ない部分です。
メニュー階層のより抽象的なデータを表現するために構造体をいくつか定義しています。
実装は新規ファイル "src/models/name_list.rs" の中で行いました。
"子音行 → 名前の先頭文字 → 名前リスト" という階層構造が、入れ子になった Vec
で表現されています。
なお、人名リストは Copilot くんが生成してくれたものをそのまま採用しました。
大目に見てあげてください。
"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 の改造は概ね以下のような流れになるんじゃないかと思われます。
-
Action
の追加 - キー入力の設定
-
Action
に応じた処理の実装 - 画面描画の実装
なお今回の実装で Template に含まれるファイルのうち主要な変更があったのは、"src/action.rs", ".config/config.json5", "src/components/home.rs" だけでした。
(ほかのファイルへの変更は mod
文の追加くらい)
なので、この Template をベースにしてより本格的なアプリケーションを作る場合にも、基本的には Action
と Component
トレイトの追加や修正だけで事足りそうです。
Action
の追加
Action
は列挙型です。細かいことを置いておけば「キー入力 (Event
) に応じて実行される処理の種類」くらいの理解で良いような気がします。
"action.rs" というファイルで定義されますが、enum Action
が定義されているだけの非常に小さなファイルです。
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
が要求されるようにしました。
{
"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
で発行された Action
は tokio
を経由しつつ最終的に Component
トレイトの update()
関数に渡されます。
後追いになりますが、今回追加定義した Action
が要求する処理は次のようなものです。
-
Previous
: ↑ キー入力に応じてリストの上の項目を選ぶ -
Next
: ↓ キー入力に応じてリストの下の項目を選ぶ -
Leave
: ← キー入力に応じてメニュー階層を一つ上る -
Visit
: → キー入力に応じてメニュー階層を一つ下る
ということで、struct Home
には以下のメンバーを追加しました。
#[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::ListState
はratatui::widgets::List
のステート管理を行います。
また enum ListHierarchy
は以下のようなものです。
#[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()
の実装は以下のように変更しました。
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_action
に Tick
,Render
を記録していないのは、ほぼ常時入ってくるためです。
select_previous()
,select_next()
,select_hierarcy_previous()
,select_hierarchy_next()
の実装は少々長くなるので折りたたみます。
まあ self.hierarchy_selected
のマッチングに応じた素直な処理だと思います。
ソースコード
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
の状態に応じた画面描画の実装を行っていきます。
Home
の Component::draw()
実装は元々はこんな素っ気ない実装でした。
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
frame.render_widget(Paragraph::new("hello world"), area);
Ok(())
}
これを以下のようにします。(このレイアウトは "List" サンプルほぼそのまま)
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
を表示させることにしました。
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" サンプル そのままです。
こちらも折りたたんでおきます。
ソースコード
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 ツールをつくってみたいと思います。
Discussion