RustでデスクトップGUI - gpui入門 Part1 (gpuiの仕組み・状態管理の基礎編)
gpui解説記事のPart1。gpuiのレンダリング方法や状態管理について、実際のソースを見ながら詳しく解説します。
この記事はZennにも投稿しています目次
RustでデスクトップGUI - gpui入門 Part1 (gpuiの仕組み・状態管理の基礎編)
この記事はRust Advent Calendar 2025 シリーズ2 14日目の記事です。
gpuiに関するスクラップが最近よく見られていたのと、自分自身もgpuiでアプリを作ろうと考えているので、勉強も兼ねてgpuiについての記事を書いてみました。
#gpuiとは
gpuiは、Rust製のデスクトップ向けUIフレームワークライブラリで、Zedエディタのために開発されました。Windows,Mac,Linuxの主要なデスクトップOSに対応しています。 ちなみに、公式サイト等では「GPUI」ではなく「gpui」という小文字表記が使用されているので本記事でもそれに従います。
gpuiは名前にも含まれている通りGPUを用いることを前提としています。GPUを搭載していないPCでも動作させることは可能ですが、エミュレーションとなるため低速になります。一方、現代的なGPUを搭載したPCであれば非常に高速に動作することを目標としているようです。
#環境
執筆時点(2025/12/13)における最新のバージョンを使用しています。
- Rust 1.92.0
- gpui v0.2.2
gpuiを導入するには、Rustプロジェクトで
cargo add gpuiを実行するだけです。
#gpuiのメリット・デメリット
他の言語及びRustのGUIライブラリと比較した際に感じたメリット・デメリットは以下のようになります。
#メリット
- GPUを活用して高速に動作するGUIを作成できる
- IMEが動作する
- Zedという製品で実用されているフレームワークであり、ある程度成熟しており、開発の継続可能性も高いと思われる
- gpui-componentやadabraka-uiなどの既成のコンポーネントライブラリがある程度ある
- WebViewじゃないのでWebのあれこれに縛られない
#デメリット
- GPUが搭載されていないPCとの互換性は低い
- OSのコンポーネントを使わない独自描画のため、ネイティブルックではなくバイナリが大きめ
- ドキュメントが非常に不足している
- モバイルには対応していない
- コンポーネントライブラリがあるとは言えそこまで充実しているわけでもない
- ホットリロードなどはない
- WebViewじゃないのでWebの膨大なエコシステムを使えない
#gpuiのスタック
まず、gpuiがどのように動作するかについてざっと見ていきます。
#コンポーネントシステム
最も高レイヤから見たgpuiは、コンポーネントベースのUIフレームワークです。例として、gpuiの公式サイトに書いてあるサンプルコードを下に示します。
use gpui::{
div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, Window,
WindowBounds, WindowOptions,
};
struct HelloWorld {
text: SharedString,
}
impl Render for HelloWorld {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.gap_3()
.bg(rgb(0x505050))
.size(px(500.0))
.justify_center()
.items_center()
.shadow_lg()
.border_1()
.border_color(rgb(0x0000ff))
.text_xl()
.text_color(rgb(0xffffff))
.child(format!("Hello, {}!", &self.text))
.child(
div()
.flex()
.gap_2()
.child(div().size_8().bg(gpui::red()))
.child(div().size_8().bg(gpui::green()))
.child(div().size_8().bg(gpui::blue()))
.child(div().size_8().bg(gpui::yellow()))
.child(div().size_8().bg(gpui::black()))
.child(div().size_8().bg(gpui::white())),
)
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| {
cx.new(|_| HelloWorld {
text: "World".into(),
})
},
)
.unwrap();
});
}これを実行すると以下のようになります。
コードを見てみると基本要素としてdivという名前が使われていたり、Tailwind CSSっぽいスタイリング構文が標準で用意されていたりと、Webフロントエンドが意識されているようです。
ここで重要になるのが状態管理ですが、これについては後の章で解説します。
#Elementトレイト
先程のソースを見ると、コンポーネントのrenderメソッドからimpl IntoElementというものが返されていることがわかります。これは最終的にimpl Elementとなります。
このElementが実際の描画を担当する低レベルなトレイトです。具体的にはElementトレイトの実装では、自身の大きさや実際に描画する内容を決める必要があり、内部ではElementが階層構造のように保持されることで、DOMのような構造になっています。
先程出てきたdiv要素はElement(およびIntoElement)を実装している物の代表例で、以下のコードを見るとかなり複雑そうなことをしているのがわかります。
#Taffy
そしてElementの描画を支えているのが、taffyというライブラリです。Taffyはいわゆるレイアウトエンジンというもので、先程のElement達を実際に画面に描画すべき構造に変換してくれます。
Taffyは他のRustプロジェクトでも使用されています。例えば
などで使われています。
このTaffyですが、flexboxやCSS gridといったブラウザのCSSで実現できるレイアウトを処理することができます。(どちらが先なのかはよくわかりませんが、Servoで使われているのはそのような事情もありそうです。)
先程のサンプルコードにjustify_centerなどCSSではお馴染のワードが出てきたのは、Taffyの力でgpuiではFlexboxがサポートされているからということです。
#GPUレンダラ
ここまでで画面に描画する内容を決定することができたので、これを実際に描画しなければいけません。そこで出てくるのがGPUレンダラです。RustではクロスプラットフォームのGUIレンダリングを実装したい場合はwgpuなどを使うことが一般的だと思いますが、gpuiではそのようなライブラリは使っていません。代わりに
- Mac: Metalまたはblade
- Linux: bladeを介したVulkan
- Windows: Direct3D のAPIを直接叩くことでそれぞれ頑張って実装しているようです。すごい…
また、↑の記事にあるように、レンダラ以外の基本的なウインドウ管理についても各OS向けに実装されている他、テキスト描画システムについてもWindowsではDirectWriteなどOSネイティブのものを使うようにしているようです。
これら各プラットフォームを抽象化したAPIの上にレンダリングパイプラインが実装されています。
以上がgpuiのレンダリングシステムの全貌となります。
#gpuiの状態管理
前項で飛ばした重要な項目に、状態管理があります。ここからは、コンポーネントを組み合わせる方法と状態管理について見ていきます。
#RenderOnceとIntoElement
まずはRenderOnceトレイトですが、これは状態を持たないコンポーネントに実装するトレイトです。その定義は
pub trait RenderOnce: 'static {
// Required method
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement;
}です。renderのみを持つトレイトですが、ここで注目すべきはrenderでselfが渡ってくるという点です。これにより「一度だけレンダリングされる」ということが表現されています。
このトレイトの特徴は、#[derive(IntoElement)]によりIntoElementトレイトを実装できることです。
div().child等のメソッドはIntoElementを引数として受け取るため、RenderOnceとIntoElementを実装する以下のようなStateless structはdivの子要素として直接渡すことができます。
use gpui::*;
struct Root {}
impl Render for Root {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().child(Stateless {}) // ← Statelessを要素として渡せる
}
}
#[derive(IntoElement)]
struct Stateless {} // ← RenderOnceを実装したコンポーネント
impl RenderOnce for Stateless {
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
div().child("Stateless")
}
}
pub fn main() {
let app = Application::new();
app.run(move |cx| {
cx.spawn(async move |cx| {
cx.open_window(WindowOptions::default(), |window, cx| cx.new(|cx| Root {}))?;
Ok::<_, anyhow::Error>(())
})
.detach();
});
}以上がステートの無いコンポーネントの例です。↓のような面白みのない画面が表示されます。

#RenderとEntity
状態を持たないコンポーネントについてはわかりましたね。では、状態を持つコンポーネントはどうすればいいでしょうか?実は既にコード中に出ていますが、そのようなコンポーネントはRenderトレイトを実装することで実現します。
以前のコードでRenderが既に出ていたのは、単にルートコンポーネントがRenderを実装していないといけないからで、深い意味はありません。
では、Renderトレイトを実装した以下のようなコンポーネントを見てみましょう。
struct Stateful {
count: u32,
}
impl Render for Stateful {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().child(format!("Stateful: {}", self.count)).child(
div().child("Button").on_mouse_down(
MouseButton::Left,
cx.listener(|this, _evt, _window, cx| {
this.count += 1;
cx.notify();
}),
),
)
}
}RenderOnceと似てどちらもrenderメソッドを持っていますが、RenderOnceではselfだったのに対してRenderでは&mut selfが渡されています。。確かにこれは何度もレンダリングされることを表現していそうですね。
また、よく見るとcxの型がRenderOnceでは&mut Appだったのに対して、&mut Context<Self>であることに気がつきます。これがステート管理の上で重要な要素になっっています。詳細については状態の更新で後ほど説明します。
render関数の中身も後ほど説明しますが、これはカウンターとインクリメントボタンを持つコンポーネントです。countというステートを持っています。とりあえず現段階では気にしなくていいです。
では、このコンポーネントはどうやって要素ツリーの中に入れればいいのでしょうか?derive(IntoElement)はRenderOnce専用なので、
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().child(Stateful {})
}のようなコードは書けません。
ですが、IntoElementのdocを見ると
impl<V: 'static + Render> IntoElement for Entity<V>という実装があることがわかります。なのでEntity<Stateful>というものを作ることができればレンダリングができそうですね!
#Entity
Entityが何なのかという話の前にまずは実際にEntity<Stateful>を作ってレンダリングするコードをお見せします。
use gpui::*;
struct Root {
stateful: Entity<Stateful>,
}
impl Root {
fn new(cx: &mut App) -> Self {
Self {
stateful: cx.new(|_| Stateful { count: 0 }), // ← ココ
}
}
}
impl Render for Root {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().child(self.stateful.clone()) // ← ココ
}
}新しく作成したRoot::newで、cx.new(|_| Stateful { count: 0 })というコードを呼んでそれを保存しています。render時にはそこで作ったものをcloneして渡していることがわかります。
実際にこれを実行すると、下のButtonと書かれた部分を押すことでStateful: 0の数字が増え、動作していることがわかります。

さて、ではcx.new()とは何なのでしょうか。
ここでcxは&mut App型ですが、これはgpuiアプリの開始時にgpuiから渡されるものです。そしてそのメソッドである cx.new()はEntity<Stateful>を作るための処理です。このEntity<V>はgpui用語でViewと呼ばれます。
これはIntoElementを実装しているため、div().child()に渡すことができます。
このコードより、ステートを持つコンポーネントを子としたい場合には、そのステートを親が保存しておく必要があることがわかります。
では、Entityとは何なのでしょうか。実はZed公式の以下の記事に詳しく書いてあります。
要するに、Entityは「Rcといったスマートポインタに似ているが、それをApp構造体でのステート中央集権型のアーキテクチャに適応したもの」です。
この記事では、Rustの所有権システムと状態管理をうまく組み合わせる方法を探した結果、Appというルート構造体に全てのステートを保存するという中央集権型の手法を採用したということが書いてあります。これは先程も出てきたcxの型であるAppと同じものを指しています
では実際にAppがどのような構造なのかちょっと見てみましょう。執筆時点でのApp構造体のコードがこちらです。
pub struct App {
...
pub(crate) entities: EntityMap,
...
}
pub(crate) struct EntityMap {
entities: SecondaryMap<EntityId, Box<dyn Any>>,
pub accessed_entities: RefCell<FxHashSet<EntityId>>,
ref_counts: Arc<RwLock<EntityRefCounts>>,
}確かにentities: SecondaryMap<EntityId, Box<dyn Any>>に全てのステートが保存されているようです。
ここで、Entityには
pub fn entity_id(&self) -> EntityIdという実装があります。このEntityIdというのはまさにentitiesマップのキーです。このようにして、cxからEntityを介して実際の状態を読み出すことができるのです。
つまり、cx.new() (= AppContext::new())は、このentitiesにステートを追加し、それへのハンドルであるEntityを取得するメソッドだったという訳です。
また、先程のサンプルコードではrender時にself.stateful.clone()を実行していましたが、これははあくまでEntityのクローンであり安価な操作であることがわかります。
#状態の更新
Entityについて分かったところで、Statefulコンポーネントの中身について見てみましょう。
struct Stateful {
count: u32,
}
impl Render for Stateful {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().child(format!("Stateful: {}", self.count)).child(
div().child("Button").on_mouse_down(
MouseButton::Left,
cx.listener(|this, _evt, _window, cx| {
this.count += 1;
cx.notify();
}),
),
)
}
}gpui単体では「ボタン」というコンポーネントは用意されていないため、単にdivに描画されたテキストの左クリックを検知とすることでボタンにしています。
そのクリックハンドラではcx.listenerをいうものが使われています。ここで注意するのは、ここでのcxは&mut Appではなく&mut Context<Self>という型であるということです。とは言っても難しいことはありません。Context<T>の定義は以下の通りです。
pub struct Context<'a, T> {
app: &'a mut App,
entity_state: WeakEntity<T>,
}この定義から分かるのは、Context<T>はAppにTのEntityを加えたものであるということです。つまり、Context<T>は、Tのステートに特化したAppであると言えます。
renderメソッドのシグネチャcx: &mut Context<Self>より、cxは&mut Context<Stateful>という型となります。つまりこのcxではEntity<Stateful>というステートに対する操作ができるのです。その方法の一つがcx.listenerで、そのシグネチャは
pub fn listener<E: ?Sized>(
&self,
f: impl Fn(&mut T, &E, &mut Window, &mut Context<'_, T>) + 'static,
) -> impl Fn(&E, &mut Window, &mut App) + 'staticです。これはイベントコールバックの中でthisとして&mut Tにアクセスできるようにするメソッドです。
ちなみに、コールバックの中でselfを直接書き換えるとライフタイムエラーが出ることがわかります。gpuiはこのようなcxのメソッドを多用することで、長いライフタイムを持つAppからステートを取得することでライフタイムエラーを回避していることが特徴です。
ではコールバックの中身を見てみましょう。今回はインクリメントするボタンなので、this.count += 1としています。
また、カウントを増やした後にcx.notify()を実行しています。これは、gpuiは変更を自動追跡するわけではないからです。変更を加えた場合はcx.notify()を実行することで「Entityが更新された」ことをgpuiに伝えます。これによりStatefulが再レンダリングされます。
#Observe
状態管理の最後としてobserveについて紹介します。これはは他のEntityの状態を監視するために用いられます。
Observeを用いる例として以下のコードを示します。
struct Counter {
value: u32,
}
impl Counter {
fn new() -> Self {
Self { value: 0 }
}
fn increment(&mut self, cx: &mut Context<Self>) {
self.value += 1;
cx.notify();
}
}
struct CounterDisplay {
counter: Entity<Counter>,
}
impl CounterDisplay {
fn new(counter: Entity<Counter>, cx: &mut Context<Self>) -> Self {
cx.observe(&counter, |this, counter, cx| {
cx.notify();
})
.detach();
Self { counter }
}
}
impl Render for CounterDisplay {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let value = self.counter.read(cx).value;
div()
.child(format!("Counter value: {}", value))
.child(div().child("Button").on_mouse_down(
MouseButton::Left,
cx.listener(|this, _evt, _window, cx| {
this.counter.update(cx, |counter, cx| {
counter.increment(cx);
});
}),
))
}
}Entity<Counter>がCounterDisplayに入っていますが、Counter自体はRenderではありません。Entityはあくまでgpuiが状態を保存するための仕組みなので必ずしもEntity<T>のTがRenderを実装しなければいけないわけではないのです。
さて、そのようなCounterの値を表示するCounterDisplayは当然Counterのステートに依存するわけですが、cx.notify()は自身のEntityのアップデートを行うだけです。
そこで使用できるのがcx.observeです。これを用いることで、他のEntityでcx.notify()が実行された際の処理を記述できます。CounterDisplayではCounterの更新時に自身をnotifyすることで表示を更新しています。
また、最後に.detach()が付いているのは、cx.observe()から返されるSubscriptionはドロップ時に監視を解除するからです。本来はCounterDisplayの中にこの値を保存しておくべきですが、今回は便宜上.deatch()することで、newの後にドロップしてもobserve処理を継続します。
#まとめ
以上で、gpuiのアーキテクチャと状態管理の基本的な仕組みについて解説しました。gpuiにはまだ
- subscribe/emit
- Globalステート
- Action(キーボードショートカット)
- 非同期ランタイム
などの機能があるのですが、これらを紹介しきることはできない(というか自分もあまり理解していない)ので、このあたりにしておきます。
また、今回使用したコードは
にあります。
#さいごに
今回はgpuiの動作の仕組みというところに焦点を当てました。正直ドキュメントが少なくて厳しかったです。間違っている箇所もあるかと思うので何かあればご指摘頂けると幸いです。
gpuiは他のRustフレームワークと比べてもZedという実戦がある分、IMEといった細かい箇所できちんとしているように感じるので、デスクトップアプリであれば選択肢に入るようになるかもしれません。
本当はgpui-componentとかを使ってカッコイイアプリを作るような記事を書こうと思っていたのですが、ステート管理などについて調べていると結構深堀りしないといけないような箇所が出てきてこのような記事になりました。 元気があればいつか実践的なアプリを作るPart 2の記事を書こうと思います…