デスクトップアプリを作るフレームワークとして、Electron が広く使われています。Visual Studio Code、Slack、Discord なども Electron で作られたアプリです。Electron は Web 開発の知識で macOS・Windows・Linux 向けのアプリを作れる一方、アプリごとにブラウザエンジンを同梱して動かすため、メモリ使用量やアプリのサイズが大きくなりやすいという課題があります。また、画面の描画も DOM のレイアウトや JavaScript のガベージコレクションの影響を受けるため、これらの処理が重なると画面の更新が間に合わずスクロールやアニメーションがカクつくことがあります。
こうした課題から、Electron 以外の選択肢も増えています。Tauri は Chromium を同梱せず OS 標準の WebView で Web 技術の UI を表示し、
egui や
Iced は Web 技術を使わず Rust だけで UI を書けます。
GPUI は後者に属するフレームワークで、コードエディタ
Zed の UI はすべて GPUI で描画されています。
GPUI は Zed 以外のアプリでも採用され始めています。2026 年 11 月には、Discord の共同創業者で CTO の Stanislav Vishnevskiy 氏が、Electron で作られているデスクトップアプリを GPUI で作り直す計画を明らかにしました。
この記事では、GPUI でシンプルな ToDo アプリを段階的に作りながら、GPUI 独自の状態管理モデルと、イベント処理の書き方を紹介します。
GPUI は 1.0 前のフレームワークで、公式 README でもバージョン間で破壊的変更が頻繁に入ると明記されています。この記事のコードは 2026 年 11 月 8 日に Rust 1.99.1、gpui-kit 0.9.0 の組み合わせで動作を確認しています。
GPUI とは
GPUI は Zed の開発元である Zed Industries が開発している UI フレームワークです。公式サイトでは、即時モード(immediate mode)と保持モード(retained mode)を組み合わせた、GPU で高速に描画する UI フレームワークだと紹介されています。即時モードは描画のたびにその時点のデータから UI 全体を組み立て直す方式で、Rust の UI ライブラリ egui などが採用しています。保持モードは UI の部品をオブジェクトとして残しておき、変化した部分だけを書き換える方式で、ブラウザの DOM がその代表です。
Zed が独自の UI フレームワークを作った理由は、2023 年の Zed のブログ記事 Leveraging Rust and the GPU to render user interfaces at 120 FPS に書かれています。Zed の開発チームは以前 Electron 製の Atom エディタを開発しており、ガベージコレクションによる停止や DOM の再レイアウトのせいで、画面の更新が 1 フレームの描画時間内に終わらない問題に悩まされていました。そこでビデオゲームのように、UI を矩形・影・文字などのプリミティブに分解して GPU のシェーダーで描画する方式を採用しました。
GPUI の API は、「レジスター」と呼ばれる 3 つの概念で構成されています。
Entity: アプリの状態を保持する。状態は GPUI が所有し、コードはRcに似た参照カウント付きのハンドルを通じてアクセスする- View:
Renderトレイトを実装したEntity。div()などで要素ツリーを組み立て、Tailwind CSS に似たメソッドチェーンでスタイルを指定する Element: レイアウトと描画を直接制御する低レベルの API。コードエディタのような特殊な描画が必要な場合に使う
この記事では低レベルの Element API は扱わず、Entity と View を使ってアプリを組み立てます。
プロジェクトを作成する
GPUI は macOS における描画に Metal を使うため、Xcode と Xcode Command Line Tools が必要です。Rust は最新の stable 版を使います。
xcode-select --install
rustup update stableCargo でプロジェクトを作成し、gpui-kit を追加します。
cargo new gpui-todo-app
cd gpui-todo-app
cargo add [email protected]crates.io の gpui クレートは 2025 年 10 月の 0.2.2 から更新されておらず、アプリの起動処理などが現在の Zed の GPUI とは異なります。この記事で使う GPUI Kit は、Zed の main ブランチのスナップショットとして公開されている gpui-pre クレートを固定して再エクスポートしています(gpui-kit 0.9.0 では Zed のコミット f816e1f 時点の 0.3.11)。
Cargo.toml は以下のようになります。
[package]
name = "gpui-todo-app"
version = "0.1.0"
edition = "2024"
[dependencies]
gpui-kit = "0.9.0"ウィンドウを表示する
まずはタイトルだけを表示するウィンドウを作ります。src/main.rs を以下のように書き換えます。
use gpui_kit::{prelude::*, *};
struct TodoApp {
title: SharedString,
}
impl Render for TodoApp {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.size_full()
.p_4()
.bg(rgb(0xffffff))
.text_color(rgb(0x111827))
.child(div().text_xl().child(self.title.clone()))
}
}
fn main() {
gpui_kit::application().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(420.), px(480.)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_window, cx| {
cx.new(|_cx| TodoApp {
title: "ToDo".into(),
})
},
)
.unwrap();
cx.activate(true);
});
}main 関数から順に見ていきます。
gpui_kit::application()は GPUI のgpui_platform::application()を再エクスポートしたもので、実行中の OS に合ったプラットフォーム実装でApplicationを作成する。run()に渡したクロージャはアプリの起動後に一度だけ呼ばれる- クロージャの引数
cx: &mut Appはアプリ全体の状態を所有するオブジェクト。ウィンドウを開く、アプリを終了するといった操作はすべてAppを通じて行う cx.open_window()の第 2 引数は、ウィンドウのルートに置く View を返すクロージャ。cx.new()でTodoAppの状態をAppに渡し、そのハンドルであるEntity<TodoApp>を返しているcx.activate(true)でアプリを前面に表示する
TodoApp には Render トレイトを実装しています。render() メソッドは要素ツリーを返し、GPUI はウィンドウを描画し直すたびにルート View の render() を呼び出します。ウィンドウが描画し直されるのは、後で説明する cx.notify() で状態の変化が伝えられたときなど、表示を更新する必要が生じたときです。div() は HTML の div 要素に相当する汎用の要素で、.flex()・.p_4()・.text_xl() のように Tailwind CSS のクラス名に近いメソッドでスタイルを指定します。.child() で子要素を追加し、文字列もそのまま子要素として渡せます。
div()
.flex()
.flex_col()
.size_full()
.p_4()
.bg(rgb(0xffffff))
.text_color(rgb(0x111827))
.child(div().text_xl().child(self.title.clone()))SharedString は GPUI の文字列型で、静的な文字列と参照カウント付きの文字列のどちらも保持できます。render() は描画し直すたびに呼ばれるため、clone() してもヒープ上の文字列はコピーされないこの型が使われます。SharedString は &str や String からの From トレイトを実装しているため、"ToDo".into() のように .into() で文字列リテラルを SharedString に変換できます。
1 行目の prelude::* は、後で使う .when() などのトレイトメソッドを読み込むためのものです。cargo run を実行すると、「ToDo」と書かれたウィンドウが表示されます。
cargo run
ToDo リストを描画してクリックで更新する
続いて ToDo の一覧を表示し、項目をクリックすると完了と未完了が切り替わるようにします。この節で完成する src/main.rs は以下のとおりです。
use gpui_kit::{prelude::*, *};
struct Todo {
title: SharedString,
done: bool,
}
struct TodoApp {
todos: Vec<Todo>,
}
impl Render for TodoApp {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let remaining = self.todos.iter().filter(|todo| !todo.done).count();
div()
.flex()
.flex_col()
.gap_2()
.size_full()
.p_4()
.bg(rgb(0xffffff))
.text_color(rgb(0x111827))
.child(div().text_xl().child("ToDo"))
.children(self.todos.iter().enumerate().map(|(ix, todo)| {
div()
.id(ix)
.flex()
.gap_2()
.px_2()
.py_1()
.rounded_md()
.cursor_pointer()
.hover(|style| style.bg(rgb(0xf3f4f6)))
.when(todo.done, |this| {
this.line_through().text_color(rgb(0x9ca3af))
})
.on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| {
this.todos[ix].done = !this.todos[ix].done;
cx.notify();
}))
.child(if todo.done { "☑" } else { "☐" })
.child(todo.title.clone())
}))
.child(
div()
.text_sm()
.text_color(rgb(0x6b7280))
.child(format!("残り {remaining} 件")),
)
}
}
fn main() {
gpui_kit::application().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(420.), px(480.)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_window, cx| {
cx.new(|_cx| TodoApp {
todos: vec![
Todo {
title: "牛乳を買う".into(),
done: false,
},
Todo {
title: "GPUI のドキュメントを読む".into(),
done: true,
},
Todo {
title: "ブログ記事を書く".into(),
done: false,
},
],
})
},
)
.unwrap();
cx.activate(true);
});
}ToDo のデータを状態として持つ
一覧に表示する ToDo を、View の状態として TodoApp に持たせます。ToDo 1 件分のタイトルと完了状態を表す Todo 構造体を定義し、TodoApp には Todo の配列 todos を持たせます。
struct Todo {
title: SharedString,
done: bool,
}
struct TodoApp {
todos: Vec<Todo>,
}GPUI の View は、表示に使うデータを自分のフィールドに持ち、render() でそのフィールドから要素を組み立てます。render() が返す要素は描画のたびに作り直されるため、要素の側に完了状態を持たせても次の描画で消えてしまいます。完了状態は描画をまたいで残る TodoApp の todos に持たせ、クリックされたらそこを書き換えます。
初期データは main() で TodoApp を作成するときに渡します。
cx.new(|_cx| TodoApp {
todos: vec![
Todo {
title: "牛乳を買う".into(),
done: false,
},
// 残りの 2 件は省略
],
})状態から ToDo の一覧を組み立てる
render() では、self.todos の各要素を div() に変換して一覧を作ります。
.children(self.todos.iter().enumerate().map(|(ix, todo)| {
div()
.flex()
.gap_2()
.px_2()
.py_1()
.rounded_md()
.child(if todo.done { "☑" } else { "☐" })
.child(todo.title.clone())
})).children() はイテレーターを受け取り、複数の子要素をまとめて追加するメソッドです。enumerate() はイテレーターの各要素に 0 から始まる番号を付け、(番号, 要素) のタプルにします。ix は index の略で、その ToDo が self.todos の何番目にあるかを表します。この ix は、後でクリックされた ToDo を特定するために使います。
一覧の下には、未完了の ToDo の件数を表示します。render() の先頭で件数を数え、format! で文字列にして子要素に追加しています。
let remaining = self.todos.iter().filter(|todo| !todo.done).count();.child(
div()
.text_sm()
.text_color(rgb(0x6b7280))
.child(format!("残り {remaining} 件")),
)render() は self.todos から要素を組み立てているだけです。状態が変わって再描画されると、状態に合った要素ツリーが作り直されます。React の関数コンポーネントに近い考え方です。
完了済みの項目に取り消し線を付ける
完了済みの ToDo は、取り消し線と薄い文字色で表示します。
.when(todo.done, |this| {
this.line_through().text_color(rgb(0x9ca3af))
}).when(条件, クロージャ) は、条件 true のときだけクロージャでスタイルを追加するメソッドです。if 式で要素を作り分けなくても、メソッドチェーンの中で条件付きのスタイルを書けます。.when() は prelude::* で読み込まれる FluentBuilder トレイトのメソッドです。
クリックで完了と未完了を切り替える
最後に、項目をクリックしたら done を反転させます。そのためには、次の 3 つが必要です。
- 要素に ID を付ける
- ハンドラから
TodoAppの状態を書き換える - GPUI に再描画を要求する
まず、.id(ix) で要素に ID を付けます。
div()
.id(ix)GPUI の要素ツリーは再描画のたびに作り直されるため、「マウスボタンを押した要素と離した要素が同じか」のように再描画をまたいで要素を識別するには ID が必要です。div() に .id() を付けると Stateful<Div> という型に変わります。この後で使う .on_click() は Stateful な要素だけが実装する StatefulInteractiveElement トレイトのメソッドなので、ID のない div() では呼べません。
あわせて、クリックできることがわかるように、カーソルの形とホバー時の背景色を指定しています。.hover() のように 1 フレーム内で完結するスタイル指定は、ID がなくても使えます。
.cursor_pointer()
.hover(|style| style.bg(rgb(0xf3f4f6)))次に、.on_click() でクリックのハンドラを登録します。
.on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| {
this.todos[ix].done = !this.todos[ix].done;
cx.notify();
}))ハンドラの中では TodoApp の todos を書き換える必要があります。しかし .on_click() が受け取るのは Fn(&ClickEvent, &mut Window, &mut App) 型のクロージャで、self を受け取る引数がありません。render() が受け取った &mut self をクロージャに取り込むこともできません。クロージャは render() が終わった後に呼ばれるためです。
cx.listener() は、この問題を解決するためのメソッドです。Context<TodoApp> の listener() に |this: &mut TodoApp, event, window, cx| という形のクロージャを渡すと、.on_click() が受け取れる形のクロージャに変換されます。変換後のクロージャは TodoApp への弱参照(WeakEntity)を保持しており、呼ばれた時点で App から TodoApp の可変参照を借りて元のクロージャを実行します。move を付けているのは、クロージャが render() の後に呼ばれても ix を使えるように、値をクロージャへ移すためです。
ハンドラの最後では cx.notify() を呼んでいます。GPUI は Entity のフィールドが書き換えられたことを自動では検知しません。cx.notify() は「この Entity の状態が変わった」と GPUI に伝え、その Entity を監視している他の Entity やウィンドウに通知するメソッドです。View を表示しているウィンドウはこの通知を受けて再描画します。cx.notify() を呼ばないと再描画されないため、done は書き換わっても画面は前の表示のままです。
cargo run で起動し、項目をクリックするとチェックと取り消し線が切り替わり、残り件数が更新されます。
GPUI の状態管理モデル
ここまでのコードで Entity・App・Context が登場しました。これらの関係を整理しておきましょう。
GPUI では、cx.new() で作成したすべての状態を App が所有します。cx.new() が返す Entity<T> はハンドルにすぎず、Rc と同じように clone() すると参照カウントが増えます。ただし Rc と違い、Entity<T> だけでは中身を読み書きできません。状態にアクセスするには、App を一緒に渡して read() や update() を呼びます。
let counter: Entity<Counter> = cx.new(|_cx| Counter { count: 0 });
// App から Counter を借りて書き換える
counter.update(cx, |counter: &mut Counter, cx: &mut Context<Counter>| {
counter.count += 1;
cx.notify();
});
// App から Counter を借りて読み取る
let count = counter.read(cx).count;update() のクロージャに渡される Context<Counter> は、App に「どの Entity を更新中か」という情報を加えたものです。Context<T> は App に参照外しできるため、App を受け取る関数にもそのまま渡せます。cx.notify() のような Entity 単位の操作は Context<T> にだけあります。
Zed の Nathan Sobo 氏はこの設計の理由を Ownership and data flow in GPUI で説明しています。Rust の所有権は木構造のデータとは相性がよい一方、イベントのコールバックから
self を参照するような UI でよく使うパターンとは相性がよくありません。最初は標準ライブラリの Rc を使った実装を試しましたがうまくいかず、App がすべての状態を所有し、更新中だけ状態を「貸し出す」現在の設計にたどり着いたとされています。
Entity 同士の連携には 2 つの仕組みがあります。
cx.observe(): 監視対象のEntityでcx.notify()が呼ばれたときにコールバックを実行するcx.subscribe(): 監視対象のEntityがcx.emit()で発行した型付きのイベントを受け取る。発行する側はEventEmitter<イベント型>トレイトを実装する
この後に追加する入力欄では、subscribe を使って入力欄の Enter キーのイベントを受け取ります。
GPUI Kit で入力欄を追加する
GPUI 本体には、ボタンやテキスト入力欄といった完成した部品がありません。GPUI のリポジトリには入力欄のサンプルがありますが、カーソル移動、選択範囲、IME による変換中の文字の扱いまで自前で実装しており、900 行ほどあります。ここからは GPUI Kit の
Input・Checkbox・Button を使い、入力欄から ToDo を追加できるようにします。
この節で完成する src/main.rs は以下のとおりです。
use gpui_kit::component::{
ActiveTheme as _,
button::{Button, ButtonVariants as _},
checkbox::Checkbox,
input::{Input, InputEvent, InputState},
};
use gpui_kit::{prelude::*, *};
struct Todo {
title: SharedString,
done: bool,
}
struct TodoApp {
input: Entity<InputState>,
todos: Vec<Todo>,
_subscriptions: Vec<Subscription>,
}
impl TodoApp {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("やることを入力"));
input.update(cx, |input, cx| input.focus(window, cx));
// InputState が Enter で発行するイベントを購読する
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add_todo(window, cx);
}
});
Self {
input,
todos: Vec::new(),
_subscriptions: vec![subscription],
}
}
fn add_todo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let title = self.input.read(cx).value().trim().to_string();
if title.is_empty() {
return;
}
self.todos.push(Todo {
title: title.into(),
done: false,
});
self.input
.update(cx, |input, cx| input.set_value("", window, cx));
cx.notify();
}
}
impl Render for TodoApp {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let remaining = self.todos.iter().filter(|todo| !todo.done).count();
div()
.flex()
.flex_col()
.gap_3()
.size_full()
.p_4()
.bg(cx.theme().background)
.text_color(cx.theme().foreground)
.child(div().text_xl().child("ToDo"))
.child(
div()
.flex()
.gap_2()
.child(div().flex_1().child(Input::new(&self.input)))
.child(
Button::new("add")
.primary()
.label("追加")
.on_click(cx.listener(|this, _, window, cx| this.add_todo(window, cx))),
),
)
.children(self.todos.iter().enumerate().map(|(ix, todo)| {
div()
.flex()
.items_center()
.justify_between()
.child(
Checkbox::new(("todo", ix))
.label(todo.title.clone())
.checked(todo.done)
.on_change(cx.listener(move |this, checked: &bool, _, cx| {
this.todos[ix].done = *checked;
cx.notify();
})),
)
.child(
Button::new(("delete", ix))
.ghost()
.label("削除")
.on_click(cx.listener(move |this, _, _, cx| {
this.todos.remove(ix);
cx.notify();
})),
)
}))
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(format!("残り {remaining} 件")),
)
}
}
fn main() {
gpui_kit::application()
.with_assets(gpui_kit::assets::Assets)
.run(|cx: &mut App| {
gpui_kit::init(cx);
let bounds = Bounds::centered(None, size(px(420.), px(480.)), cx);
gpui_kit::open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
cx,
|window, cx| cx.new(|cx| TodoApp::new(window, cx)),
)
.unwrap();
cx.activate(true);
});
}以降では、このコードを前の節のコードからどのような順に変更したのかを、変更ごとに説明します。
GPUI Kit を初期化する
GPUI Kit のコンポーネントは、テーマやアイコンなどの初期設定が登録されていないと描画できません。そのため、コンポーネントを使う前に main() を書き換えます。
fn main() {
gpui_kit::application()
.with_assets(gpui_kit::assets::Assets)
.run(|cx: &mut App| {
gpui_kit::init(cx);
let bounds = Bounds::centered(None, size(px(420.), px(480.)), cx);
gpui_kit::open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
cx,
|window, cx| cx.new(|cx| TodoApp::new(window, cx)),
)
.unwrap();
cx.activate(true);
});
}GPUI だけを使っていたときから、次の 3 か所が変わっています。
.with_assets(gpui_kit::assets::Assets)で、コンポーネントが使うアイコンの SVG を読み込めるようにするgpui_kit::init(cx)で GPUI Kit を初期化する。テーマや各コンポーネントの初期設定が登録されるため、コンポーネントを使う前に一度呼ぶ必要があるcx.open_window()の代わりにgpui_kit::open_window()を使う。この関数は、渡した View を GPUI Kit のRootでラップしてからウィンドウを開く。Rootはダイアログや通知などを View の上に重ねて描画するための View
ルートの View は、構造体リテラルではなく TodoApp::new(window, cx) で作るように変えています。この関数は次の手順で実装します。
入力欄の状態を Entity として持つ
GPUI Kit のコンポーネントは gpui_kit::component から読み込みます。
use gpui_kit::component::{
ActiveTheme as _,
button::{Button, ButtonVariants as _},
checkbox::Checkbox,
input::{Input, InputEvent, InputState},
};入力中の文字列やカーソル位置といった入力欄の状態は、GPUI Kit の InputState が持ちます。InputState は TodoApp とは別の Entity で、TodoApp はそのハンドルを input フィールドに保持します。_subscriptions フィールドは、後で Enter キーのイベントを購読するために使う Subscription を保持するためのものです。
struct TodoApp {
input: Entity<InputState>,
todos: Vec<Todo>,
_subscriptions: Vec<Subscription>,
}InputState::new() は引数に Window を必要とします。そのため、TodoApp を作る処理を window を受け取る TodoApp::new() にまとめました。
impl TodoApp {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("やることを入力"));
input.update(cx, |input, cx| input.focus(window, cx));
// ...
}
}cx.new() で作った InputState に対して、input.update() で focus() を呼んでいます。これにより起動した直後から、入力欄に自動でフォーカスが当たるようになります。
入力欄と追加ボタンを描画する
render() では、タイトルの下に入力欄と「追加」ボタンを横に並べます。
.child(
div()
.flex()
.gap_2()
.child(div().flex_1().child(Input::new(&self.input)))
.child(
Button::new("add")
.primary()
.label("追加")
.on_click(cx.listener(|this, _, window, cx| this.add_todo(window, cx))),
),
)GPUI Kit のコンポーネントも、div() と同じく要素ツリーの一部として .child() で追加します。Input::new(&self.input) は InputState を描画する要素で、状態は TodoApp が保持する Entity の側にあります。そのため render() が再描画のたびに Input を作り直しても、入力中の文字列は失われません。
Button::new() の引数は要素の ID です。前の節で div() に .id() を付けたのと同じく、クリックを扱う要素には ID が必要です。クリックのハンドラは前の節と同じく cx.listener() で変換し、次の手順で実装する add_todo() を呼び出します。
入力値から ToDo を追加する
add_todo() は、入力欄の値を読み取って ToDo を追加し、入力欄を空に戻すメソッドです。
fn add_todo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let title = self.input.read(cx).value().trim().to_string();
if title.is_empty() {
return;
}
self.todos.push(Todo {
title: title.into(),
done: false,
});
self.input
.update(cx, |input, cx| input.set_value("", window, cx));
cx.notify();
}入力値は self.input.read(cx).value() で読み取ります。入力欄を空に戻すときは、self.input.update() で InputState を借りて set_value() を呼びます。「GPUI の状態管理モデル」で説明した Entity の read() と update() が、そのまま使われていることがわかります。空白だけの文字列は先頭の is_empty() の判定で弾くため、追加されません。todos を書き換えた後は、前の節と同じく cx.notify() で再描画を要求します。
Enter キーでも追加できるようにする
ボタンのクリックだけでなく、入力欄で Enter キーを押したときにも ToDo を追加します。InputState は Enter キーが押されると InputEvent::PressEnter を発行するので、TodoApp::new() の中でこのイベントを購読します。
// InputState が Enter で発行するイベントを購読する
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add_todo(window, cx);
}
});
Self {
input,
todos: Vec::new(),
_subscriptions: vec![subscription],
}cx.subscribe_in() は、指定した Entity が発行するイベントを購読するメソッドです。「GPUI の状態管理モデル」で紹介した cx.subscribe() との違いは、コールバックに &mut Window が渡される点です。add_todo() は入力欄を空に戻すために Window を必要とするので、ここでは subscribe_in() を使っています。
subscribe_in() が返す Subscription は購読の登録を表す値で、drop されると購読が解除されます。new() の中でローカル変数のまま捨てると、Enter キーを押しても何も起きません。そのため TodoApp の _subscriptions フィールドに保持し、TodoApp が存在する間は購読が続くようにしています。
チェックボックスと削除ボタンに置き換える
前の節では div() のクリックで完了状態を切り替えていました。これを GPUI Kit の Checkbox に置き換え、ToDo を削除するボタンも追加します。
.children(self.todos.iter().enumerate().map(|(ix, todo)| {
div()
.flex()
.items_center()
.justify_between()
.child(
Checkbox::new(("todo", ix))
.label(todo.title.clone())
.checked(todo.done)
.on_change(cx.listener(move |this, checked: &bool, _, cx| {
this.todos[ix].done = *checked;
cx.notify();
})),
)
.child(
Button::new(("delete", ix))
.ghost()
.label("削除")
.on_click(cx.listener(move |this, _, _, cx| {
this.todos.remove(ix);
cx.notify();
})),
)
}))クリックは Checkbox と Button が処理するので、外側の div() には .id() や .on_click() が不要になりました。Checkbox と Button の ID には ("todo", ix) のようなタプルを渡しています。同じ名前の要素が複数並ぶため、ix を付けてユニークにしています。
Checkbox は自分では checked の状態を持ちません。.checked(todo.done) で表示する値を受け取り、クリックされると .on_change() に新しい値を渡します。TodoApp 側で todos を更新して cx.notify() を呼ぶと、再描画で新しい値の Checkbox が描画されます。状態の持ち主はあくまで TodoApp で、Checkbox はその値を表示するだけです。React の制御コンポーネントと同じ考え方です。
テーマの色を使う
最後に、rgb() で直接指定していた色を GPUI Kit のテーマの色に置き換えます。
.bg(cx.theme().background)
.text_color(cx.theme().foreground).text_color(cx.theme().muted_foreground)cx.theme() は gpui_kit::init() が登録したテーマを読み出すメソッドです。ActiveTheme トレイトのメソッドなので、use で ActiveTheme as _ を読み込んでいます。GPUI Kit のコンポーネントもテーマの色で描画されるため、自分で書いた div() の色をテーマに合わせておくと、コンポーネントとの見た目がそろいます。
cargo run で起動すると、入力欄に文字を入力して Enter キーまたは「追加」ボタンで ToDo を追加できます。
Action でキーボード操作を定義する
最後に、完了済みの ToDo を cmd-shift-backspace でまとめて削除する操作を追加します。
GPUI のキーディスパッチのドキュメントは、GPUI を「キーボード操作を第一に考えたフレームワーク」と位置付けています。マウス向けの操作はボタンのクリックハンドラで実装しますが、キーボード向けの操作は「Action」と「キーコンテキスト」の組み合わせで実装します。
この節で完成する src/main.rs は以下のとおりです。
use gpui_kit::component::{
ActiveTheme as _,
button::{Button, ButtonVariants as _},
checkbox::Checkbox,
input::{Input, InputEvent, InputState},
};
use gpui_kit::{prelude::*, *};
actions!(todo_app, [ClearCompleted]);
struct Todo {
title: SharedString,
done: bool,
}
struct TodoApp {
input: Entity<InputState>,
todos: Vec<Todo>,
_subscriptions: Vec<Subscription>,
}
impl TodoApp {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("やることを入力"));
input.update(cx, |input, cx| input.focus(window, cx));
// InputState が Enter で発行するイベントを購読する
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add_todo(window, cx);
}
});
Self {
input,
todos: Vec::new(),
_subscriptions: vec![subscription],
}
}
fn add_todo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let title = self.input.read(cx).value().trim().to_string();
if title.is_empty() {
return;
}
self.todos.push(Todo {
title: title.into(),
done: false,
});
self.input
.update(cx, |input, cx| input.set_value("", window, cx));
cx.notify();
}
fn clear_completed(
&mut self,
_: &ClearCompleted,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.todos.retain(|todo| !todo.done);
cx.notify();
}
}
impl Render for TodoApp {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let remaining = self.todos.iter().filter(|todo| !todo.done).count();
div()
.key_context("TodoApp")
.on_action(cx.listener(Self::clear_completed))
.flex()
.flex_col()
.gap_3()
.size_full()
.p_4()
.bg(cx.theme().background)
.text_color(cx.theme().foreground)
.child(div().text_xl().child("ToDo"))
.child(
div()
.flex()
.gap_2()
.child(div().flex_1().child(Input::new(&self.input)))
.child(
Button::new("add")
.primary()
.label("追加")
.on_click(cx.listener(|this, _, window, cx| this.add_todo(window, cx))),
),
)
.children(self.todos.iter().enumerate().map(|(ix, todo)| {
div()
.flex()
.items_center()
.justify_between()
.child(
Checkbox::new(("todo", ix))
.label(todo.title.clone())
.checked(todo.done)
.on_change(cx.listener(move |this, checked: &bool, _, cx| {
this.todos[ix].done = *checked;
cx.notify();
})),
)
.child(
Button::new(("delete", ix))
.ghost()
.label("削除")
.on_click(cx.listener(move |this, _, _, cx| {
this.todos.remove(ix);
cx.notify();
})),
)
}))
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(format!("残り {remaining} 件")),
)
}
}
fn main() {
gpui_kit::application()
.with_assets(gpui_kit::assets::Assets)
.run(|cx: &mut App| {
gpui_kit::init(cx);
cx.bind_keys([KeyBinding::new(
"cmd-shift-backspace",
ClearCompleted,
Some("TodoApp"),
)]);
let bounds = Bounds::centered(None, size(px(420.), px(480.)), cx);
gpui_kit::open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
cx,
|window, cx| cx.new(|cx| TodoApp::new(window, cx)),
)
.unwrap();
cx.activate(true);
});
}Action を定義する
Action は「完了済みを削除する」のような論理的な操作を表す型です。actions! マクロで定義します。
actions!(todo_app, [ClearCompleted]);actions! マクロは、第 2 引数に並べた名前ごとに、フィールドを持たない構造体を生成します。この 1 行で、次のような ClearCompleted 型が定義されます。
#[derive(Clone, PartialEq, Default, Debug, Action)]
#[action(namespace = todo_app)]
pub struct ClearCompleted;第 1 引数は名前空間で、Action は todo_app::ClearCompleted のように「名前空間::型名」の形の名前で識別されます。Zed のショートカット設定でも、"cmd-z": "editor::Undo" のように同じ形式の名前で Action を指定しています。どのキーで実行するかは、Action の定義には含めません。キーとの対応付けは後で別に登録します。
Action を処理するメソッドを追加する
ClearCompleted が実行されたときの処理を、TodoApp のメソッドとして実装します。
fn clear_completed(
&mut self,
_: &ClearCompleted,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.todos.retain(|todo| !todo.done);
cx.notify();
}第 2 引数の型が &ClearCompleted になっている点が重要です。GPUI は、ハンドラの引数の型からどの Action を処理するかを決めます。処理の内容は、完了していない ToDo だけを残して cx.notify() を呼ぶだけです。
要素にキーコンテキストとハンドラを登録する
Action を処理する側は、要素に .key_context() でキーコンテキストの名前を付け、.on_action() でハンドラを登録します。ここでは render() が返すルートの div() に追加します。
div()
.key_context("TodoApp")
.on_action(cx.listener(Self::clear_completed)).on_action() が受け取るクロージャにも self の引数がないため、クリックのハンドラと同じく cx.listener() で clear_completed メソッドを変換しています。キーコンテキストは、次の手順でキーバインドを有効にする範囲を指定するときに使います。
キーと Action を対応付ける
キーと Action の対応付けは、要素とは別に main() で cx.bind_keys() を使って登録します。
cx.bind_keys([KeyBinding::new(
"cmd-shift-backspace",
ClearCompleted,
Some("TodoApp"),
)]);KeyBinding::new() の第 3 引数がキーコンテキストです。Some("TodoApp") を指定したキーバインドは、フォーカスを持つ要素から親へたどった経路に TodoApp のキーコンテキストが含まれるときだけ有効になります。この ToDo アプリでは、起動時に input.focus(window, cx) で入力欄にフォーカスを当てています。入力欄は TodoApp の要素の子孫なので、入力中に cmd-shift-backspace を押すと ClearCompleted が TodoApp まで届きます。
キーコンテキストが入れ子になっている場合は、フォーカスに近い内側のコンテキストのキーバインドが優先されます。例えば Zed ではエディタのキーバインドが、それを含むペインやワークスペースのキーバインドより優先されます。
cargo run で起動し、いくつかの ToDo を完了にしてから cmd-shift-backspace を押すと、完了済みの ToDo がまとめて削除されます。
まとめ
- GPUI は Zed のために開発された Rust の UI フレームワークで、UI を矩形や文字などのプリミティブに分解して GPU で描画する
- アプリの状態は
cx.new()でAppに渡し、Entity<T>ハンドルとAppを通じてread()/update()で読み書きする - View は
Renderトレイトを実装したEntityで、render()は再描画のたびに状態から要素ツリーを作り直す - 状態を変更したら
cx.notify()を呼んで GPUI に再描画を要求する - クリックなどのハンドラから View の状態を変更するには、
cx.listener()でクロージャを変換する div()でクリックなどのイベントを扱うには.id()で ID を付ける必要がある- GPUI 本体には入力欄などの部品がないため、GPUI Kit(gpui-component)の
Input・Checkbox・Buttonで補える。GPUI Kit を使うときはgpui_kit::init()を呼び、gpui_kit::open_window()でウィンドウを開く - キーボード操作は
actions!で定義した Action を.key_context()と.on_action()で処理し、cx.bind_keys()でキーと対応付ける
参考
GPUI
zed/crates/gpui at main · zed-industries/zed
Contexts(GPUI ドキュメント)
Key Dispatch(GPUI ドキュメント)
Leveraging Rust and the GPU to render user interfaces at 120 FPS - Zed Blog
Ownership and data flow in GPUI - Zed Blog
Stanislav Vishnevskiy 氏の X への投稿(2026 年 11 月 4 日)
「Discord」がクライアント全体を「Rust」で書き直し、「Electron」から脱却へ - 窓の杜
longbridge/gpui-kit
GPUI Kit
gpui - crates.io
gpui-pre - crates.io




