Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

7. 実践:タスク管理アプリの開発とWeb展開

タスク管理(ToDo)アプリの設計と機能要件

本書の集大成として、これまで学んだパネル構成、ウィジェット、レイアウト、色装飾を組み合わせて、日常的に使える「タスク管理(ToDo)アプリ」を作成します。

実装する主な機能は以下のとおりです。

  • 新しいタスクの追加(タイトル入力、優先度の選択、Enter キーまたは追加ボタンでの登録)
  • 状態による絞り込みフィルタ(すべて / 未完了のみ / 完了済みのみ)とキーワード検索
  • タスク一覧のスクロール表示、完了チェックボックス、優先度バッジの色分け、個別削除ボタン
  • 下部ステータスバーでの進捗集計(残りタスク数、完了率のプログレスバー、完了済み一括削除)
flowchart TD
    Root["TodoApp(アプリ全体の状態)"] --> Top["TopBottomPanel::top<br/>タイトル・キーワード検索・状態フィルタ"]
    Root --> Bottom["TopBottomPanel::bottom<br/>残り件数・達成率ProgressBar・完了済み一括削除"]
    Root --> Central["CentralPanel<br/>新規タスク入力バー + ScrollAreaによるタスク一覧"]

タスク管理アプリの完全なコード実装

src/main.rs にそのまま貼り付けて cargo run で動かせる完全なソースコードです(日本語表示を行う場合は第 2 章の setup_custom_fonts 関数を追加してください。ここではどの環境でもそのまま動くよう英語ラベルを基本としつつ、日本語入力にも対応できる構成にしています)。

use eframe::egui::{self, Color32, RichText};

fn main() -> eframe::Result<()> {
    let options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default()
            .with_inner_size([560.0, 480.0])
            .with_min_inner_size([400.0, 300.0]),
        ..Default::default()
    };

    eframe::run_native(
        "egui Task Manager",
        options,
        Box::new(|_cc| Ok(Box::<TodoApp>::default())),
    )
}

// タスクの優先度を表す列挙型
#[derive(PartialEq, Clone, Copy, Debug)]
enum Priority {
    Low,
    Medium,
    High,
}

impl Priority {
    fn label(&self) -> &'static str {
        match self {
            Priority::Low => "LOW",
            Priority::Medium => "MED",
            Priority::High => "HIGH",
        }
    }

    fn color(&self) -> Color32 {
        match self {
            Priority::Low => Color32::from_rgb(100, 180, 240),
            Priority::Medium => Color32::from_rgb(240, 180, 60),
            Priority::High => Color32::from_rgb(240, 90, 90),
        }
    }
}

// 表示フィルタの種類
#[derive(PartialEq, Clone, Copy)]
enum Filter {
    All,
    Active,
    Completed,
}

// 1件のタスクを表す構造体
struct TaskItem {
    id: usize,
    title: String,
    completed: bool,
    priority: Priority,
}

// アプリケーション全体の状態を保持する構造体
struct TodoApp {
    tasks: Vec<TaskItem>,
    next_id: usize,
    new_title: String,
    new_priority: Priority,
    filter: Filter,
    search_query: String,
}

impl Default for TodoApp {
    fn default() -> Self {
        Self {
            tasks: vec![
                TaskItem {
                    id: 1,
                    title: "Read egui guidebook chapters".to_owned(),
                    completed: true,
                    priority: Priority::Medium,
                },
                TaskItem {
                    id: 2,
                    title: "Build a custom desktop tool in Rust".to_owned(),
                    completed: false,
                    priority: Priority::High,
                },
                TaskItem {
                    id: 3,
                    title: "Try compiling to WebAssembly with trunk".to_owned(),
                    completed: false,
                    priority: Priority::Low,
                },
            ],
            next_id: 4,
            new_title: String::new(),
            new_priority: Priority::Medium,
            filter: Filter::All,
            search_query: String::new(),
        }
    }
}

impl TodoApp {
    fn add_task(&mut self) {
        let trimmed = self.new_title.trim();
        if !trimmed.is_empty() {
            self.tasks.push(TaskItem {
                id: self.next_id,
                title: trimmed.to_owned(),
                completed: false,
                priority: self.new_priority,
            });
            self.next_id += 1;
            self.new_title.clear();
        }
    }
}

impl eframe::App for TodoApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        // 1. 上部パネル:ヘッダーと検索・フィルタバー
        egui::TopBottomPanel::top("header_panel").show(ctx, |ui| {
            ui.add_space(4.0);
            ui.horizontal(|ui| {
                ui.heading("Task Manager");
                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                    egui::widgets::global_theme_preference_buttons(ui);
                });
            });
            ui.add_space(4.0);

            ui.horizontal(|ui| {
                ui.label("Search:");
                ui.add(
                    egui::TextEdit::singleline(&mut self.search_query)
                        .hint_text("Filter by keyword...")
                        .desired_width(160.0),
                );

                ui.separator();

                ui.selectable_value(&mut self.filter, Filter::All, "All");
                ui.selectable_value(&mut self.filter, Filter::Active, "Active");
                ui.selectable_value(&mut self.filter, Filter::Completed, "Completed");
            });
            ui.add_space(4.0);
        });

        // 2. 下部パネル:統計情報と一括削除ボタン
        egui::TopBottomPanel::bottom("footer_panel").show(ctx, |ui| {
            let total = self.tasks.len();
            let completed_count = self.tasks.iter().filter(|t| t.completed).count();
            let active_count = total - completed_count;
            let progress = if total > 0 {
                completed_count as f32 / total as f32
            } else {
                0.0
            };

            ui.add_space(4.0);
            ui.horizontal(|ui| {
                ui.label(format!("{active_count} items left"));
                ui.add(
                    egui::ProgressBar::new(progress)
                        .desired_width(140.0)
                        .show_percentage(),
                );

                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                    if ui
                        .add_enabled(
                            completed_count > 0,
                            egui::Button::new("Clear Completed"),
                        )
                        .clicked()
                    {
                        self.tasks.retain(|t| !t.completed);
                    }
                });
            });
            ui.add_space(4.0);
        });

        // 3. 中央パネル:新規タスク追加欄とタスク一覧リスト
        egui::CentralPanel::default().show(ctx, |ui| {
            // 新規タスク入力エリア
            ui.horizontal(|ui| {
                let text_res = ui.add(
                    egui::TextEdit::singleline(&mut self.new_title)
                        .hint_text("What needs to be done?")
                        .desired_width(260.0),
                );

                // Enterキーが押されたらタスクを追加
                if text_res.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
                    self.add_task();
                    text_res.request_focus(); // 連続入力しやすいようフォーカスを戻す
                }

                egui::ComboBox::from_id_salt("priority_select")
                    .selected_text(self.new_priority.label())
                    .width(70.0)
                    .show_ui(ui, |ui| {
                        ui.selectable_value(&mut self.new_priority, Priority::Low, "LOW");
                        ui.selectable_value(&mut self.new_priority, Priority::Medium, "MED");
                        ui.selectable_value(&mut self.new_priority, Priority::High, "HIGH");
                    });

                let can_add = !self.new_title.trim().is_empty();
                if ui.add_enabled(can_add, egui::Button::new("Add")).clicked() {
                    self.add_task();
                }
            });

            ui.separator();

            // タスク一覧のスクロール領域
            let query = self.search_query.to_lowercase();
            let mut delete_id: Option<usize> = None;

            egui::ScrollArea::vertical().show(ui, |ui| {
                for task in &mut self.tasks {
                    // 状態フィルタの判定
                    let matches_filter = match self.filter {
                        Filter::All => true,
                        Filter::Active => !task.completed,
                        Filter::Completed => task.completed,
                    };
                    // キーワード検索の判定
                    let matches_query =
                        query.is_empty() || task.title.to_lowercase().contains(&query);

                    if !matches_filter || !matches_query {
                        continue;
                    }

                    ui.group(|ui| {
                        ui.horizontal(|ui| {
                            ui.checkbox(&mut task.completed, "");

                            // 優先度バッジ
                            ui.label(
                                RichText::new(task.priority.label())
                                    .color(Color32::BLACK)
                                    .background_color(task.priority.color())
                                    .small()
                                    .strong(),
                            );

                            // 完了済みの場合は取り消し線(strikethrough)を付ける
                            let mut title_text = RichText::new(&task.title);
                            if task.completed {
                                title_text = title_text.strikethrough().weak();
                            }
                            ui.label(title_text);

                            // 右端に削除ボタンを配置する
                            ui.with_layout(
                                egui::Layout::right_to_left(egui::Align::Center),
                                |ui| {
                                    if ui.small_button("Delete").clicked() {
                                        delete_id = Some(task.id);
                                    }
                                },
                            );
                        });
                    });
                }
            });

            // ループの外で削除対象のタスクを取り除く(借用ルールを守るための定番パターン)
            if let Some(id) = delete_id {
                self.tasks.retain(|t| t.id != id);
            }
        });
    }
}

▸ Rustらしい実装のポイント:ループ内での要素削除

上のコードの末尾にある delete_id の使い方は、Rust と egui でリストを扱うときの非常に重要なテクニックです。for task in &mut self.tasks でループを回している最中は、self.tasks が可変借用されているため、そのループの中で直接 self.tasks.retain(...) や remove(...) を呼ぶとコンパイルエラーになります。

そこで、ループの中では「削除ボタンが押されたタスクの ID」だけをローカル変数 delete_id に記録しておき、for ループを抜けた直後に self.tasks.retain(|t| t.id != id) で削除を実行します。こうすることで、Rust の借用ルールをきれいに守りながら安全に要素を削除できます。

公式拡張クレート(egui_extras・egui_plot)の活用

標準の egui クレートだけでも多くの画面を作れますが、さらに高度な部品が必要になったときは、公式がメンテナンスしている拡張クレートを追加できます。

クレート名主な機能と特徴
egui_extras列幅をドラッグで変更できる本格的なテーブル(TableBuilder)、日付選択カレンダー(DatePickerButton)、画像ローダーなどを提供します。
egui_plot折れ線グラフ(Line)、棒グラフ(BarChart)、散布図(Points)を数行のコードで描画でき、マウスでのズームやスクロールにも標準対応しています。
egui_dock / egui_tilesVS Code やゲームエンジンのように、複数のタブやパネルをドラッグ&ドロップで自由に分割・ドッキングできる画面構成を提供します。

たとえば、egui_extras::TableBuilder を使うと、数万行のデータでも高速にスクロールできるヘッダー固定付きの表を簡単に作成できます。

WebAssembly(WASM)へのビルドとブラウザ実行

egui と eframe の大きな魅力のひとつが、デスクトップ向けに書いた Rust コードを WebAssembly(WASM)にコンパイルして、Chrome や Safari などの Web ブラウザ上でそのまま動かせる点です。

Web 向けにビルドする際は、Rust 製の WASM ビルドツールである trunk を使うのが最も簡単です。

▸ 1. WASMターゲットとtrunkのインストール

ターミナルで以下のコマンドを 1 回だけ実行し、WASM 用のコンパイルターゲットと trunk を導入します。

rustup target add wasm32-unknown-unknown
cargo install --locked trunk

▸ 2. index.html の作成

プロジェクトのルートディレクトリ(Cargo.toml と同じ階層)に、キャンバスを配置するためのシンプルな index.html を作成します。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>egui Web App</title>
    <link data-trunk rel="rust" data-wasm-opt="2" />
    <style>
        html, body {
            margin: 0;
            padding: 0;
            width: 100%;
            height: 100%;
            overflow: hidden;
        }
        canvas {
            width: 100%;
            height: 100%;
        }
    </style>
</head>
<body>
    <canvas id="the_canvas_id"></canvas>
</body>
</html>

▸ 3. main関数のネイティブ・Web両対応化

デスクトップ(ネイティブ)でビルドしたときは eframe::run_native を呼び、WebAssembly 向けにビルドしたときは eframe::WebRunner を使って HTML 上の <canvas id="the_canvas_id"> に描画するように、条件付きコンパイル属性 #[cfg(...)] で分岐します。

// デスクトップ(ネイティブ)実行時の main 関数
#[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result<()> {
    let options = eframe::NativeOptions::default();
    eframe::run_native(
        "Todo App",
        options,
        Box::new(|_cc| Ok(Box::<TodoApp>::default())),
    )
}

// Webブラウザ(WASM)実行時の main 関数
#[cfg(target_arch = "wasm32")]
fn main() {
    use eframe::wasm_bindgen::JsCast as _;

    let web_options = eframe::WebOptions::default();
    wasm_bindgen_futures::spawn_local(async {
        let document = web_sys::window()
            .expect("No window")
            .document()
            .expect("No document");

        let canvas = document
            .get_element_by_id("the_canvas_id")
            .expect("Failed to find the_canvas_id")
            .dyn_into::<web_sys::HtmlCanvasElement>()
            .expect("the_canvas_id was not a HtmlCanvasElement");

        let _ = eframe::WebRunner::new()
            .start(
                canvas,
                web_options,
                Box::new(|_cc| Ok(Box::<TodoApp>::default())),
            )
            .await;
    });
}

▸ 4. ローカルサーバーでの起動と本番ビルド

準備ができたら、以下のコマンドを実行するだけで自動的に WASM へのビルドと開発用 Web サーバーの起動が行われます。

# 開発用ローカルサーバーを起動する(ブラウザで http://127.0.0.1:8080 を開く)
trunk serve

# GitHub Pages や Cloudflare Pages 等に配置できる静的ファイルを dist/ に出力する
trunk build --release

trunk build --release を実行すると、dist/ フォルダの中に HTML、JavaScript、そして最適化された .wasm ファイルが出力されます。これを任意の Web サーバーに置くだけで、インストール不要で誰でもブラウザから触れる Rust 製 GUI アプリを公開できます。

本書で学んだ Immediate Mode の考え方、ウィジェットと Response の扱い方、パネルとレイアウトの組み合わせ、そしてスレッド連携のパターンを活用すれば、ちょっとした業務効率化ツールから本格的なビジュアライザーまで、Rust の安全性と速さを活かした GUI アプリケーションを自在に開発できます。