じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

Rustで郵便番号・住所検索TUIツールを開発した - jposta

こちらの記事は Rust Advent Calendar 2024 シリーズ 3 5日目の記事です!

qiita.com

はじめに

こんにちは!jposta を紹介させてください。jpostaは、日本の郵便番号・住所をターミナルから手軽に検索できるTUIツール 🔍 です。Rustで書かれており ⚡、使いやすさを重視してリアルタイム検索を実装しました 🖥️。

jposta の動作イメージ

元ライブラリーの実装についてはsyumai さんの実装を全面的に参考にさせていただいております。美しい実装すぎて震えました。

blog.syum.ai

機能紹介

この小さなツールでは、郵便番号から住所の簡単検索 🏠 はもちろん、住所からの郵便番号検索 🔢 もラクラクできます。入力しながらサクサク表示されるリアルタイム検索 ⚡ や、キーボードだけでスイスイ操作 ⌨️ が可能で、スクロールもサクサク動き 📜、もちろん日本語もバッチリ対応 🗾 しています。

ぜひGitHubをチェックしてみてください! github.com

インストール

cargo install --git https://github.com/nwiizo/jposta

もしくは

cargo install jposta

こちら、みんなだいすきcrate.ioにちゃんとあげました。

https://crates.io/crates/jposta

基本操作

  • Tab: 郵便番号/住所検索モード切替
  • ↑↓: 結果スクロール
  • Esc: 終了

検索モード

  1. 郵便番号検索

    • 数字を入力すると自動で該当する住所を表示
    • 部分一致対応("100"で始まる郵便番号すべて等)
  2. 住所検索

    • 漢字やかなで住所を入力
    • 部分一致対応("渋谷"等)

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_statescroll_position: スクロール状態の管理
  • search_txresult_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()関数では、

  1. 2つのチャンネルを作成(検索クエリ用と結果用)
  2. 検索処理を行うワーカースレッドを起動
  3. 初期状態の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);

検索処理では、

  1. モード変更メッセージの確認と処理
  2. 重複クエリのスキップ
  3. 空クエリの即時処理
  4. ディバウンス処理(100ms)
  5. モードに応じた検索実行
  6. 結果の送信

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とイベント処理では、

  1. ターミナルの初期化
  2. メインループ
    • 検索結果の確認
    • 画面描画
    • キー入力処理
  3. 終了時のクリーンアップ

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. モード切替処理

これらの機能により、スムーズな検索体験を実現しています。

使用ライブラリ

Rust学習リソース

1. 基礎学習

2. 基本概念

3. メモリ管理

4. 言語機能

5. エラー処理と型システム

さいごに

このプロジェクトは、Rustの実践的な学習と日本の住所システムへの理解を深める良い機会となりました 📚。非同期処理やTUIの実装を通じて、Rustの強力な型システムと安全性を活かしたコーディングを実践できました ⚡。

ぜひ使ってみて、フィードバックをいただければ幸いです 🙏。プルリクエストも大歓迎です 🎉!

ソースコード

🦀GitHub - jposta