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

5. GPUI-Componentの導入とテーマ設計

GPUI-Componentの全体構成

第 1 章から第 4 章までで学んだように、GPUI 自体は「高速な GPU 描画と状態管理の土台」を提供するフレームワークです。 その土台の上に、現代的なデスクトップアプリケーションに必要な 70 種類以上の UI 部品とデザインシステムを構築したライブラリが GPUI-Component(プロジェクト名: GPUI Kit)です。

GPUI-Component に含まれるウィジェットは、第 3 章で学んだ GPUI の設計思想に沿って、大きく 2 つの種類 に分かれています。

分類実装されているトレイト代表的なウィジェット特徴・使い方
ステートレス部品RenderOnceButton, Icon, Badge, Tag, Checkbox, Switch, Avatar状態を自分で持たない。render メソッドの中で毎回 Button::new(...) のように作って .child() に直接渡す。
ステートフル部品Render + 専用 StateInput (InputState), Table (TableState), List (ListState), Select (SelectState)カーソル位置やスクロール位置などの複雑な状態を Entity<XxxState> として保持し、親ビューから参照を渡して描画する。

この「シンプルな部品はそのまま置くだけ、複雑な部品は State エンティティを作って渡すだけ」という一貫したルールがあるため、1 つの部品の使い方を覚えれば他の 70 種類以上の部品もすべて同じ感覚で扱うことができます。

初期化処理とRootラッパーの役割

GPUI-Component を使ってアプリケーションを作る際には、必ず守るべき 2 つの約束事 があります。

  1. アプリ起動時(app.run の先頭)に gpui_component::init(cx) を呼び出すこと。
  2. 各ウィンドウの一番外側のビューを Root::new(view.into(), window, cx) で包むこと。
graph TD
    subgraph Window["OS ウィンドウ"]
        subgraph Root["Root コンポーネント (gpui_component::Root)"]
            MainView["アプリ本来のメイン画面 (Your MainView)"]
            DialogLayer["モーダル・ダイアログ表示レイヤー"]
            DrawerLayer["ドロワー(スライドパネル)表示レイヤー"]
            ToastLayer["Notification(トースト通知)表示レイヤー"]
        end
    end

なぜ Root で包む必要があるのでしょうか。 それは、確認ダイアログ(Modal)や画面右上に浮かび上がる通知(Notification)、ドロップダウンメニューなどを表示するとき、メイン画面のレイアウトに影響を与えずに 「ウィンドウの一番手前のレイヤー」に重ねて描画する必要があるから です。

Root コンポーネントがウィンドウの最上位に居座ることで、アプリのどこからでも window.open_dialog(...) や window.push_notification(...) を呼び出すだけで、自動的に美しいオーバーレイ画面が表示される仕組みになっています。

▸ 標準的な起動ボイラープレート

GPUI-Component を使うアプリの main.rs の基本形は以下のようになります。

use gpui::*;
use gpui_component::*;

pub struct MyMainView;

impl Render for MyMainView {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        v_flex()
            .size_full()
            .p_6()
            .bg(cx.theme().background)
            .text_color(cx.theme().foreground)
            .child("GPUI-Component の基本ウィンドウ")
    }
}

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

    app.run(move |cx: &mut App| {
        // 約束1: 最初に必ず init(cx) を呼ぶ(テーマや組み込みショートカットが登録される)
        gpui_component::init(cx);

        let window_options = WindowOptions {
            window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
                None,
                size(px(800.0), px(600.0)),
                cx,
            ))),
            ..Default::default()
        };

        cx.open_window(window_options, |window, cx| {
            let main_view = cx.new(|_cx| MyMainView);

            // 約束2: メインビューを Root::new で包んで返す
            cx.new(|cx| Root::new(main_view.into(), window, cx))
        })
        .expect("ウィンドウを開けませんでした");
    });
}

レイアウト補助ヘルパー(h_flex / v_flex)

第 3 章では、要素を横や縦に並べるために div().flex().flex_row().items_center() や div().flex().flex_col() と書きました。 しかし、実際の画面開発ではこの記述を何十回、何百回と繰り返すことになります。

そこで GPUI-Component は、よく使う Flexbox コンテナを一発で作れる補助関数と、要素のサイズや状態を統一的に扱う拡張トレイトを提供しています。

▸ h_flex() と v_flex()

  • h_flex(): div().flex().flex_row().items_center() と同じです。要素を 横一列に並べ、上下中央に揃える コンテナを作ります(ツールバーやアイコン付きラベルに最適です)。
  • v_flex(): div().flex().flex_col() と同じです。要素を 上から下へ縦に並べる コンテナを作ります。
#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::{h_flex, v_flex, StyledExt as _};

fn render_header() -> impl IntoElement {
    // 縦並びのコンテナ
    v_flex()
        .gap_2()
        .p_4()
        // 横並び+上下中央揃えのコンテナ
        .child(
            h_flex()
                .justify_between()
                .child("タイトル")
                .child("右側メニュー"),
        )
}
}

▸ 共通ユーティリティトレイト(Sizable, Disableable, Selectable)

GPUI-Component のすべてのウィジェットは、共通のトレイトを実装しているため、どの部品に対しても同じメソッド名でサイズや状態を制御できます。

  • Sizable トレイト: .xsmall()、.small()、.medium()(既定)、.large()、または .with_size(Size::Small) で、ボタンも入力欄もスピナーも同じ基準でサイズを揃えられます。
  • Disableable トレイト: .disabled(true) を呼ぶだけで、ボタンや入力欄を操作不能(グレーアウト)にできます。
  • Selectable トレイト: .selected(true) で、タブやトグルボタンを選択状態の見た目に切り替えられます。

テーマシステム(ActiveTheme)の活用

アプリの色を指定するとき、rgb(0x1e293b) のように直接カラーコード(ハードコード)を書いてしまうと、後から「やっぱりダークモードに対応したい」「全体のアクセントカラーを青から緑に変えたい」となったときに、すべてのコードを書き直さなければなりません。

GPUI-Component には、Web フロントエンドで人気の shadcn/ui にインスパイアされたセマンティック(意味ベース)なテーマシステムが組み込まれています。 ActiveTheme トレイトをインポートすると、どのコンテキスト(cx)からでも cx.theme() を呼び出して、現在のテーマカラーや設定値を取得できます。

テーマトークン (cx.theme().xxx)意味・主な使いどころ
background / foregroundウィンドウ全体の基本背景色と、基本の文字色
secondary / secondary_foregroundカードやサイドバーなど、少し浮き出た領域の背景色と文字色
muted / muted_foreground控えめな背景色と、補足説明やプレースホルダーなどの薄い文字色
primary / primary_foregroundメインボタンや強調部分に使われるアクセントカラーと、その上の文字色
danger / success / warning削除・エラー(赤)、正常完了(緑)、注意(黄)を表す色
border / ring枠線の色と、フォーカスが当たったときの外枠リングの色
list_hover / list_activeリスト項目にマウスが乗ったとき、または選択されているときの背景色
radius / font_sizeアプリ全体で統一された角丸の大きさ(Pixels)と基本文字サイズ

実際に cx.theme() を使ってカード部品をスタイリングする例を見てみましょう。

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

fn render_themed_card(cx: &App) -> impl IntoElement {
    let theme = cx.theme();

    v_flex()
        .p_4()
        .gap_2()
        .bg(theme.secondary)             // セカンダリ背景色
        .text_color(theme.foreground)    // 基本文字色
        .border_1()
        .border_color(theme.border)      // テーマの枠線色
        .rounded(theme.radius)           // テーマ共通の角丸サイズ
        .child(
            div()
                .font_weight(FontWeight::BOLD)
                .child("カードのタイトル"),
        )
        .child(
            div()
                .text_sm()
                .text_color(theme.muted_foreground) // 補足用の薄い文字色
                .child("テーマトークンを使うと、ライト/ダークの両方で美しく表示されます。"),
        )
}
}

このように cx.theme() の色を使っておけば、テーマが切り替わった瞬間にアプリ中のすべての画面が自動的に新しい配色で再描画されます。

ダークモード対応とカスタムテーマの作成

GPUI-Component のテーマは、第 2 章で触れた GPUI の Global 状態として管理されています。 そのため、Theme::change() メソッドを 1 行呼ぶだけで、アプリ全体のライトモードとダークモードを即座に切り替えることができます。

▸ ライトモード/ダークモードの切り替え

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

fn render_theme_switcher(cx: &mut Context<MyMainView>) -> impl IntoElement {
    let is_dark = cx.theme().mode.is_dark();

    h_flex()
        .gap_2()
        .child(format!(
            "現在のモード: {}",
            if is_dark { "ダーク" } else { "ライト" }
        ))
        .child(
            Button::new("toggle-theme")
                .label(if is_dark {
                    "ライトモードへ切替"
                } else {
                    "ダークモードへ切替"
                })
                .on_click(|_event, window, cx| {
                    // 現在のモードを反転させてアプリ全体に適用する
                    let next_mode = if cx.theme().mode.is_dark() {
                        ThemeMode::Light
                    } else {
                        ThemeMode::Dark
                    };
                    Theme::change(next_mode, Some(window), cx);
                }),
        )
}
}

▸ テーマの細かなカスタマイズ

さらに、特定のアクセントカラーや角丸の大きさを自分好みに調整したい場合は、Theme::global_mut(cx) を使ってグローバルなテーマ設定を直接書き換えることができます。

#![allow(unused)]
fn main() {
// アプリ初期化時や設定変更時に、テーマのトークンをカスタマイズする
let theme = Theme::global_mut(cx);
theme.primary = hsla(220.0 / 360.0, 0.85, 0.55, 1.0); // ブランドカラーの青に変更
theme.radius = px(8.0);                               // 角丸を少し大きめ(8px)に統一
theme.font_size = px(15.0);                           // 基本フォントサイズを 15px に変更
}

また、GPUI-Component には JSON ファイルからカラーテーマ一覧を読み込む ThemeRegistry 機能も備わっており、Zed エディタのように「数十種類のカラーテーマの中からユーザーが好きなものを選ぶ」といった高度な機能も簡単に実装できます。

これで、GPUI-Component を動かすための土台とテーマ設計が整いました。 次章では、いよいよボタン、アイコン、テキスト入力、チェックボックス、モーダル、通知といった「基本ウィジェットとフォーム制御」の具体的な使い方を網羅していきます。