こちらの記事は Rust Advent Calendar 2024 シリーズ 3 5日目の記事です!
はじめに
こんにちは!jposta を紹介させてください。jpostaは、日本の郵便番号・住所をターミナルから手軽に検索できるTUIツール 🔍 です。Rustで書かれており ⚡、使いやすさを重視してリアルタイム検索を実装しました 🖥️。
元ライブラリーの実装についてはsyumai さんの実装を全面的に参考にさせていただいております。美しい実装すぎて震えました。
機能紹介
この小さなツールでは、郵便番号から住所の簡単検索 🏠 はもちろん、住所からの郵便番号検索 🔢 もラクラクできます。入力しながらサクサク表示されるリアルタイム検索 ⚡ や、キーボードだけでスイスイ操作 ⌨️ が可能で、スクロールもサクサク動き 📜、もちろん日本語もバッチリ対応 🗾 しています。
ぜひGitHubをチェックしてみてください! github.com
インストール
cargo install --git https://github.com/nwiizo/jposta
もしくは
cargo install jposta
こちら、みんなだいすきcrate.ioにちゃんとあげました。
https://crates.io/crates/jposta
基本操作
- Tab: 郵便番号/住所検索モード切替
- ↑↓: 結果スクロール
- Esc: 終了
検索モード
郵便番号検索
- 数字を入力すると自動で該当する住所を表示
- 部分一致対応("100"で始まる郵便番号すべて等)
住所検索
- 漢字やかなで住所を入力
- 部分一致対応("渋谷"等)
Rustでの実装解説
1. 基本構造の定義
#[derive(Clone)] enum InputMode { Postal, // 郵便番号検索 Address, // 住所検索 } struct App { input: String, results: Vec<String>, input_mode: InputMode, scroll_state: ScrollbarState, scroll_position: u16, search_tx: mpsc::Sender<String>, result_rx: mpsc::Receiver<Vec<String>>, }
InputMode
は検索モードを表す列挙型です。Clone
トレイトを導出することで、値のコピーが可能になります。
App
構造体はアプリケーションの状態を管理します。
input
: 現在の入力文字列results
: 検索結果の配列input_mode
: 現在の検索モードscroll_state
とscroll_position
: スクロール状態の管理search_tx
とresult_rx
: スレッド間通信用のチャンネル
2. アプリケーションの初期化
impl App { fn new() -> App { let (search_tx, search_rx) = mpsc::channel::<String>(); let (result_tx, result_rx) = mpsc::channel(); thread::spawn(move || { let mut last_query = String::new(); let mut input_mode = InputMode::Postal; while let Ok(query) = search_rx.recv() { // 検索処理(後述) } }); App { input: String::new(), results: Vec::new(), input_mode: InputMode::Postal, scroll_state: ScrollbarState::default(), scroll_position: 0, search_tx, result_rx, } } }
new()
関数では、
- 2つのチャンネルを作成(検索クエリ用と結果用)
- 検索処理を行うワーカースレッドを起動
- 初期状態のAppインスタンスを返す
3. 検索処理の実装
// 検索スレッド内の処理 if query.starts_with("MODE_CHANGE:") { input_mode = match &query[11..] { "postal" => InputMode::Postal, _ => InputMode::Address, }; continue; } if query == last_query { continue; } last_query = query.clone(); if query.is_empty() { let _ = result_tx.send(Vec::new()); continue; } thread::sleep(Duration::from_millis(100)); let results = match input_mode { InputMode::Postal => lookup_addresses(&query) .map(|addresses| { addresses .into_iter() .map(|addr| addr.formatted_with_kana()) .collect() }) .unwrap_or_default(), InputMode::Address => search_by_address(&query) .into_iter() .map(|addr| addr.formatted_with_kana()) .collect(), }; let _ = result_tx.send(results);
検索処理では、
- モード変更メッセージの確認と処理
- 重複クエリのスキップ
- 空クエリの即時処理
- ディバウンス処理(100ms)
- モードに応じた検索実行
- 結果の送信
4. UIとイベント処理
fn main() -> io::Result<()> { enable_raw_mode()?; let mut stdout = stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; let mut app = App::new(); loop { app.check_results(); terminal.draw(|f| { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Min(0) ]) .split(f.size()); // 入力欄の描画 let input_block = Block::default() .title(match app.input_mode { InputMode::Postal => "郵便番号検索", InputMode::Address => "住所検索", }) .borders(Borders::ALL); let input = Paragraph::new(app.input.as_str()) .block(input_block) .style(Style::default().fg(Color::Yellow)); f.render_widget(input, chunks[0]); // 結果表示の描画 let results_block = Block::default() .title(format!("検索結果 ({} 件)", app.results.len())) .borders(Borders::ALL); let results = Paragraph::new(app.results.join("\n")) .block(results_block) .scroll((app.scroll_position, 0)); f.render_widget(results, chunks[1]); })?; // キー入力処理 if let Event::Key(key) = event::read()? { match key.code { KeyCode::Char(c) => { app.input.push(c); app.search(); } KeyCode::Backspace => { app.input.pop(); app.search(); } KeyCode::Up => app.scroll_up(), KeyCode::Down => app.scroll_down(), KeyCode::Tab => app.change_mode(match app.input_mode { InputMode::Postal => InputMode::Address, InputMode::Address => InputMode::Postal, }), KeyCode::Esc => break, _ => {} } } } // 終了処理 execute!(terminal.backend_mut(), LeaveAlternateScreen)?; disable_raw_mode()?; Ok(()) }
UIとイベント処理では、
- ターミナルの初期化
- メインループ
- 検索結果の確認
- 画面描画
- キー入力処理
- 終了時のクリーンアップ
5. 補助機能の実装
impl App { fn search(&mut self) { let _ = self.search_tx.send(self.input.clone()); } fn check_results(&mut self) { if let Ok(new_results) = self.result_rx.try_recv() { self.results = new_results; self.scroll_position = 0; self.scroll_state = ScrollbarState::new(self.results.len()); } } fn scroll_up(&mut self) { self.scroll_position = self.scroll_position.saturating_sub(1); } fn scroll_down(&mut self) { if !self.results.is_empty() { self.scroll_position = self .scroll_position .saturating_add(1) .min((self.results.len() as u16).saturating_sub(1)); } } fn change_mode(&mut self, mode: InputMode) { self.input_mode = mode; let mode_str = match self.input_mode { InputMode::Postal => "postal", InputMode::Address => "address", }; let _ = self.search_tx.send(format!("MODE_CHANGE:{}", mode_str)); self.input.clear(); self.results.clear(); } }
補助機能として、 1. 検索リクエストの送信 2. 検索結果の確認と更新 3. スクロール処理 4. モード切替処理
これらの機能により、スムーズな検索体験を実現しています。
使用ライブラリ
- ratatui: TUI(テキストユーザーインターフェース)フレームワーク
- crossterm: ターミナル操作ライブラリ
- jpostcode_rs: 郵便番号データ処理ライブラリ
Rust学習リソース
1. 基礎学習
- The Rust Programming Language - 公式ガイドブック
- Rust by Example - 実例で学ぶRust
- Rustlings - 対話型学習ツール
2. 基本概念
3. メモリ管理
4. 言語機能
5. エラー処理と型システム
さいごに
このプロジェクトは、Rustの実践的な学習と日本の住所システムへの理解を深める良い機会となりました 📚。非同期処理やTUIの実装を通じて、Rustの強力な型システムと安全性を活かしたコーディングを実践できました ⚡。
ぜひ使ってみて、フィードバックをいただければ幸いです 🙏。プルリクエストも大歓迎です 🎉!