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

6. 基本ウィジェットとフォーム制御

ボタン(Button)とバリエーション

画面操作の基本となる Button コンポーネントは、gpui_component::button モジュールに含まれています。 Button は内部状態を持たないステートレス部品(RenderOnce)なので、render メソッドの中で毎回 Button::new("一意なID") を呼び出して組み立てます。

▸ バリエーション(見た目)の切り替え

ButtonVariants トレイトをインポートすると、用途に応じた 6 種類のスタイルをメソッド 1 つで切り替えられます。

  • .primary(): 画面内で最も重要なメイン操作(保存、送信、追加など)。
  • .default()(既定): 標準的なセカンダリ操作(キャンセル、閉じるなど)。
  • .danger(): 取り消せない危険な操作(削除、リセットなど。赤色で強調されます)。
  • .warning(): 注意を促す操作。
  • .ghost(): 背景や枠線がなく、マウスを乗せたときだけ背景が出る控えめなボタン(ツールバーなどに最適)。
  • .link(): Web のハイパーリンクのように見えるテキストボタン。
#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::{
    button::{Button, ButtonVariants as _},
    h_flex, IconName, Sizable as _, Disableable as _,
};

fn render_buttons() -> impl IntoElement {
    h_flex()
        .gap_3()
        // 1. プライマリボタン
        .child(
            Button::new("save-btn")
                .primary()
                .label("保存する")
                .on_click(|_event, _window, _cx| {
                    println!("保存が実行されました");
                }),
        )
        // 2. アイコン付きの小サイズ(Small)削除ボタン
        .child(
            Button::new("delete-btn")
                .danger()
                .small()
                .icon(IconName::Delete)
                .label("削除")
                .on_click(|_event, _window, _cx| {
                    println!("削除が実行されました");
                }),
        )
        // 3. ローディング中・操作無効化ボタン
        .child(
            Button::new("loading-btn")
                .label("通信中...")
                .loading(true)   // スピナーアイコンが回り、クリックが防止される
                .disabled(true), // 明示的に無効化する場合
        )
}
}

アイコンだけの正方形ボタンを作りたいときも、Button::new("icon-btn").ghost().icon(IconName::Settings) のように .label() を省略するだけで自動的に美しいアイコンボタンになります。

アイコン・バッジ・アバター・タグ

文字だけでなく視覚的なシンボルを添えることで、画面の分かりやすさは劇的に向上します。 GPUI-Component には、すぐ使える表示用ウィジェットが揃っています。

▸ 1. アイコン(Icon と IconName)

GPUI-Component には、検索、設定、チェックマーク、矢印、フォルダ、ゴミ箱など、アプリ開発で頻繁に使う SVG アイコンが IconName 列挙型としてあらかじめ同梱されています。

#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _};

fn render_icons(cx: &App) -> impl IntoElement {
    Icon::new(IconName::Search)
        .small()
        .text_color(cx.theme().muted_foreground)
}
}

もちろん、独自の SVG ファイルを使いたい場合も Icon::empty().path("icons/custom.svg") のようにアセットパスを指定して表示できます。

▸ 2. タグ(Tag)とバッジ(Badge)

  • Tag: タスクのカテゴリやステータス(「進行中」「重要」など)を示す色付きの小さなラベルです。
  • Badge: アイコンやアバターの右上に未読件数(数字)や赤いドットを重ねて表示する部品です。
#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::{badge::Badge, h_flex, tag::Tag, Icon, IconName};

fn render_status_indicators() -> impl IntoElement {
    h_flex()
        .gap_4()
        // 色付きタグ
        .child(Tag::primary().child("新機能"))
        .child(Tag::danger().child("優先度:高"))
        // アイコンの右上に未読数「5」のバッジを重ねる
        .child(
            Badge::new()
                .count(5)
                .child(Icon::new(IconName::Bell)),
        )
}
}

テキスト入力(InputとInputState)

GUI フレームワークを自作するときに最も開発者を悩ませるのが テキスト入力欄 です。 キーボードの文字入力だけでなく、日本語や中国語の変換(IME 候補ウィンドウの位置合わせ)、マウスドラッグによる範囲選択、クリップボードへのコピー&ペースト(Ctrl+C / Ctrl+V)、取り消し(Ctrl+Z)、左右矢印キーでのカーソル移動など、膨大な処理が必要になるからです。

GPUI-Component の Input(および InputState)は、これらすべての機能を最初から完璧に備えています。

▸ InputState(状態)と Input(見た目)のペアで使う

テキスト入力欄は内部にカーソル位置や選択範囲などの複雑な状態を持つため、以下の 2 段階で扱います。

  1. 親ビューの初期化時(new): cx.new(|cx| InputState::new(window, cx)) を呼んで、状態エンティティ(Entity<InputState>)を作成し、親ビューのフィールドに保存します。
  2. 親ビューの描画時(render): Input::new(&self.input_state) を呼んで画面に配置します。
#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::{
    button::{Button, ButtonVariants as _},
    h_flex,
    input::{Input, InputEvent, InputState},
    v_flex,
};

pub struct FormDemo {
    name_input: Entity<InputState>,
    submitted_text: String,
}

impl FormDemo {
    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        // 1. InputState エンティティを作成する
        let name_input = cx.new(|cx| {
            InputState::new(window, cx)
                .placeholder("お名前を入力してください...")
                .clean_on_escape() // Escキーで入力をクリアするオプション
        });

        // 2. 入力内容の変化や Enter キー押下を購読(subscribe)する
        cx.subscribe_in(&name_input, window, |this, state, event: &InputEvent, window, cx| {
            match event {
                InputEvent::Change => {
                    // 文字が入力・削除されるたびに呼ばれる
                    let current = state.read(cx).value();
                    println!("入力中の文字列: {}", current);
                }
                InputEvent::PressEnter { .. } => {
                    // Enter キーが押されたら送信処理を実行する
                    this.submit(window, cx);
                }
                _ => {}
            }
        })
        .detach();

        Self {
            name_input,
            submitted_text: String::new(),
        }
    }

    fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        // InputState から現在の文字列を取り出す
        let text = self.name_input.read(cx).value().to_string();
        if !text.is_empty() {
            self.submitted_text = format!("こんにちは、{} さん!", text);
            // 入力欄を空に戻す
            self.name_input.update(cx, |state, cx| {
                state.set_value("", window, cx);
            });
            cx.notify();
        }
    }
}

impl Render for FormDemo {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        v_flex()
            .gap_3()
            .p_4()
            .child(
                h_flex()
                    .gap_2()
                    // 3. Input::new に &Entity<InputState> を渡して描画する
                    .child(
                        Input::new(&self.name_input)
                            .cleanable(true), // 右端にクリア用の「×」ボタンを表示
                    )
                    .child(
                        Button::new("submit-btn")
                            .primary()
                            .label("送信")
                            .on_click(cx.listener(|this, _event, window, cx| {
                                this.submit(window, cx);
                            })),
                    ),
            )
            .child(self.submitted_text.clone())
    }
}
}

さらに、InputState にはパスワード入力用の .masked(true) や、複数行のテキストエリアにする .multi_line()、検索アイコンを左端に付ける Input::new(&state).prefix(Icon::new(IconName::Search)) などの便利なメソッドも用意されています。

選択系ウィジェット(Checkbox・Switch・Radio・Select)

設定画面やフィルタ画面でよく使う選択系のウィジェットも、直感的に記述できます。

▸ 1. Checkbox(チェックボックス)と Switch(トグルスイッチ)

どちらも真偽値(bool)を渡して表示し、クリックされたときに新しい bool 値を受け取るステートレス部品です。

#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::{checkbox::Checkbox, h_flex, switch::Switch};

// ビューのフィールドに `agreed: bool` と `notifications_enabled: bool` がある場合の描画例
fn render_toggles(
    agreed: bool,
    notifications_enabled: bool,
    cx: &mut Context<MySettingsView>,
) -> impl IntoElement {
    h_flex()
        .gap_6()
        // チェックボックス
        .child(
            Checkbox::new("agree-check")
                .label("利用規約に同意する")
                .checked(agreed)
                .on_click(cx.listener(|this, checked: &bool, _window, cx| {
                    this.agreed = *checked;
                    cx.notify();
                })),
        )
        // トグルスイッチ
        .child(
            Switch::new("notify-switch")
                .label("デスクトップ通知を有効にする")
                .checked(notifications_enabled)
                .on_click(cx.listener(|this, checked: &bool, _window, cx| {
                    this.notifications_enabled = *checked;
                    cx.notify();
                })),
        )
}
}

▸ 2. Select(ドロップダウン選択メニュー)

複数の選択肢から 1 つを選ぶドロップダウンメニューは、テキスト入力と同様に状態を持つため SelectState エンティティと Select ウィジェットを組み合わせて使います。

#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::select::{Select, SelectEvent, SelectState};

// 初期化時(cx.new の中): 選択肢のリストと初期選択インデックス(Some(0) など)を渡す
let role_select = cx.new(|cx| {
    SelectState::new(
        vec!["管理者 (Admin)", "編集者 (Editor)", "閲覧者 (Viewer)"],
        Some(IndexPath::default()), // 最初の項目(0番目)を選択状態にする
        window,
        cx,
    )
});

// 選択が変更されたときのイベントを購読する
cx.subscribe_in(&role_select, window, |this, _state, event: &SelectEvent<Vec<&'static str>>, _window, cx| {
    if let SelectEvent::Confirm(selected_value) = event {
        println!("選択されたロール: {:?}", selected_value);
        cx.notify();
    }
})
.detach();

// render メソッド内での配置:
// .child(Select::new(&self.role_select))
}

モーダル・ドロワー・通知(Notification)

第 5 章でウィンドウ全体を Root コンポーネントで包んだおかげで、確認ダイアログ(Dialog / Modal)やトースト通知(Notification)は、特別な状態フィールドを作らなくても window の拡張メソッド(WindowExt)を呼ぶだけ でいつでも画面上に表示できます。

▸ 1. トースト通知(window.push_notification)

保存が完了したときやエラーが起きたときに、画面の隅に数秒間だけメッセージを表示して自動で消える通知です。

#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::{button::Button, notification::Notification, WindowExt as _};

Button::new("notify-btn")
    .label("保存テスト")
    .on_click(|_event, window, cx| {
        // 文字列を渡すだけのシンプルな通知、または Notification::success / error で種類を指定
        window.push_notification(
            Notification::success("ファイルが正常に保存されました。"),
            cx,
        );
    })
}

▸ 2. 確認ダイアログ・モーダル(window.open_dialog)

重要な操作の前にユーザーの意思を確認したり、別フォームを入力させたりするモーダルウィンドウです。

#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::{button::Button, WindowExt as _};

Button::new("confirm-delete-btn")
    .danger()
    .label("データを初期化する")
    .on_click(|_event, window, cx| {
        // ウィンドウの最前面にモーダルダイアログを開く
        window.open_dialog(cx, |dialog, _window, _cx| {
            dialog
                .title("本当に初期化しますか?")
                .child("この操作は取り消すことができません。すべてのタスクデータが削除されます。")
                .confirm() // 「OK」と「キャンセル」の標準ボタンを配置
                .on_ok(|_event, window, cx| {
                    window.push_notification("データを初期化しました", cx);
                    true // true を返すとダイアログが閉じる
                })
        });
    })
}

同様に、画面の左右や上下の端からスッと滑り出てくるスライドパネル(ドロワー)も、window.open_drawer(cx, |drawer, window, cx| { ... }) というまったく同じ書き方で呼び出すことができます。

これで、一般的な業務アプリやツールに必要な基本ウィジェットとフォーム制御が一通り揃いました。 次章では、GPUI と GPUI-Component の真骨頂である「数十万件のデータを一瞬で描画する仮想化リスト・データテーブル」と「画面を自在に分割する Dock システム」を解説します。