🖼

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-componentadabraka-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プロジェクトでも使用されています。例えば

  • Blitz: Dioxusをベースとした別のGUIライブラリ
  • Servo: 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の状態管理

前項で飛ばした重要な項目に、状態管理があります。ここからは、コンポーネントを組み合わせる方法と状態管理について見ていきます。

RenderOnceIntoElement

まずはRenderOnceトレイトですが、これは状態を持たないコンポーネントに実装するトレイトです。その定義は

pub trait RenderOnce: 'static {
    // Required method
    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement;
}

で、renderという一つのメソッドのみを持っていることがわかります。ここで注目したいのはrenderselfが渡ってくるという点です。これにより「一度だけレンダリングされる」ということが表現されています。

このRenderOnceトレイトの特徴は、#[derive(IntoElement)]によりIntoElementトレイトを実装できることです。
div().child()等のメソッドはIntoElementを引数として受け取るため、RenderOnceIntoElementを実装する以下のような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();
    });
}

以上がステートの無いコンポーネントの例です。↓のような面白みのない画面が表示されます。

RenderEntity

状態を持たないコンポーネントについてはわかりましたね。では、状態を持つコンポーネントはどうすればいいでしょうか?実は既にコード中に出ていますが、そのようなコンポーネントは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から渡される値です。以下のコードでgpuiの開始時に渡されていることがわかります。

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();
    });
}

そしてそのメソッドである cx.new()StatefulからEntity<Stateful>を作るための処理です。このようにして作成されたEntity<V: Render>はgpui用語でViewと呼ばれます。
先程示した通りViewはIntoElementを実装しているため、div().child()に渡すことができます。

このコードより、ステートを持つコンポーネントを子としたい場合には、そのステート(のEntity)を親が保存しておく必要があることがわかります。

では、Entityとは何なのでしょうか。実はZed公式の以下の記事に詳しく書いてあります。

要するに、Entityは「gpui版のRcなどに似たスマートポインタのようなもの」です。

この記事では、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マップのキーです。つまり、Appへの参照であるcxからEntityを介して実際の状態を読み出すことができるのです。

cxがあれば逆に書き込むこともできます。それがcx.new()であり、これはApp内の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>AppTEntityを加えたものであるということです。つまり、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

です。これはイベントコールバックの中でエンティティの中身&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に入っていますが、CounterRenderを実装していないため、Entity<Counter>はViewではありません。Entityはあくまでgpuiが状態を保存するための仕組みなので必ずしもEntity<T>TRenderを実装しなければいけないわけではないのです。

さて、そのようなCounterの値を表示するCounterDisplayは当然Counterのステートに依存するわけですが、cx.notify()は自身のEntityのアップデートを行うだけあり、あくまでEntity<Counter>Entity<CounterDisplay>は別のEntityであるため、CounterでnotifyしてもCounterDisplayには伝わりません。
そこで使用できるのがcx.observeです。これを用いることで、他のEntitycx.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の記事を書こうと思います…

参考記事・ドキュメント

この記事は個人ブログとクロスポストしています。

GitHubで編集を提案

Discussion