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

8. 実践:タスク管理デスクトップアプリの開発

作成するアプリケーションの仕様と全体設計

本章では、これまでの章で学んだ GPUI と GPUI-Component の知識を統合し、実践的なタスク管理デスクトップアプリケーション 「TaskFlow」 を構築します。

まずは完成形の画面イメージと、組み込む機能を確認しましょう。

+-------------------------------------------------------------------+
|  TaskFlow                        [残: 2件]  [テーマ切替] [完了削除] |
+-------------------------------------------------------------------+
|  [ 新しいタスクを入力... (Ctrl+N)                  ] [+ 追加する] |
+-------------------------------------------------------------------+
|  フィルタ: (・すべて ) ( 未完了 ) ( 完了済み )     🔍 [ 検索...  ] |
+-------------------------------------------------------------------+
|  [✓] GPUIの基本アーキテクチャを学ぶ          [学習]     [🗑 削除]  |
|  [ ] GPUI-Componentでテーブルを実装する      [開発]     [🗑 削除]  |
|  [ ] リリースビルドを作成して配布する        [運用]     [🗑 削除]  |
+-------------------------------------------------------------------+

このアプリケーションには、実際のデスクトップ開発で必要となる以下のエッセンスがすべて詰まっています。

  • 状態管理(第 2 章): Entity<TaskApp> によるタスク配列の保持と、cx.notify() によるリアクティブ更新。
  • レイアウトと再利用部品(第 3 章): h_flex / v_flex による整列と、RenderOnce を使ったタスク行コンポーネントの分離。
  • アクションとショートカット(第 4 章): Ctrl+N(macOS は Cmd+N)で入力欄へ即座にフォーカス、Enter キーでタスク追加。
  • テーマ対応(第 5 章): cx.theme() を使った配色と、ワンクリックでのライト/ダークモード切り替え。
  • ウィジェットと通知(第 6 章): InputState、Button、Checkbox、Tag、および Notification(トースト通知)の連携。

プロジェクト設定とデータモデルの定義

新しいプロジェクト cargo new task-flow を作成し、Cargo.toml を設定します。

[package]
name = "task-flow"
version = "0.1.0"
edition = "2021"

[dependencies]
gpui = "0.2"
gpui-component = "0.5"

続いて、アプリケーションで扱うデータ構造(タスク 1 件を表す TaskItem と、表示フィルタを表す TaskFilter)およびキーボードショートカット用のアクションを定義します。

#![allow(unused)]
fn main() {
use gpui::prelude::*;
use gpui::*;
use gpui_component::{
    button::{Button, ButtonVariants as _},
    checkbox::Checkbox,
    h_flex,
    input::{Input, InputEvent, InputState},
    notification::Notification,
    tag::Tag,
    v_flex, ActiveTheme as _, IconName, Root, Selectable as _, Sizable as _, Theme, ThemeMode,
    WindowExt as _,
};

// キーボードショートカット用のアクション定義
actions!(task_flow, [FocusNewTask, ToggleTheme]);

// タスクの絞り込み条件
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum TaskFilter {
    All,
    Active,
    Completed,
}

// 1件のタスクを表すデータ構造
#[derive(Clone)]
pub struct TaskItem {
    pub id: usize,
    pub title: SharedString,
    pub category: SharedString,
    pub completed: bool,
}
}

ヘッダー・検索バー・入力フォームの実装

次に、アプリケーション全体の状態を保持する TaskApp 構造体と、その初期化処理(new)およびタスク操作メソッドを実装します。 新しいタスクを入力する new_task_input と、タスクをキーワードで絞り込む search_input の 2 つの Entity<InputState> を保持します。

#![allow(unused)]
fn main() {
pub struct TaskApp {
    focus_handle: FocusHandle,
    tasks: Vec<TaskItem>,
    next_id: usize,
    filter: TaskFilter,
    new_task_input: Entity<InputState>,
    search_input: Entity<InputState>,
}

impl Focusable for TaskApp {
    fn focus_handle(&self, _cx: &App) -> FocusHandle {
        self.focus_handle.clone()
    }
}

impl TaskApp {
    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        // 1. 新規タスク入力欄の作成
        let new_task_input = cx.new(|cx| {
            InputState::new(window, cx)
                .placeholder("新しいタスクを入力して Enter を押す...")
                .clean_on_escape()
        });

        // 2. 検索フィルタ入力欄の作成
        let search_input = cx.new(|cx| {
            InputState::new(window, cx)
                .placeholder("キーワードで絞り込み...")
                .clean_on_escape()
        });

        // 新規タスク入力欄で Enter キーが押されたらタスクを追加する
        cx.subscribe_in(
            &new_task_input,
            window,
            |this, _state, event: &InputEvent, window, cx| {
                if let InputEvent::PressEnter { .. } = event {
                    this.add_task(window, cx);
                }
            },
        )
        .detach();

        // 検索欄の文字が変わるたびに画面を再描画してリアルタイム絞り込みを行う
        cx.subscribe_in(
            &search_input,
            window,
            |_this, _state, event: &InputEvent, _window, cx| {
                if let InputEvent::Change = event {
                    cx.notify();
                }
            },
        )
        .detach();

        // 初期サンプルデータ
        let initial_tasks = vec![
            TaskItem {
                id: 1,
                title: "GPUIのコアアーキテクチャとEntityを理解する".into(),
                category: "学習".into(),
                completed: true,
            },
            TaskItem {
                id: 2,
                title: "GPUI-ComponentのInputとButtonを組み込む".into(),
                category: "開発".into(),
                completed: false,
            },
            TaskItem {
                id: 3,
                title: "ダークモード切替とショートカットキーをテストする".into(),
                category: "検証".into(),
                completed: false,
            },
        ];

        Self {
            focus_handle: cx.focus_handle(),
            tasks: initial_tasks,
            next_id: 4,
            filter: TaskFilter::All,
            new_task_input,
            search_input,
        }
    }

    // タスクの追加処理
    fn add_task(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let raw_text = self.new_task_input.read(cx).value().trim().to_string();
        if raw_text.is_empty() {
            return;
        }

        self.tasks.push(TaskItem {
            id: self.next_id,
            title: raw_text.clone().into(),
            category: "一般".into(),
            completed: false,
        });
        self.next_id += 1;

        // 入力欄を空に戻す
        self.new_task_input.update(cx, |state, cx| {
            state.set_value("", window, cx);
        });

        // トースト通知を表示し、再描画をリクエスト
        window.push_notification(
            Notification::success(format!("タスク「{}」を追加しました", raw_text)),
            cx,
        );
        cx.notify();
    }

    // 完了状態のトグル切り替え
    fn toggle_task(&mut self, id: usize, cx: &mut Context<Self>) {
        if let Some(task) = self.tasks.iter_mut().find(|t| t.id == id) {
            task.completed = !task.completed;
            cx.notify();
        }
    }

    // 個別タスクの削除
    fn delete_task(&mut self, id: usize, cx: &mut Context<Self>) {
        self.tasks.retain(|t| t.id != id);
        cx.notify();
    }

    // 完了済みタスクの一括削除
    fn clear_completed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let before = self.tasks.len();
        self.tasks.retain(|t| !t.completed);
        let removed = before - self.tasks.len();

        if removed > 0 {
            window.push_notification(
                format!("完了済みのタスクを {} 件削除しました", removed),
                cx,
            );
            cx.notify();
        }
    }
}
}

タスク一覧とインタラクションの実装

続いて、TaskApp に Render トレイトを実装し、ヘッダー、入力バー、フィルタバー、そしてタスクリスト全体を組み立てます。

#![allow(unused)]
fn main() {
impl Render for TaskApp {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let theme = cx.theme();
        let active_count = self.tasks.iter().filter(|t| !t.completed).count();
        let search_query = self.search_input.read(cx).value().trim().to_lowercase();

        // フィルタ条件と検索キーワードの両方に一致するタスクだけを抽出する
        let filtered_tasks: Vec<TaskItem> = self
            .tasks
            .iter()
            .filter(|t| match self.filter {
                TaskFilter::All => true,
                TaskFilter::Active => !t.completed,
                TaskFilter::Completed => t.completed,
            })
            .filter(|t| {
                search_query.is_empty()
                    || t.title.to_lowercase().contains(&search_query)
                    || t.category.to_lowercase().contains(&search_query)
            })
            .cloned()
            .collect();

        v_flex()
            .key_context("TaskFlow")
            .track_focus(&self.focus_handle)
            // ショートカットアクションの受け取り
            .on_action(cx.listener(|this, _: &FocusNewTask, window, cx| {
                this.new_task_input.update(cx, |state, cx| {
                    state.focus(window, cx);
                });
            }))
            .on_action(cx.listener(|_this, _: &ToggleTheme, window, cx| {
                let next = if cx.theme().mode.is_dark() {
                    ThemeMode::Light
                } else {
                    ThemeMode::Dark
                };
                Theme::change(next, Some(window), cx);
            }))
            .size_full()
            .p_6()
            .gap_4()
            .bg(theme.background)
            .text_color(theme.foreground)
            // 1. ヘッダー領域
            .child(
                h_flex()
                    .justify_between()
                    .pb_2()
                    .border_b_1()
                    .border_color(theme.border)
                    .child(
                        h_flex()
                            .gap_3()
                            .child(
                                div()
                                    .text_xl()
                                    .font_weight(FontWeight::BOLD)
                                    .child("TaskFlow"),
                            )
                            .child(Tag::primary().child(format!("未完了: {} 件", active_count))),
                    )
                    .child(
                        h_flex()
                            .gap_2()
                            .child(
                                Button::new("theme-btn")
                                    .ghost()
                                    .small()
                                    .label(if theme.mode.is_dark() {
                                        "ライトモード"
                                    } else {
                                        "ダークモード"
                                    })
                                    .on_click(|_event, window, cx| {
                                        let next = if cx.theme().mode.is_dark() {
                                            ThemeMode::Light
                                        } else {
                                            ThemeMode::Dark
                                        };
                                        Theme::change(next, Some(window), cx);
                                    }),
                            )
                            .child(
                                Button::new("clear-btn")
                                    .danger()
                                    .small()
                                    .label("完了済みを削除")
                                    .on_click(cx.listener(|this, _event, window, cx| {
                                        this.clear_completed(window, cx);
                                    })),
                            ),
                    ),
            )
            // 2. 新規タスク追加フォーム
            .child(
                h_flex()
                    .gap_2()
                    .child(div().flex_1().child(Input::new(&self.new_task_input)))
                    .child(
                        Button::new("add-btn")
                            .primary()
                            .icon(IconName::Plus)
                            .label("追加する")
                            .on_click(cx.listener(|this, _event, window, cx| {
                                this.add_task(window, cx);
                            })),
                    ),
            )
            // 3. フィルタ切り替えボタンと検索ボックス
            .child(
                h_flex()
                    .justify_between()
                    .gap_4()
                    .child(
                        h_flex()
                            .gap_1()
                            .child(
                                Button::new("filter-all")
                                    .ghost()
                                    .small()
                                    .selected(self.filter == TaskFilter::All)
                                    .label("すべて")
                                    .on_click(cx.listener(|this, _, _, cx| {
                                        this.filter = TaskFilter::All;
                                        cx.notify();
                                    })),
                            )
                            .child(
                                Button::new("filter-active")
                                    .ghost()
                                    .small()
                                    .selected(self.filter == TaskFilter::Active)
                                    .label("未完了")
                                    .on_click(cx.listener(|this, _, _, cx| {
                                        this.filter = TaskFilter::Active;
                                        cx.notify();
                                    })),
                            )
                            .child(
                                Button::new("filter-completed")
                                    .ghost()
                                    .small()
                                    .selected(self.filter == TaskFilter::Completed)
                                    .label("完了済み")
                                    .on_click(cx.listener(|this, _, _, cx| {
                                        this.filter = TaskFilter::Completed;
                                        cx.notify();
                                    })),
                            ),
                    )
                    .child(
                        div()
                            .w(px(240.0))
                            .child(Input::new(&self.search_input).small().cleanable(true)),
                    ),
            )
            // 4. タスク一覧リスト
            .child(
                v_flex()
                    .flex_1()
                    .gap_2()
                    .overflow_y_hidden()
                    .children(filtered_tasks.into_iter().map(|task| {
                        let task_id = task.id;
                        h_flex()
                            .justify_between()
                            .p_3()
                            .rounded(theme.radius)
                            .bg(theme.secondary)
                            .border_1()
                            .border_color(theme.border)
                            .child(
                                h_flex()
                                    .gap_3()
                                    .child(
                                        Checkbox::new(("task-check", task_id))
                                            .checked(task.completed)
                                            .on_click(cx.listener(move |this, _checked, _, cx| {
                                                this.toggle_task(task_id, cx);
                                            })),
                                    )
                                    .child(
                                        div()
                                            .when(task.completed, |el| {
                                                el.line_through()
                                                    .text_color(theme.muted_foreground)
                                            })
                                            .child(task.title),
                                    ),
                            )
                            .child(
                                h_flex()
                                    .gap_2()
                                    .child(Tag::secondary().child(task.category))
                                    .child(
                                        Button::new(("delete-task", task_id))
                                            .ghost()
                                            .small()
                                            .icon(IconName::Delete)
                                            .on_click(cx.listener(move |this, _, _, cx| {
                                                this.delete_task(task_id, cx);
                                            })),
                                    ),
                            )
                    })),
            )
    }
}
}

リスト内の要素に ID を付ける際、("task-check", task_id) のように タプル(文字列と数値の組み合わせ) を渡している点に注目してください。 GPUI では (&'static str, usize) のタプルがそのまま一意な ElementId に変換されるため、ループの中で動的にボタンやチェックボックスを作るときに文字列フォーマット(format!)を実行する必要がありません。

ショートカットキー・通知・テーマ切り替えの統合

最後に、main 関数でショートカットキーのバインディングを登録し、ウィンドウを起動します。

fn main() {
    let app = Application::new();

    app.run(move |cx: &mut App| {
        // 1. GPUI-Component の初期化
        gpui_component::init(cx);

        // 2. キーボードショートカットの登録
        // Ctrl+N (macOSはCmd+N) で新規タスク入力欄にフォーカス
        // Ctrl+T (macOSはCmd+T) でライト/ダークテーマを切り替え
        cx.bind_keys([
            KeyBinding::new("secondary-n", FocusNewTask, Some("TaskFlow")),
            KeyBinding::new("secondary-t", ToggleTheme, Some("TaskFlow")),
        ]);

        // 3. ウィンドウサイズを 720x540 に設定して画面中央に開く
        let window_options = WindowOptions {
            window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
                None,
                size(px(720.0), px(540.0)),
                cx,
            ))),
            ..Default::default()
        };

        cx.open_window(window_options, |window, cx| {
            let app_view = cx.new(|cx| TaskApp::new(window, cx));
            cx.new(|cx| Root::new(app_view.into(), window, cx))
        })
        .expect("TaskFlow ウィンドウの起動に失敗しました");
    });
}

これで、わずか 250 行あまりの Rust コードで、日本語入力・リアルタイム検索・ショートカットキー・トースト通知・ダークモード切り替えを備えたネイティブアプリが完成しました。 次章(最終章)では、さらに大規模なアプリへ成長させるための「パフォーマンス最適化と自動テスト、配布戦略」を解説します。