全体アウトライン
本書は、Rust 言語で高速かつネイティブな操作感を持つデスクトップアプリケーションを構築するための UI フレームワーク「GPUI」と、その上で動作する包括的なコンポーネントライブラリ「GPUI-Component(GPUI Kit)」の仕組みと実践的な開発手法を分かりやすく解説する技術書です。
本書の目的と対象読者
本書は、Rust の基本的な文法(構造体、トレイト、所有権など)を学んだことがあり、これから実用的な GUI デスクトップアプリケーションを作ってみたい開発者を対象としています。 「なぜ Rust の GUI 開発は難しいと言われてきたのか」「GPUI はどのようにして所有権の壁を越え、120 FPS の描画を実現しているのか」「GPUI-Component を使ってどのように業務レベルの画面を素早く組み立てるのか」という疑問に対し、図解と動作イメージが湧くコード例を交えて解説します。
章構成一覧
第1章 GPUIとGPUI-Componentの概要
高速コードエディタ「Zed」の心臓部として誕生した GPUI の設計思想と、Longbridge 社が開発した UI ライブラリ GPUI-Component(GPUI Kit)の役割を解説します。従来の Electron(Web 技術ベース)や他の Rust GUI フレームワークとの違いを比較し、開発環境のセットアップと最初のウィンドウ表示までを体験します。
第2章 コアアーキテクチャと状態管理
Rust で GUI を作るときに最大の壁となる「状態の共有と更新」を、GPUI がどのように解決しているかを学びます。アプリケーション全体が状態を所有する集中管理モデル、Entity<T> と Context<T> の関係、Render トレイトによる画面描画、そして変更通知(notify)と購読(subscribe)の仕組みを解き明かします。
第3章 レイアウトとスタイリング
GPUI における宣言的な UI 構築手法を学びます。レイアウトエンジン「Taffy」による Flexbox 配置と、Tailwind CSS のようなメソッドチェーン記法(.flex(), .gap_2(), .bg() など)を使った直感的なデザイン手法を整理します。また、コンポーネントを再利用しやすくする RenderOnce トレイトの使い分けも解説します。
第4章 イベント・アクション・非同期処理
ユーザーの操作に反応するインタラクティブな仕組みを扱います。マウスクリックなどのイベント処理、キーボードショートカットと論理コマンドを結びつける actions! マクロと FocusHandle、そして UI スレッドを止めずにファイル読み書きや通信を行う非同期タスク(cx.spawn)の安全な実装パターンを学びます。
第5章 GPUI-Componentの導入とテーマ設計
素の GPUI には用意されていない汎用ウィジェット群を提供する「GPUI-Component」の基礎を学びます。初期化関数 gpui_component::init とウィンドウ全体を包む Root コンポーネントの役割、ライトモード・ダークモードの切り替え、およびデザインシステム(Theme)のカスタマイズ方法を解説します。
第6章 基本ウィジェットとフォーム制御
日常的な画面開発で最もよく使う部品の使い方を習得します。ボタン(Button)、アイコン、バッジなどの表示部品から、日本語入力(IME)やカーソル制御を内蔵したテキスト入力(Input / InputState)、チェックボックス、スイッチ、セレクトボックス、モーダルダイアログや通知(トースト)の実装方法をコード例とともに確認します。
第7章 高度なUI(仮想化テーブル・リスト・Dock)
本格的かつ複雑なデスクトップアプリに欠かせない高度なコンポーネントを解説します。数十万件のデータでも一瞬でスクロールできる仮想化リスト(VirtualList)とデータテーブル(Table / TableDelegate)、そして IDE のように画面を自由なパネルやタブに分割・配置できる DockArea システムの構築手法を学びます。
第8章 実践:タスク管理デスクトップアプリの開発
これまでの知識を統合し、GPUI と GPUI-Component を組み合わせた実用的な「タスク管理デスクトップアプリケーション」を設計・実装します。サイドバー、検索・追加フォーム、ステータス切り替え、一覧表示、キーボードショートカット、テーマ切り替えを備えたアプリのコードを追いながら、実践的な設計パターンを身につけます。
第9章 パフォーマンス最適化とテスト戦略
120 FPS の滑らかな描画性能を維持するための設計上の注意点とテスト手法をまとめます。不要な再描画の防止、シェーダーや GPU 描画パイプラインの内部動作の理解、ヘッドレス環境での UI 自動テスト、そしてアクセシビリティ(AccessKit)対応や配布に向けたベストプラクティスを解説します。
1. GPUIとGPUI-Componentの概要
デスクトップGUI開発の現状と課題
現代のデスクトップアプリケーション開発では、「開発のしやすさ」と「実行時の軽快さ」をどのように両立させるかが長年の課題となっています。
これまで多くのデスクトップアプリ(VS Code、Slack、Discord など)では、Web ブラウザのエンジン(Chromium)を丸ごと同梱する「Electron」が広く使われてきました。 Web の技術(HTML、CSS、TypeScript)をそのまま使えるため開発効率は非常に高いのですが、アプリを 1 つ起動するだけで数百メガバイトのメモリを消費し、キー入力やスクロールにわずかな遅延(ラグ)が生じるという弱点がありました。
この問題を解決するため、OS 標準の WebView を使う「Tauri」や、Rust 言語だけで画面を描画する GUI フレームワーク(iced、egui、Slint、Dioxus など)が登場しました。 その中でも、「Web フロントエンドのような書きやすさ」と「3D ゲームのような 120 FPS の圧倒的な描画性能」を両立させた次世代のフレームワークとして注目を集めているのが GPUI です。
Zedから誕生した「GPUI」とは
GPUI(GPU-accelerated UI)は、超高速な次世代コードエディタ「Zed」を開発するために、Zed Industries 社(Atom や Electron、Tree-sitter の生みの親である Nathan Sobo 氏らが設立)がゼロから開発した Rust 製の UI フレームワークです。
従来の GUI ツールキットの多くは、OS が用意したボタンやテキストボックスを組み合わせたり、CPU で少しずつ画面を塗り直したりしていました。 それに対して GPUI は、ビデオゲームと同じように 画面上の文字や四角形、影、アイコンのすべてを GPU(グラフィックス処理装置)のシェーダーを使って一括で直接描画 します。
graph LR
subgraph 従来のWebベースUI
JS["JavaScript / DOM"] --> Layout1["ブラウザエンジン計算"]
Layout1 --> Comp["合成・描画(重い・メモリ大)"]
end
subgraph GPUIのアプローチ
Rust["Rust View / State"] --> Taffy["Taffy レイアウト計算"]
Taffy --> GPU["GPU 直接描画(Metal / DirectX / Vulkan)<br/>120 FPS・省メモリ"]
end
GPUI は OS ごとに最適なグラフィックス API(macOS では Metal、Windows では DirectX、Linux では Vulkan や Blade)を直接叩きます。 そのため、数万行のテキストファイルを高速にスクロールしたり、画面を何分割しても、フレームレートが落ちることなく瞬時に反応します。
ハイブリッド描画モードの特徴
GUI フレームワークの設計には、大きく分けて「Retained Mode(保持モード)」と「Immediate Mode(即時モード)」の 2 種類があります。
▸ Retained Mode(保持モード)
HTML の DOM や従来のデスクトップ UI のように、画面の部品(オブジェクト)をメモリ上にずっと保持し続ける方式です。 状態が変わったときに「どの部品のどの文字を書き換えるか」を管理しやすく、複雑なレイアウトに向いていますが、状態と画面の同期コードが複雑になりやすい欠点があります。
▸ Immediate Mode(即時モード)
ゲームのデバッグ画面(Dear ImGui や Rust の egui)のように、画面の部品を保持せず、毎フレームごとに最初からすべての画面を描き直す方式です。 コードがシンプルになる反面、複雑なレイアウト計算や OS のアクセシビリティ連携、フォーカス管理が難しくなる傾向があります。
GPUI は、この 2 つの良いとこ取りをした ハイブリッド描画アーキテクチャ を採用しています。
- 状態とイベントは Retained(保持): アプリケーションのデータ(
Entity)やフォーカス状態は GPUI がメモリ上でしっかりと保持・管理します。 - 見た目の構築は Immediate(宣言的): 状態が変化して再描画が必要になると、React や SwiftUI のように
renderメソッドが呼ばれ、その時点のデータから新しい画面要素のツリーを宣言的に組み立てます。
この設計により、開発者は「データがどう変わったら、どの画面部品をどう書き換えるか」という面倒な手順を考える必要がありません。 「いまのデータなら、こういう画面になるはずだ」という宣言を書くだけで、裏側の GPU パイプラインが高速に描画を完了させます。
なぜ「GPUI-Component」が必要なのか
GPUI は非常に強力なフレームワークですが、もともとは「Zed エディタを作るための専用エンジン」として生まれました。
そのため、単体の gpui クレート(ライブラリ)には、四角形(div)やテキスト、画像、スクロールといった 最も基礎的な部品(プリミティブ)しか入っていません。
いざ業務用のデスクトップアプリや管理ツールを作ろうとしても、一般的なボタン、日本語入力に対応したテキストボックス、ドロップダウンメニュー、カレンダー、大規模なデータテーブル、タブ分割可能なドック画面などをすべて一から自作するのは大変な労力がかかります。
そこで登場したのが、金融テクノロジー企業の Longbridge 社が自社のデスクトップ取引ツール(Longbridge Pro)の開発を通じて構築し、オープンソースとして公開した GPUI-Component(プロジェクト総称 GPUI Kit)です。
| 比較項目 | GPUI(単体) | GPUI + GPUI-Component |
|---|---|---|
| 開発元 | Zed Industries | Longbridge |
| 主な役割 | GPU 描画エンジン・状態管理・イベント基盤 | 実用的な UI ウィジェット群・テーマ管理 |
| 提供される部品 | div、テキスト、SVG、キャンバス等の基礎要素 | 70 種類以上の完成された UI コンポーネント |
| テキスト入力 | 自前でキー入力や IME 制御を書く必要がある | IME・コピペ・Undo 対応の Input をすぐ使える |
| 大量データ表示 | 低レベルな一様リストのみ | 仮想化された高機能な Table や List を完備 |
| 画面分割 | なし(自作が必要) | ドラッグ&ドロップ対応の DockArea を完備 |
GPUI を「高性能なエンジンの車台」とするなら、GPUI-Component は「快適なシート、ハンドル、エアコン、ナビが一式揃った内装キット」にあたります。 この 2 つを組み合わせることで、Rust でありながら Web フロントエンド(React + shadcn/ui や Tailwind CSS)と同じくらい素早く、かつネイティブならではの圧倒的な速度を持つデスクトップアプリを作ることができます。
開発環境の準備と最小プロジェクトの作成
それでは、手元のコンピュータで GPUI と GPUI-Component を使った最小限のアプリケーションを動かす準備をしましょう。
▸ OS ごとの事前準備
Rust のコンパイラ(rustup)がインストールされていることを前提とします。
GPUI は OS のグラフィックス機能やウィンドウ機能を直接利用するため、OS ごとに以下の開発ツールが必要です。
- Windows: Visual Studio C++ Build Tools および Windows 10/11 SDK が必要です。
- macOS: Xcode Command Line Tools(
xcode-select --install)が必要です。 - Linux: Vulkan ドライバや Wayland / X11 の開発用ヘッダライブラリ(
libxcb、libxkbcommon、libwayland-devなど)が必要です。
▸ Cargo.toml の設定
まず cargo new hello-gpui で新しいプロジェクトを作成し、Cargo.toml に gpui と gpui-component を追加します。
[package]
name = "hello-gpui"
version = "0.1.0"
edition = "2021"
[dependencies]
gpui = "0.2"
gpui-component = "0.5"
▸ 最初のウィンドウを表示するコード(src/main.rs)
次に、src/main.rs を以下のように記述します。
これは GPUI-Component を使って画面中央にメッセージとボタンを 1 つ表示する最小のプログラムです。
use gpui::*;
use gpui_component::{
button::{Button, ButtonVariants as _},
v_flex, ActiveTheme as _, Root,
};
// 1. 画面を表す構造体を定義する
pub struct HelloView;
// 2. Renderトレイトを実装して見た目を組み立てる
impl Render for HelloView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.gap_4()
.bg(cx.theme().background)
.text_color(cx.theme().foreground)
.child("Hello, GPUI & GPUI-Component!")
.child(
Button::new("greet-btn")
.primary()
.label("クリックしてください")
.on_click(|_event, _window, _cx| {
println!("ボタンが押されました!");
}),
)
}
}
// 3. アプリケーションを起動してウィンドウを開く
fn main() {
let app = Application::new();
app.run(move |cx: &mut App| {
// GPUI-Componentのテーマやグローバル設定を初期化する
gpui_component::init(cx);
// ウィンドウを開く
cx.open_window(WindowOptions::default(), |window, cx| {
// 自分たちのビュー(HelloView)を作成する
let view = cx.new(|_cx| HelloView);
// GPUI-ComponentのRootで包んでウィンドウのルートビューにする
cx.new(|cx| Root::new(view.into(), window, cx))
})
.expect("ウィンドウの起動に失敗しました");
});
}
cargo run を実行すると、ネイティブウィンドウが立ち上がり、テーマに沿った美しいボタンが表示されます。
次章では、このコードの中に登場した App、Context、cx.new、Render といった GPUI の最も重要な「コアアーキテクチャと状態管理の仕組み」を詳しく解き明かしていきます。
2. コアアーキテクチャと状態管理
RustにおけるGUI状態管理の難しさ
Rust でコマンドラインツールや Web サーバーを作るときは、データの受け渡しが比較的シンプルです。 入力を受け取り、処理をして、結果を返せば処理が終わるため、データの「所有権(Ownership)」がどこにあるかを一直線に追跡できます。
しかし、GUI アプリケーションでは状況が一変します。 たとえば、「画面上部のヘッダー」「左側のサイドバー」「中央の編集画面」という 3 つの部品が、同じ「ユーザー設定」や「タスクリスト」のデータを同時に表示し、しかもどの部品からでもデータを書き換えられるようにしたい場面は日常茶飯事です。
[ヘッダー] ───┐
[サイドバー] ─┼──> 同じ「タスクリスト」を読み書きしたい!
[メイン画面] ─┘
これを素の Rust でそのまま書こうとすると、コンパイラの借用チェッカー(Borrow Checker)に阻まれます。
Rc<RefCell<T>> や Arc<Mutex<T>> を大量に組み合わせる方法もありますが、コードが .borrow_mut() だらけになって読みづらくなるうえ、実行時に二重借用でパニック(クラッシュ)したり、循環参照でメモリリークを起こしたりする危険がつきまといます。
GPUI は、この Rust 特有の課題を 「アプリケーション全体による状態の集中所有」 という鮮やかなアーキテクチャで解決しています。
Appによる状態の集中所有モデル
GPUI における最大のルールは、「画面の部品やモデルのデータ本体は、すべて App(アプリケーションコンテキスト)が 1 カ所でまとめて所有する」 というものです。
開発者が作った構造体のインスタンス(実体)は、App の内部にある巨大な保管庫(Entity マップ)に登録されます。
そして、他の構造体やイベント処理には、データ本体ではなく 「保管庫の何番に入っているかを示す軽量な引換券(ハンドル)」 だけを渡します。
graph TD
subgraph App["App(アプリケーション全体の保管庫)"]
Store["Entity Storage<br/>・ID 1: Counter { count: 0 }<br/>・ID 2: UserProfile { name: 'Alice' }<br/>・ID 3: MainView { ... }"]
end
Handle1["Entity<Counter><br/>(ID 1 の引換券)"] -.->|cx を通じてアクセス| Store
Handle2["Entity<UserProfile><br/>(ID 2 の引換券)"] -.->|cx を通じてアクセス| Store
この「引換券」は単なる ID 番号のラッパーなので、何回 .clone() してコピーしてもコストはほぼゼロです。
引換券自体にはライフタイム('a)の制約がないため、どの構造体にも自由に持たせることができます。
そして、「引換券を使って中身のデータを読んだり書き換えたりするときだけ、必ず cx(コンテキスト)という窓口を通す」というルールにすることで、Rust の安全性を保ったまま自由なデータ共有を実現しています。
EntityとContextの仕組み
GPUI のコードを読むと、必ず Entity<T> と Context<T>(変数名は慣習的に cx)が登場します。
この 2 つの役割を整理しましょう。
▸ Entity<T>(状態のハンドル)
App の保管庫に登録された T 型のデータを指すスマートポインタ(引換券)です。
以前のバージョンの GPUI では用途によって Model<T> と View<T> に分かれていましたが、現在の GPUI ではすべて Entity<T> に統一されています。
Entity<T> は参照カウント方式で管理されており、どこからも参照されなくなると自動的にメモリから解放されます。
▸ WeakEntity<T>(弱参照ハンドル)
参照カウントを増やさない引換券です。
親子関係でお互いに Entity を持ち合うと循環参照になってメモリが解放されなくなるため、子から親を指すときや、非同期処理(cx.spawn)の中に持ち込むときは entity.downgrade() で取得した WeakEntity<T> を使います。
▸ App と Context<T>(操作の窓口)
App: アプリケーション全体を表す最上位のコンテキストです。Context<T>: 特定のEntity<T>に紐づいたコンテキストです。Appのすべての機能に加えて、「自分自身の再描画を通知する(cx.notify())」「自分自身のイベントを発行する(cx.emit())」といった専用メソッドを持っています。
▸ データの作成・読み取り・更新の3ステップ
Entity<T> を扱う基本操作は、「作成(new)」「読み取り(read)」「更新(update)」の 3 つだけです。
#![allow(unused)]
fn main() {
use gpui::*;
// 管理したいデータの構造体
pub struct Counter {
pub count: i32,
}
fn example(cx: &mut App) {
// 1. 作成(cx.new): 保管庫に Counter を登録し、引換券 Entity<Counter> を受け取る
let counter: Entity<Counter> = cx.new(|_cx| Counter { count: 0 });
// 2. 読み取り(counter.read): 引換券と cx を使って中身を安全に参照する
let current = counter.read(cx).count;
println!("現在のカウント: {}", current);
// 3. 更新(counter.update): クロージャの中で &mut Counter と &mut Context<Counter> を受け取って書き換える
counter.update(cx, |state: &mut Counter, cx: &mut Context<Counter>| {
state.count += 10;
// 状態が変わったことをGPUIに知らせる
cx.notify();
});
}
}
update メソッドを使うときだけ一時的に可変参照(&mut Counter)が取り出され、クロージャを抜けるとすぐに返却されます。
そのため、アプリのどこからでも競合を起こさずに状態を更新できます。
RenderトレイトとViewの役割
GPUI において、単なるデータのかたまり(モデル)と、画面に表示される部品(ビュー)の違いはたった 1 つです。
それは 「Render トレイトを実装しているかどうか」 です。
ある構造体 MyView に Render トレイトを実装すると、そのハンドルである Entity<MyView> はそのまま画面の要素(IntoElement)として親の画面に配置できるようになります。
#![allow(unused)]
fn main() {
use gpui::*;
pub struct CounterView {
count: i32,
}
impl Render for CounterView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.gap_2()
.p_4()
.child(format!("カウント値: {}", self.count))
.child(
div()
.id("add-btn")
.px_3()
.py_1()
.bg(rgb(0x2563eb))
.text_color(rgb(0xffffff))
.child("+1 増やす")
.on_click(cx.listener(|this: &mut Self, _event, _window, cx| {
this.count += 1;
cx.notify(); // 画面の再描画をリクエスト
})),
)
}
}
}
render メソッドの引数には以下の 3 つが渡されます。
&mut self: 自分自身の構造体への可変参照です。フィールドの値を読み取って画面に反映します。window: &mut Window: 描画対象のウィンドウへの参照です。ウィンドウのサイズを取得したり、フォーカスを制御したりするときに使います。cx: &mut Context<Self>: このビュー専用のコンテキストです。子ビューの状態を読み取ったり、イベントリスナーを登録したりするときに使います。
また、親のビューが子のビュー(Entity<ChildView>)をフィールドとして持っている場合、親の render メソッドの中で .child(self.child_view.clone()) と書くだけで、子の画面を好きな場所に埋め込むことができます。
状態変更の通知とリアクティブ更新
複数のビューやデータモデルを組み合わせてアプリを作る場合、「データモデル側が更新されたら、それを表示しているビューも自動的に再描画してほしい」という場面が出てきます。 GPUI には、そのための仕組みとして Observe(監視) と Subscribe(イベント購読) の 2 つが用意されています。
sequenceDiagram
participant Model as データ (Entity<Cart>)
participant GPUI as GPUI ランタイム
participant View as 画面 (Entity<HeaderView>)
Note over View,Model: 初期化時に cx.observe(&cart, ...) を登録
Model->>GPUI: 1. cx.notify() または cx.emit(Event)
GPUI->>View: 2. 登録されたコールバックを実行
View->>GPUI: 3. 自分自身の cx.notify() を呼ぶ
GPUI->>View: 4. 次のフレームで render() を再実行
▸ 1. cx.observe(状態変化の監視)
監視対象の Entity の中で cx.notify() が呼ばれたときに、自動的にコールバック関数を実行する仕組みです。
「対象がとにかく何か更新されたら、自分の画面も描き直したい」という場合は cx.observe を使います。
#![allow(unused)]
fn main() {
// 親ビューの初期化時(cx.new の中など)で設定する
let cart = cx.new(|_| ShoppingCart { items: vec![] });
cx.observe(&cart, |this: &mut HeaderView, _cart_entity, cx| {
// カートの中身が変わって cart 側で cx.notify() が呼ばれたら、ここが実行される
cx.notify(); // HeaderView 自身も再描画を予約する
})
.detach();
}
▸ 2. EventEmitter と cx.subscribe(型付きイベントの購読)
単なる「何かが変わった」だけでなく、「具体的にどんな出来事が起きたか(たとえば『アイテムが削除された』『保存が完了した』など)」というデータを添えて通知したい場合は、EventEmitter トレイトと cx.subscribe を使います。
#![allow(unused)]
fn main() {
use gpui::*;
// 1. 発行したいイベントの型を定義する
pub enum CartEvent {
ItemAdded(String),
Cleared,
}
pub struct ShoppingCart {
pub items: Vec<String>,
}
// 2. ShoppingCart が CartEvent を発行できることを宣言する
impl EventEmitter<CartEvent> for ShoppingCart {}
impl ShoppingCart {
pub fn add_item(&mut self, name: String, cx: &mut Context<Self>) {
self.items.push(name.clone());
// イベントを発行する
cx.emit(CartEvent::ItemAdded(name));
cx.notify();
}
}
}
購読する側のビューでは、cx.subscribe を使ってこの CartEvent を受け取ります。
#![allow(unused)]
fn main() {
// 別のビューの初期化時
cx.subscribe(&cart, |this: &mut MainView, _cart, event: &CartEvent, cx| {
match event {
CartEvent::ItemAdded(name) => {
this.last_message = format!("「{}」が追加されました", name);
}
CartEvent::Cleared => {
this.last_message = "カートが空になりました".to_string();
}
}
cx.notify();
})
.detach();
}
なお、cx.observe や cx.subscribe は戻り値として Subscription(購読チケット)を返します。
購読元のビューが生きている限りずっと監視を続けたい場合は末尾で .detach() を呼び、途中で監視を止めたい場合やビューと一緒に確実に破棄したい場合は、ビューの構造体のフィールド(Vec<Subscription> など)に保存しておきます。
▸ グローバル状態(Global)
アプリ全体でたった 1 つしか存在しない設定情報やテーマ、共有サービスなどは、Global トレイトを実装することで、cx.set_global(MyConfig { ... }) で登録し、どこからでも cx.global::<MyConfig>() で手軽に読み取ることができます。
第 5 章で学ぶ GPUI-Component のテーマ機能(cx.theme())も、この Global の仕組みを使って実装されています。
これで、GPUI の心臓部である「状態管理と再描画のサイクル」が分かりました。 次章では、画面の見た目を自在にデザインするための「レイアウトとスタイリング」の手法を詳しく見ていきます。
3. レイアウトとスタイリング
宣言的UIツリーとElementの仕組み
GPUI で画面を作るときは、HTML で <div> タグの中に文字や別の <div> を入れていくのと同じように、関数とメソッドチェーンを組み合わせて 要素(Element)のツリー構造 を組み立てます。
このとき中心となるのが ParentElement トレイトと IntoElement トレイトです。
div(): 最も基本的な箱(コンテナ)を作る関数です。HTML の<div>に相当します。.child(...): 1 つの子要素を箱の中に追加します。.children(...): ベクタ(Vec)やイテレータを使って、複数の子要素をまとめて箱の中に追加します。
#![allow(unused)]
fn main() {
use gpui::*;
fn build_card() -> impl IntoElement {
let tags = vec!["Rust", "GPUI", "Desktop"];
div()
.p_4()
.child("カードタイトル") // 文字列(&str や String)はそのまま子要素になる
.child(
div()
.flex()
.gap_2()
// イテレータで複数の子要素をまとめて追加する
.children(tags.into_iter().map(|tag| {
div().px_2().py_1().bg(rgb(0xe2e8f0)).child(tag)
})),
)
}
}
文字列(&'static str、String、SharedString)や div()、SVG 画像、さらには前章で学んだ子ビュー(Entity<MyView>)まで、画面に表示できるものはすべて IntoElement トレイトを実装しているため、.child() にそのまま渡すことができます。
▸ 文字列を高速に扱う SharedString
GUI では同じラベル文字列を毎フレーム何度も受け渡します。
毎回 String::clone() でメモリのコピーが発生するのを防ぐため、GPUI では文字列リテラル(&'static str)と参照カウント文字列(Arc<str>)を統合した SharedString 型が広く使われます。
"テキスト".into() と書くだけでコストゼロで変換できるため、カスタム部品のフィールドには SharedString を使うのが定石です。
TaffyによるFlexboxレイアウト
GPUI のレイアウト計算エンジンには、Rust 製の高速なライブラリ Taffy が組み込まれています。 Web 開発(CSS)で標準的に使われている Flexbox(フレックスボックス) のルールがそのまま動くため、Web フロントエンドの経験があれば迷うことなく配置を決められます。
+---------------------------------------------------+
| .flex().flex_col().gap_4().p_4() |
| +---------------------------------------------+ |
| | ヘッダー領域 (.h(px(48.0))) | |
| +---------------------------------------------+ |
| +---------------------------------------------+ |
| | メイン領域 (.flex().flex_row().flex_1()) | |
| | +---------------+ +--------------------+ | |
| | | サイドバー | | コンテンツ | | |
| | | (.w(px(200.)))| | (.flex_1()) | | |
| | +---------------+ +--------------------+ | |
| +---------------------------------------------+ |
+---------------------------------------------------+
よく使う Flexbox のメソッドは以下のとおりです。
| メソッド | CSS での相当指定 | 役割・動作 |
|---|---|---|
.flex() | display: flex | Flexbox レイアウトを有効にする |
.flex_row() | flex-direction: row | 子要素を 横方向(左から右) に並べる(既定) |
.flex_col() | flex-direction: column | 子要素を 縦方向(上から下) に並べる |
.flex_1() | flex: 1 1 0% | 親の余っているスペースいっぱいに伸縮する |
.flex_shrink_0() | flex-shrink: 0 | スペースが足りなくても縮まないように固定する |
.items_center() | align-items: center | 交差軸(横並びなら上下方向)の中央に揃える |
.justify_between() | justify-content: space-between | 主軸の両端に寄せ、均等に間隔を空ける |
.justify_center() | justify-content: center | 主軸の中央に集める |
.gap_2() / .gap(px(8.)) | gap: 0.5rem / 8px | 並んでいる子要素どうしの隙間を一括で空ける |
また、親要素からはみ出た部分を隠す .overflow_hidden() や、絶対配置を行う .relative() と .absolute().top_0().right_0() なども CSS とまったく同じ感覚で使えます。
Tailwind風のメソッドチェーン記法
GPUI では Styled トレイトによって、Tailwind CSS のユーティリティクラスをそのまま Rust のメソッドにしたようなデザイン記法が提供されています。
CSS ファイルを別個に管理する必要がなく、Rust の型チェックと入力補完(オートコンプリート)を効かせながら見た目を整えられます。
▸ 1. サイズと単位(px, rems, relative)
長さを指定するときは、専用の単位関数を使います。
px(120.0): ピクセル単位(論理ピクセル、高解像度ディスプレイでも自動スケーリングされます)。rems(1.5): ルートフォントサイズに対する相対単位です。relative(0.5): 親要素のサイズに対する割合(0.5なら50%)です。.size_full()/.w_full()/.h_full(): 幅や高さを親要素の100%に広げます。
▸ 2. 余白(Padding と Margin)
Tailwind CSS と同じく、数値ステップ(1 = 0.25rem 相当)または直接ピクセル値で余白を指定します。
.p_4(): 上下左右すべてにパディングを入れる.px_3()/.py_2(): 左右(X 軸)のみ、上下(Y 軸)のみにパディングを入れる.pt_2()/.pb_2()/.pl_4()/.pr_4(): 上・下・左・右の個別に指定する.m_2()/.mx_auto()/.mt_4(): 外側の余白(マージン)を指定する
▸ 3. 色(Color)と背景・枠線・角丸・影
色の指定には rgb(0xRRGGBB)、rgba(0xRRGGBBAA)、または hsla(h, s, l, a) 関数を使います。
#![allow(unused)]
fn main() {
div()
.w(px(300.0))
.p_4()
.bg(rgb(0x1e293b)) // 背景色(ダークブルーグレー)
.text_color(rgb(0xf8fafc)) // 文字色(オフホワイト)
.text_sm() // 文字サイズ(小さめ)
.font_weight(FontWeight::BOLD) // 太字
.border_1() // 1px の枠線を有効化
.border_color(rgb(0x334155))// 枠線の色
.rounded_lg() // 角丸(大きめ)
.shadow_md() // ドロップシャドウ(中くらいの影)
.child("スタイリッシュなボックス")
}
注意点として、枠線を表示したいときは .border_color(...) だけでなく、必ず太さを指定する .border_1()(または .border_2()、下線のみの .border_b_1() など)をセットで呼び出す必要があります。
条件付きスタイルとインタラクティブ状態
「マウスが乗ったときに背景を明るくしたい」「選択されているときだけ青い枠線をつけたい」といった動的な変化も、メソッドチェーンを途切れさせずに書くことができます。
▸ ホバー(.hover)とアクティブ(.active)
インタラクティブな状態変化を使うには、まず要素に .id("一意な名前") を付けて Stateful<Div> に変換します。
すると、.hover() や .active() メソッドが使えるようになります。
#![allow(unused)]
fn main() {
div()
.id("menu-item-1") // インタラクティブ要素には ID が必要
.px_3()
.py_2()
.rounded_md()
.cursor_pointer() // マウスカーソルを指マークにする
.bg(rgb(0x1e293b))
.hover(|style| style.bg(rgb(0x334155))) // マウスが乗ったときの色
.active(|style| style.bg(rgb(0x0f172a))) // クリックしている最中の色
.child("メニュー項目")
}
▸ 条件分岐ヘルパー(.when と .when_some)
Rust の if 文を使うために一度変数に代入すると、メソッドチェーンが途切れて読みづらくなります。
GPUI の FluentBuilder トレイト(gpui::prelude::* に含まれます)には、チェーンの中で条件分岐を行う .when() と .when_some() が用意されています。
#![allow(unused)]
fn main() {
use gpui::prelude::*;
use gpui::*;
fn render_item(is_selected: bool, badge_text: Option<String>) -> impl IntoElement {
div()
.p_3()
.rounded_md()
.bg(rgb(0xffffff))
// 1. is_selected が true のときだけスタイルを追加する
.when(is_selected, |this| {
this.bg(rgb(0xeff6ff))
.border_1()
.border_color(rgb(0x3b82f6))
})
.child("項目タイトル")
// 2. Option が Some(val) のときだけ中身を取り出して子要素を追加する
.when_some(badge_text, |this, text| {
this.child(
div().px_2().bg(rgb(0xef4444)).text_color(white()).child(text),
)
})
}
}
この .when() パターンを使うことで、条件に応じたスタイルの切り替えや子要素の出し分けを上から下へ自然に読むことができます。
ステートレスな部品を作るRenderOnce
第 2 章では、独自の内部状態を持つ部品として Render トレイト(Entity<T>)を学びました。
しかし、アプリを作っていると「バッジ」「見出し付きカード」「ステータスアイコン」のように、自分自身の状態を持たず、親から渡されたプロパティ(引数)を表示するだけの再利用パーツ をたくさん作りたくなります。
そのような毎回使い捨てできる軽量な部品を作るために用意されているのが RenderOnce トレイト と #[derive(IntoElement)] マクロ です。
#![allow(unused)]
fn main() {
use gpui::*;
// 1. #[derive(IntoElement)] を付けると、そのまま .child() に渡せるようになる
#[derive(IntoElement)]
pub struct StatusBadge {
label: SharedString,
is_online: bool,
}
impl StatusBadge {
pub fn new(label: impl Into<SharedString>, is_online: bool) -> Self {
Self {
label: label.into(),
is_online,
}
}
}
// 2. RenderOnce を実装する(第1引数が &mut self ではなく self である点に注目!)
impl RenderOnce for StatusBadge {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let dot_color = if self.is_online {
rgb(0x22c55e) // 緑
} else {
rgb(0x94a3b8) // グレー
};
div()
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.rounded_full()
.bg(rgb(0xf1f5f9))
.child(div().size(px(8.0)).rounded_full().bg(dot_color))
.child(self.label)
}
}
}
これを使う側は、cx.new() で保管庫に登録する必要すらありません。
親の render メソッドの中で .child(StatusBadge::new("稼働中", true)) と書くだけで、標準の div() と同じように軽快に配置できます。
| トレイト | 第1引数 | 主な用途 | 使い方 |
|---|---|---|---|
Render | &mut self | 自身の状態を持ち、イベントで変化する画面・領域 | cx.new(|cx| MyView::new(cx)) で Entity として保持 |
RenderOnce | self(消費) | 親から値を受け取って描画するだけの軽量な UI 部品 | MyWidget::new() を直接 .child() に渡す |
この Render と RenderOnce の使い分けは、第 5 章以降で扱う GPUI-Component の設計でもそのまま使われている重要な基本パターンです。
次章では、作成した画面に命を吹き込む「イベント・アクション・非同期処理」の仕組みを学びます。
4. イベント・アクション・非同期処理
マウスイベントとリスナーの登録
画面の部品がクリックされたり、マウスが動かされたりしたときに処理を実行するには、要素に対してイベントハンドラを登録します。
このとき最もよく使うのが、コンテキストが提供する cx.listener() メソッドです。
普通のクロージャでは、イベントが起きたときに「どのビューの状態を書き換えればよいか」を取り出すのが面倒ですが、cx.listener() で包むと、第 1 引数として自分自身の構造体(&mut Self)と、最後の引数として専用コンテキスト(&mut Context<Self>)を自動的に渡してくれます。
#![allow(unused)]
fn main() {
use gpui::*;
pub struct ClickDemo {
click_count: usize,
}
impl Render for ClickDemo {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.id("clickable-box") // クリックを受け取る要素には .id() が必要
.p_4()
.bg(rgb(0x2563eb))
.text_color(white())
.cursor_pointer()
.child(format!("クリック回数: {}", self.click_count))
// 左クリック(押して離したとき)のイベント
.on_click(cx.listener(|this: &mut Self, event: &ClickEvent, _window, cx| {
this.click_count += 1;
println!("クリック位置: {:?}", event.position());
cx.notify();
}))
// マウスの右ボタンが押された瞬間のイベント
.on_mouse_down(
MouseButton::Right,
cx.listener(|this: &mut Self, _event: &MouseDownEvent, _window, cx| {
this.click_count = 0; // 右クリックでリセット
cx.notify();
}),
)
}
}
}
主なマウスイベント用メソッドは以下のとおりです。
.on_click(...): 要素がクリックされたとき(.id(...)の指定が必須です)。.on_mouse_down(MouseButton, ...): 指定したマウスボタン(Left,Right,Middle)が押し込まれた瞬間。.on_mouse_up(MouseButton, ...): マウスボタンが離された瞬間。.on_mouse_move(...): 要素の上でマウスカーソルが移動したとき。.on_scroll_wheel(...): マウスホイールやトラックパッドでスクロールされたとき。
また、イベントハンドラの中で cx.stop_propagation() を呼ぶと、そのイベントが親要素へ伝わっていく(バブリングする)のを止めることができます。
キーボードフォーカスとFocusHandle
デスクトップアプリにおいて、キーボード操作はとても重要です。
今どの部品がキーボードの入力を受け付けているか(フォーカスを持っているか)を管理するために、GPUI では FocusHandle を使います。
キーボード操作を受け取りたいビューは、自身のフィールドに FocusHandle を 1 つ保持し、Focusable トレイトを実装します。
#![allow(unused)]
fn main() {
use gpui::*;
pub struct EditorView {
focus_handle: FocusHandle,
last_key: String,
}
impl EditorView {
pub fn new(cx: &mut Context<Self>) -> Self {
Self {
// 新しい FocusHandle を発行して保持する
focus_handle: cx.focus_handle(),
last_key: "まだキーが押されていません".to_string(),
}
}
}
// Focusable トレイトを実装する
impl Focusable for EditorView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for EditorView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_focused = self.focus_handle.is_focused(window);
div()
// 1. この要素に FocusHandle を紐づける
.track_focus(&self.focus_handle)
// 2. キーが押されたときのイベントを受け取る
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
this.last_key = format!("押されたキー: {}", event.keystroke.key);
cx.notify();
}))
.p_4()
.border_2()
.border_color(if is_focused { rgb(0x3b82f6) } else { rgb(0x94a3b8) })
.child(self.last_key.clone())
}
}
}
プログラムから特定のビューにフォーカスを当てたいときは、window.focus(&self.focus_handle)(または self.focus_handle.focus(window))を呼び出します。
また、div().track_focus(&self.focus_handle) を設定しておくと、その要素がマウスでクリックされたときに自動的にフォーカスが当たるようになります。
アクション(Action)とショートカットキー
小さなアプリなら .on_key_down() で直接キーの名前を調べても動きますが、本格的なデスクトップアプリでは「メニューバーから『保存』を選んだとき」も「ショートカットキー Ctrl+S(macOS なら Cmd+S)を押したとき」も、同じ処理を呼び出したいはずです。
さらに、ユーザーが自分でショートカットキーの設定を変更できるようにしたい場合もあります。
そこで GPUI は、物理的なキー入力と実際の処理の間に 「Action(論理コマンド)」 という仕組みを挟みます。
graph LR
Key["キー入力<br/>(ctrl-s / cmd-s)"] -->|KeyBinding| Action["Action<br/>(SaveDocument)"]
Menu["メニュー・ボタン<br/>(window.dispatch_action)"] --> Action
Action -->|フォーカス階層を遡る| Handler["ビューの .on_action()<br/>で処理を実行"]
アクションの定義から実行までは、以下の 3 ステップで行います。
▸ 1. actions! マクロでコマンドを定義する
引数を持たないシンプルなコマンドは、actions! マクロで一気に定義できます。
#![allow(unused)]
fn main() {
use gpui::*;
// "task_app" という名前空間に 3 つのアクションを定義する
actions!(task_app, [AddTask, DeleteSelected, ClearAll]);
}
もしアクションにデータ(たとえば検索ワードや ID など)を持たせたい場合は、#[derive(Clone, PartialEq, Action)](または JSON デシリアライズ対応)を付けた通常の構造体として定義することもできます。
▸ 2. KeyBinding でショートカットキーを登録する
アプリの初期化時(app.run の中など)に、どのキーとどのアクションを結びつけるかを cx.bind_keys で登録します。
#![allow(unused)]
fn main() {
// Secondary-n は、macOS では Cmd+N、Windows/Linux では Ctrl+N として扱われる便利な指定です
cx.bind_keys([
KeyBinding::new("secondary-n", AddTask, None),
KeyBinding::new("backspace", DeleteSelected, Some("TaskList")),
]);
}
第 3 引数の Some("TaskList") は キーコンテキスト(KeyContext) です。
これを指定すると、.key_context("TaskList") が設定された画面領域にフォーカスがあるときだけ、そのショートカットが有効になります。
▸ 3. .on_action() で処理を受け取る
最後に、ビューの render メソッド内で .on_action() を登録します。
#![allow(unused)]
fn main() {
div()
.key_context("TaskList")
.track_focus(&self.focus_handle)
.on_action(cx.listener(|this: &mut Self, _: &AddTask, _window, cx| {
this.create_new_task(cx);
}))
.on_action(cx.listener(|this: &mut Self, _: &DeleteSelected, _window, cx| {
this.delete_current_task(cx);
}))
}
これにより、現在フォーカスされている要素(またはその親要素)に向かってアクションが飛び、対応するメソッドがきれいに呼び出されます。
非同期ランタイムとcx.spawn
デスクトップアプリで Web API からデータを取得したり、大きなファイルを読み込んだりするとき、メインスレッド(UI スレッド)でそのまま処理を待つと、処理が終わるまでウィンドウが固まって(フリーズして)しまいます。
GPUI は独自の軽量な非同期ランタイムを内蔵しており、すべてのコンテキストから cx.spawn() を使って非同期処理(async ブロック)を起動できます。
#![allow(unused)]
fn main() {
use gpui::*;
use std::time::Duration;
pub struct DataFetcher {
status: String,
is_loading: bool,
}
impl DataFetcher {
pub fn start_fetch(&mut self, cx: &mut Context<Self>) {
self.is_loading = true;
self.status = "データを取得中...".to_string();
cx.notify();
// cx.spawn で非同期タスクを開始する
// 第1引数 this は WeakEntity<Self>、第2引数 cx は &mut AsyncApp として渡される
cx.spawn(async move |this: WeakEntity<Self>, cx: &mut AsyncApp| {
// 例として 2 秒間タイマーで待機する(非同期処理のシミュレーション)
cx.background_executor()
.timer(Duration::from_secs(2))
.await;
// 非同期処理が終わったら、this.update() でメインスレッドのビューを安全に更新する
let _ = this.update(cx, |this: &mut Self, cx: &mut Context<Self>| {
this.is_loading = false;
this.status = "データの取得が完了しました!".to_string();
cx.notify();
});
})
.detach(); // タスクをバックグラウンドで自走させる
}
}
}
ここで注目してほしいポイントが 2 つあります。
WeakEntity<Self>による安全なアクセス: 通信に 2 秒かかっている間に、ユーザーがその画面(ビュー)を閉じてしまうかもしれません。cx.spawnが渡してくれるthisは弱参照(WeakEntity)になっているため、もし画面がすでに閉じられて破棄されていた場合は、this.update(...)が単にErrを返して何もしないため、クラッシュやメモリリークが絶対に起きません。Task<T>の扱い(.detach()またはフィールド保持):cx.spawnは戻り値としてTask<R>を返します。 戻り値のTaskをそのまま捨ててしまうと、その瞬間に非同期処理がキャンセルされてしまいます。 最後まで走らせたい場合は.detach()を呼ぶか、あるいはビューの構造体にcurrent_task: Option<Task<()>>のようなフィールドを作ってそこに代入しておきます(フィールドに保存しておけば、ビューが破棄されたときに非同期タスクも自動でキャンセルされるため非常に安全です)。
バックグラウンドスレッドでの並列計算
cx.spawn() 自体は、メインスレッド上で非同期タスクの待ち合わせ(I/O 待ちなど)を行う仕組みです。
もし「数万件の JSON データを解析する」「画像の圧縮計算をする」といった CPU をフル回転させる重い計算 をそのまま cx.spawn の中に書くと、やはりメインスレッドの描画を邪魔してしまいます。
CPU 負荷の高い計算を別のワーカースレッド(裏側のスレッドプール)へ完全に逃がしたいときは、cx.background_executor().spawn(...) を組み合わせます。
#![allow(unused)]
fn main() {
pub fn run_heavy_calculation(&mut self, cx: &mut Context<Self>) {
cx.spawn(async move |this: WeakEntity<Self>, cx: &mut AsyncApp| {
// 1. 重い計算だけをバックグラウンドのスレッドプールに投げて .await で結果を待つ
let calc_result: u64 = cx
.background_executor()
.spawn(async move {
// ここは別スレッドで並列実行されるため、UI は 120 FPS のまま一切カクつかない
(1..=10_000_000).sum()
})
.await;
// 2. 計算結果が出たら、メインスレッド側で画面の状態を更新する
let _ = this.update(cx, |this, cx| {
this.status = format!("計算結果: {}", calc_result);
cx.notify();
});
})
.detach();
}
}
「重い計算は background_executor で別スレッドに投げ、結果が出たら this.update で画面に反映する」というパターンを覚えておけば、どれほど重い処理を行うアプリでも常に滑らかな操作感を保つことができます。
ここまでで、GPUI の基礎となる状態管理、レイアウト、イベント、非同期処理のすべてを習得しました。 次章からはいよいよ GPUI-Component(GPUI Kit) を導入し、豊富なウィジェットとテーマ機能を使って実用的な画面を素早く組み立てる方法に入っていきます。
5. GPUI-Componentの導入とテーマ設計
GPUI-Componentの全体構成
第 1 章から第 4 章までで学んだように、GPUI 自体は「高速な GPU 描画と状態管理の土台」を提供するフレームワークです。 その土台の上に、現代的なデスクトップアプリケーションに必要な 70 種類以上の UI 部品とデザインシステムを構築したライブラリが GPUI-Component(プロジェクト名: GPUI Kit)です。
GPUI-Component に含まれるウィジェットは、第 3 章で学んだ GPUI の設計思想に沿って、大きく 2 つの種類 に分かれています。
| 分類 | 実装されているトレイト | 代表的なウィジェット | 特徴・使い方 |
|---|---|---|---|
| ステートレス部品 | RenderOnce | Button, Icon, Badge, Tag, Checkbox, Switch, Avatar | 状態を自分で持たない。render メソッドの中で毎回 Button::new(...) のように作って .child() に直接渡す。 |
| ステートフル部品 | Render + 専用 State | Input (InputState), Table (TableState), List (ListState), Select (SelectState) | カーソル位置やスクロール位置などの複雑な状態を Entity<XxxState> として保持し、親ビューから参照を渡して描画する。 |
この「シンプルな部品はそのまま置くだけ、複雑な部品は State エンティティを作って渡すだけ」という一貫したルールがあるため、1 つの部品の使い方を覚えれば他の 70 種類以上の部品もすべて同じ感覚で扱うことができます。
初期化処理とRootラッパーの役割
GPUI-Component を使ってアプリケーションを作る際には、必ず守るべき 2 つの約束事 があります。
- アプリ起動時(
app.runの先頭)にgpui_component::init(cx)を呼び出すこと。 - 各ウィンドウの一番外側のビューを
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 を動かすための土台とテーマ設計が整いました。 次章では、いよいよボタン、アイコン、テキスト入力、チェックボックス、モーダル、通知といった「基本ウィジェットとフォーム制御」の具体的な使い方を網羅していきます。
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 段階で扱います。
- 親ビューの初期化時(
new):cx.new(|cx| InputState::new(window, cx))を呼んで、状態エンティティ(Entity<InputState>)を作成し、親ビューのフィールドに保存します。 - 親ビューの描画時(
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 システム」を解説します。
7. 高度なUI(仮想化テーブル・リスト・Dock)
なぜ「仮想化(Virtualization)」が必要なのか
ログビューア、データベース管理ツール、ファイルマネージャー、金融取引ツールなどを作るとき、1 万件や 10 万件といった大量のデータを一覧表示したい場面がよくあります。
もし 10 万件のデータをそのまま div().children(items.iter().map(...)) で画面に並べたらどうなるでしょうか。
実際には画面の高さ(たとえば 800 ピクセル)には 20〜30 行しか映らないにもかかわらず、GPUI は裏側で 10 万個すべての要素のレイアウト計算(Taffy)と描画準備を毎フレーム行うことになり、いかに高速な GPU であっても動作が重くなってしまいます。
これを解決するのが UI の仮想化(Virtualization) です。
[ データ全体: 100,000 行 ]
┌────────────────────────┐
│ 0行目 〜 499行目 │ ← 要素すら生成しない(スキップ)
├════════════════════════┤
║ 500行目 (画面に表示中) ║ ┐
║ 501行目 (画面に表示中) ║ ├─ 画面に見えている 約25行分 だけ
║ ... ║ │ Render / Layout を実行する!
║ 524行目 (画面に表示中) ║ ┘
├════════════════════════┤
│ 525行目 〜 99,999行目 │ ← 要素すら生成しない(スキップ)
└────────────────────────┘
仮想化リストや仮想化テーブルは、現在のスクロール位置とウィンドウの高さから 「いま画面に見えているのは何行目から何行目までか」 を瞬時に計算し、その見えている数十行分だけ render を呼び出します。
そのため、データが 100 件でも 100 万件でも、メモリ消費量と描画速度(120 FPS)がまったく変わらないという驚異的な性能を発揮します。
GPUI 本体には基本的な uniform_list(すべての行が同じ高さのリスト)が備わっており、GPUI-Component はそれをさらに発展させた VirtualList、検索・選択機能付きの List、そして高機能な Table を提供しています。
仮想化リスト(List / VirtualList)の実装
GPUI-Component の List コンポーネントは、大量の項目を高速に表示するだけでなく、上部の検索ボックスによる絞り込み、キーボードの上下矢印キーでの移動、選択状態の管理などをセットで提供します。
List を使うときは、「データと見た目の作り方」を定義する ListDelegate トレイト を自分の構造体に実装し、それを ListState に渡します。
#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::{
list::{List, ListDelegate, ListItem, ListState},
IndexPath,
};
// 1. データと描画ルールを持つデリゲート構造体を定義する
pub struct FileListDelegate {
files: Vec<SharedString>,
selected_index: Option<IndexPath>,
}
impl ListDelegate for FileListDelegate {
type Item = ListItem;
// 項目が全部で何件あるかを返す
fn items_count(&self, _section: usize, _cx: &App) -> usize {
self.files.len()
}
// 指定された行(ix.row)の見た目を組み立てて返す(画面に見えている行だけ呼ばれる!)
fn render_item(
&mut self,
ix: IndexPath,
_window: &mut Window,
_cx: &mut Context<ListState<Self>>,
) -> Option<Self::Item> {
let file_name = self.files.get(ix.row)?.clone();
let is_selected = self.selected_index == Some(ix);
Some(
ListItem::new(ix)
.selected(is_selected)
.py_1()
.px_3()
.child(file_name),
)
}
// 項目がクリックされたり選択されたりしたときに呼ばれる
fn set_selected_index(
&mut self,
ix: Option<IndexPath>,
_window: &mut Window,
cx: &mut Context<ListState<Self>>,
) {
self.selected_index = ix;
cx.notify();
}
}
}
このデリゲートを作ったら、親ビューの初期化時に cx.new(|cx| ListState::new(delegate, window, cx)) で状態エンティティを作成し、render メソッドの中で List::new(&self.list_state) を配置するだけで、何万件でも滑らかに動くリストが完成します。
高機能データテーブル(TableとTableDelegate)
業務アプリケーションやデータ分析ツールで最も重宝するのが Table(データテーブル) コンポーネントです。
行(縦方向)だけでなく 列(横方向)の仮想化 にも対応しており、以下のような機能を標準で備えています。
- 列幅のマウスドラッグによるリサイズ
- 特定カラムの左右への固定(ピン留め)
- 列ヘッダーをクリックしたときの昇順・降順ソート(並び替え)
- セル単位または行単位の選択とスクロール追従
テーブルの使い方もリストとまったく同じで、TableDelegate トレイト を実装して TableState に渡します。
#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::table::{Column, ColumnSort, Table, TableDelegate, TableState};
// 1行分のデータ構造
#[derive(Clone)]
pub struct StockRow {
pub symbol: SharedString,
pub name: SharedString,
pub price: f64,
}
// テーブルのデリゲート構造体
pub struct StockTableDelegate {
rows: Vec<StockRow>,
columns: Vec<Column>,
}
impl StockTableDelegate {
pub fn new(rows: Vec<StockRow>) -> Self {
// 列(カラム)の設定:キー名、表示名、幅、ソート可否などを定義する
let columns = vec![
Column::new("symbol", "銘柄コード").width(px(120.0)).sortable(),
Column::new("name", "企業名").width(px(220.0)).resizable(true),
Column::new("price", "現在値").width(px(120.0)).text_right().sortable(),
];
Self { rows, columns }
}
}
impl TableDelegate for StockTableDelegate {
// 列の数を返す
fn columns_count(&self, _cx: &App) -> usize {
self.columns.len()
}
// 行の数を返す
fn rows_count(&self, _cx: &App) -> usize {
self.rows.len()
}
// 指定番号の列定義を返す
fn column(&self, col_ix: usize, _cx: &App) -> &Column {
&self.columns[col_ix]
}
// 各セル(row_ix 行目、col_ix 列目)の見た目を返す
fn render_td(
&mut self,
row_ix: usize,
col_ix: usize,
_window: &mut Window,
_cx: &mut Context<TableState<Self>>,
) -> impl IntoElement {
let row = &self.rows[row_ix];
match col_ix {
0 => div().font_weight(FontWeight::BOLD).child(row.symbol.clone()),
1 => div().child(row.name.clone()),
2 => div().child(format!("{:.2}", row.price)),
_ => div(),
}
}
// ヘッダーのソートボタンが押されたときの並び替え処理
fn perform_sort(
&mut self,
col_ix: usize,
sort: ColumnSort,
_window: &mut Window,
cx: &mut Context<TableState<Self>>,
) {
let is_asc = matches!(sort, ColumnSort::Ascending);
match col_ix {
0 => self.rows.sort_by(|a, b| {
if is_asc { a.symbol.cmp(&b.symbol) } else { b.symbol.cmp(&a.symbol) }
}),
2 => self.rows.sort_by(|a, b| {
let cmp = a.price.partial_cmp(&b.price).unwrap();
if is_asc { cmp } else { cmp.reverse() }
}),
_ => {}
}
cx.notify();
}
}
}
あとは親ビューで let table_state = cx.new(|cx| TableState::new(delegate, window, cx)); を作り、画面に Table::new(&self.table_state).stripe(true)(1 行おきの縞模様表示)を置くだけで、プロ仕様のデータグリッドが動作します。
マルチペインを構築するDockシステム
VS Code や Zed、あるいは Adobe 製品や金融トレーディングアプリのように、「画面の左にファイルツリー、中央に複数のタブ、下にターミナル、右にプロパティ画面を配置し、ユーザーがタブをドラッグして自由に画面分割を変えられる」という UI を ドックシステム(Dock Layout) と呼びます。
これをゼロから実装するのは至難の業ですが、GPUI-Component には完成された DockArea と Panel の仕組みが組み込まれています。
graph TD
subgraph DockArea["DockArea(ウィンドウ全体のドック領域)"]
LeftDock["Left Dock<br/>(サイドバーパネル)"]
subgraph CenterSplit["Center DockItem(分割・タブ領域)"]
TabPanel1["Tab: エディタパネル 1"]
TabPanel2["Tab: エディタパネル 2"]
BottomPanel["Bottom Split: ログ出力パネル"]
end
RightDock["Right Dock<br/>(インスペクタパネル)"]
end
▸ 1. 自分のビューに Panel トレイトを実装する
ドックの中に配置したい各画面(ビュー)には、通常の Render と Focusable に加えて Panel トレイト を実装します。
#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::dock::{Panel, PanelEvent};
pub struct LogPanel {
focus_handle: FocusHandle,
}
impl EventEmitter<PanelEvent> for LogPanel {}
impl Focusable for LogPanel {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
// Panel トレイトを実装すると、Dock のタブとして扱えるようになる
impl Panel for LogPanel {
fn panel_name(&self) -> &'static str {
"LogPanel" // 保存・復元に使われる識別名
}
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
"システムログ" // タブに表示されるタイトル
}
}
impl Render for LogPanel {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().p_4().child("ここにログが出力されます...")
}
}
}
▸ 2. DockArea を作成してパネルを配置する
メインウィンドウ側では、DockArea::new(...) を作成し、DockItem::split(上下左右の分割)や DockItem::tabs(タブグループ)を使って初期レイアウトを組み立てます。
#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::dock::{DockArea, DockItem, DockPlacement};
use std::sync::Arc;
fn setup_dock(window: &mut Window, cx: &mut App) -> Entity<DockArea> {
// 1. DockArea を作成する
let dock_area = cx.new(|cx| DockArea::new("main-dock", Some(1), window, cx));
let weak_dock = dock_area.downgrade();
// 2. 中央に配置するパネルを作成する
let editor_panel = cx.new(|cx| LogPanel { focus_handle: cx.focus_handle() });
let preview_panel = cx.new(|cx| LogPanel { focus_handle: cx.focus_handle() });
// 3. 中央を左右(Horizontal)に分割してセットする
let center_layout = DockItem::h_split(
vec![
DockItem::tab(editor_panel, &weak_dock, window, cx),
DockItem::tab(preview_panel, &weak_dock, window, cx),
],
&weak_dock,
window,
cx,
);
dock_area.update(cx, |area, cx| {
area.set_center(center_layout, window, cx);
});
dock_area
}
}
これだけで、境界線をドラッグしてサイズを変えたり、タブを引っ張って別の場所にドッキングさせたり、パネルを最大化・クローズしたりできる本格的なワークスペース画面が手に入ります。
レイアウト状態の保存と復元
ドックシステムを使うアプリでは、ユーザーがせっかく自分好みに画面を分割しても、アプリを再起動したときに初期状態に戻ってしまうと不便です。
DockArea には、現在のパネル配置や分割比率を丸ごとデータ構造(DockAreaState)として書き出す dump(&self, cx: &App) メソッドと、そこから配置を復元する load(...) メソッドが用意されています。
#![allow(unused)]
fn main() {
// 1. 現在のレイアウト状態を JSON 文字列として保存する
let state = dock_area.read(cx).dump(cx);
let json_str = serde_json::to_string_pretty(&state).unwrap();
std::fs::write("layout.json", json_str).ok();
}
次回起動時には、あらかじめ PanelRegistry にパネルの生成関数を登録しておき、serde_json::from_str で読み込んだ DockAreaState を dock_area.update(cx, |area, cx| area.load(state, window, cx)) に渡すだけで、前回終了したときと寸分違わぬ画面レイアウトが復元されます。
ここまでで、GPUI の基礎から GPUI-Component の高度なウィジェットまでを一通り学びました。 次章では、これらの知識をすべて組み合わせて、1 つの動くデスクトップアプリケーション「TaskFlow(タスク管理アプリ)」をゼロから完成させます。
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 コードで、日本語入力・リアルタイム検索・ショートカットキー・トースト通知・ダークモード切り替えを備えたネイティブアプリが完成しました。 次章(最終章)では、さらに大規模なアプリへ成長させるための「パフォーマンス最適化と自動テスト、配布戦略」を解説します。
9. パフォーマンス最適化とテスト戦略
GPUIのGPU描画パイプラインを知る
GPUI がなぜこれほど高速(120 FPS)に動作するのか、そしてどうすればその性能を落とさずに大規模なアプリを作れるのかを理解するために、1 フレーム(120 FPS なら約 8.3 ミリ秒)の裏側で起きている 3 つのフェーズ を見てみましょう。
graph LR
Phase1["1. Render フェーズ<br/>View から Element ツリーを構築"] --> Phase2["2. Layout & Prepaint フェーズ<br/>Taffy で座標・サイズを計算し<br/>当たり判定(Hitbox)を登録"]
Phase2 --> Phase3["3. Paint & GPU フェーズ<br/>四角形・文字・影をバッチ化し<br/>GPU シェーダーで一括描画"]
- Render フェーズ:
cx.notify()が呼ばれたビューのrender()メソッドが実行され、軽量なElementのツリーが作られます。 - Layout & Prepaint フェーズ:
Taffy エンジンが各要素の実際の幅・高さ・座標を計算し、マウスクリックを受け取るための当たり判定領域(
Hitbox)を配置します。 - Paint & GPU フェーズ: 背景の四角形(Quad)、角丸、影(Shadow)、文字のグリフ(テクスチャアトラス)、SVG パスといった描画命令(プリミティブ)を集め、種類ごとにまとめて GPU へ転送し、1 回のドローコールで一気に画面を塗り上げます。
つまり、GPU での描画そのものは極めて高速であり、アプリが重くなるとしたらその原因のほとんどは 「1. Render フェーズで不必要に巨大なツリーを毎回作り直している」 か 「メインスレッドで重い処理をしている」 のどちらかです。
120 FPSを維持するための状態分割と設計のコツ
アプリの規模が大きくなっても常に 120 FPS を維持するための、実践的な 4 つの設計ルールを紹介します。
▸ 1. 頻繁に更新される部分は独立した子 Entity(子ビュー)に分ける
GPUI の再描画は Entity(ビュー)単位 で行われます。
もし「1 秒間に 60 回変化するアニメーションやストップウォッチ」の変数を、画面全体のルートビュー(MainWindowView)に直接持たせて cx.notify() を呼ぶと、毎フレーム画面全体の何百個もの部品の render() が走り直してしまいます。
[悪い例] ルートビューがすべてを持つ
MainWindowView (タイマー更新で毎回全体を再構築!)
├── Sidebar
├── BigTable
└── TimerText
[良い例] 頻繁に変わる部分を独立した Entity に切り出す
MainWindowView (再構築されない)
├── Entity<SidebarView>
├── Entity<BigTableView>
└── Entity<TimerView> (ここだけが cx.notify() で再描画される!)
頻繁に状態が変わる部品は、cx.new(|_| TimerView::new()) のように独立した Entity として切り出しましょう。
そうすれば、その子ビューの中で cx.notify() を呼んでも、親ビューや兄弟ビューの render() は一切実行されず、再計算コストを最小限に抑えられます。
▸ 2. 文字列やリストの受け渡しには SharedString と Arc を使う
render() メソッドは高頻度で呼ばれます。
その中で毎回大きな String や Vec を .clone() すると、メモリの確保とコピーが繰り返されてしまいます。
文字列には GPUI 標準の SharedString を使い、大きな読み取り専用データの共有には Arc<[T]> を使うことで、ポインタのコピーだけで瞬時に受け渡せます。
▸ 3. 100 件を超える一覧には必ず仮想化(VirtualList / Table)を使う
第 7 章で解説したとおり、スクロール領域の中に何百件もの要素を並べる場合は、通常の v_flex().children(...) ではなく、必ず GPUI の uniform_list や GPUI-Component の List / Table を使ってください。
▸ 4. render() メソッドの中でファイルの読み書きや通信をしない
render() メソッドは「現在のメモリの状態をそのまま画面要素に写し取るだけの純粋な処理」に徹する必要があります。
データの検索やソート、ファイルアクセスなどは、イベントハンドラや cx.background_executor() 側であらかじめ済ませておき、結果をビューのフィールドに保存してから cx.notify() を呼ぶように設計しましょう。
ヘッドレスUIテスト(#[gpui::test])の書き方
GUI アプリケーションの開発では、「ボタンを押したら本当に状態が変わるか」「ショートカットキーが正しく動くか」を自動テストで確認したい場面が多々あります。 しかし、テストのたびに本物の OS ウィンドウを開いていたのでは、動作が遅いうえに CI(GitHub Actions などの自動検証サーバー)上で動かすのが困難です。
GPUI には、実際のウィンドウを画面に出さずにメモリ上だけでウィンドウとイベントを完全に再現する ヘッドレス UI テスト機構(#[gpui::test]) が最初から組み込まれています。
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
// #[gpui::test] を付けると、テスト用の仮想環境(TestAppContext)が自動で注入される
#[gpui::test]
async fn test_counter_increment(cx: &mut TestAppContext) {
// 1. 仮想ウィンドウを開き、テスト対象のビューを作成する
let (view, cx) = cx.add_window_view(|_window, _cx| CounterView { count: 0 });
// 初期状態が 0 であることを確認する
view.read_with(cx, |counter, _cx| {
assert_eq!(counter.count, 0);
});
// 2. キー入力やアクション、状態更新をシミュレートする
view.update(cx, |counter, cx| {
counter.count += 1;
cx.notify();
});
// 非同期タスクや再描画予約がすべて落ち着くまで進める
cx.run_until_parked();
// 3. 更新後の状態を検証する
view.read_with(cx, |counter, _cx| {
assert_eq!(counter.count, 1);
});
}
}
}
#[gpui::test] を使うと、以下のような高度なテストも通常の cargo test コマンドで一瞬で実行できます。
cx.simulate_keystrokes("cmd-n hello enter"): 実際のキーボード入力を順番に再現する。cx.simulate_click(position, Modifiers::none()): 指定座標へのマウスクリックを再現する。cx.executor().advance_clock(Duration::from_secs(5)): 実際には 5 秒待たずに、仮想時計を 5 秒進めてタイマー処理を即座にテストする。- ランダムシードを使った並列実行テスト(非同期処理のタイミングによるバグを自動検出する機能)。
アクセシビリティ(AccessKit)と多言語対応
実用的なデスクトップアプリとして広く使われるためには、アクセシビリティや多言語(国際化)への配慮も欠かせません。
▸ アクセシビリティとキーボードナビゲーション
GPUI および GPUI-Component は、Rust 製のクロスプラットフォームなアクセシビリティ基盤である AccessKit と連携し、スクリーンリーダーへの情報提供やキーボードだけでの画面操作をサポートしています。
GPUI-Component の Button、Input、Checkbox、Modal などは、Tab キーによるフォーカス移動(タブストップ)や矢印キーでのリスト選択、Esc キーでのキャンセル操作が標準で組み込まれています。
▸ 多言語対応(i18n)とフォント設定
GPUI-Component の内部メッセージ(たとえばテーブルの空表示メッセージやダイアログのボタン名など)はロケール設定に対応しており、アプリの言語設定に合わせて切り替えられます。
また、日本語環境で文字化けや不自然な中華フォント(CJK 統合漢字の字形問題)を防ぐため、Theme::global_mut(cx).font_family に OS 標準の日本語フォント(Windows なら "Yu Gothic UI" や "Meiryo"、macOS なら "Hiragino Sans")を指定しておくと、どの環境でも美しい日本語表示が得られます。
アプリケーションのビルドと配布
開発中のデバッグビルド(cargo run)では、コンパイル速度を優先して最適化が省かれているため、実行ファイルが大きくなり、描画パフォーマンスも本来の力を発揮していません。
ユーザーに配布するときや、本来の 120 FPS の描画性能を計測するときは、必ず --release フラグ を付けてビルドします。
cargo build --release
さらに、Cargo.toml に以下のようなリリースプロファイル設定を追加しておくと、最終的な実行ファイルのサイズを大幅に縮小し、実行速度を極限まで高めることができます。
[profile.release]
opt-level = 3 # 最大限の速度最適化を行う
lto = "thin" # リンク時最適化(Link Time Optimization)を有効にする
codegen-units = 1 # クレート全体の最適化効率を高める
strip = true # デバッグシンボルを除去してファイルサイズを小さくする
また、Windows 環境でアプリをダブルクリックして起動したときに、背後に黒いコマンドプロンプト(コンソール画面)が表示されないようにするには、src/main.rs の一番上の行に以下の属性を 1 行追加します。
#![allow(unused)]
fn main() {
// リリースビルド時のみ、Windowsのコンソールウィンドウを非表示にする
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
}
できあがった実行ファイルは、macOS なら .app バンドル(cargo-bundle クレートなどを利用)、Windows なら MSI / NSIS インストーラー(cargo-packager や wix などを利用)、Linux なら AppImage や Flatpak、Debian パッケージとしてパッケージングすることで、ユーザーが簡単にインストールできる製品として配布できます。
本書を通じて、GPUI の基礎概念である状態管理(Entity と Context)、宣言的なレイアウトとスタイリング、イベントと非同期処理から、GPUI-Component を活用したテーマ設計、フォーム制御、仮想化テーブル、ドックレイアウト、そして実践的なアプリケーション構築までを一通り学びました。
Rust の安全性と GPU の圧倒的な描画性能を兼ね備えた GPUI エコシステムを活用して、ぜひあなた自身の快適で高速なデスクトップアプリケーションを開発してみてください。