icon

nazo6.dev

一覧に戻る
2025/12/13 2025/12/14 24 min read

RustでデスクトップGUI - gpui入門 Part1 (gpuiの仕組み・状態管理の基礎編)

gpui解説記事のPart1。gpuiのレンダリング方法や状態管理について、実際のソースを見ながら詳しく解説します。

この記事はZennにも投稿しています

この記事は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はZedエディタに合わせて開発が進んでおり、今後もAPIに破壊的変更があることが予想されます。

#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が渡ってくるという点です。これにより「一度だけレンダリングされる」ということが表現されています。

このトレイトの特徴は、#[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から渡されるものです。そしてそのメソッドである cx.new()Entity<Stateful>を作るための処理です。このEntity<V>はgpui用語でViewと呼ばれます。 これはIntoElementを実装しているため、div().child()に渡すことができます。

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

ここまで、「コンポーネント」という用語をカジュアルに使ってきましたが、この用語はあまり適切ではないと感じています。「RenderOnceを実装したstruct」は確かにコンポーネントっぽいですが、「Renderを実装した構造体」はコンポーネントなのでしょうか?それはステートではないのでしょうか? ただ、これ以外に適切な言葉を見つけられないため、とりあえずコンポーネントらしきものをコンポーネントを呼んでいます。

では、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>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

です。これはイベントコールバックの中で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>TRenderを実装しなければいけないわけではないのです。

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

#参考記事・ドキュメント

Share this article:
一覧に戻る

関連記事

2021/12/27

2023/10/20

#tech/lang/rust
memo

Rust

気づきとかいろいろ

Read Article

2024/3/23

#tech/lang/rust#hardware/keyboard/keyball
blog

RustでKeyballのファームウェアを書きたい話

KeyballのファームウェアはQMKを使ったC言語のものになっています。ですがやはりRust、使いたいですよね?

Read Article

2023/5/26

#tech/lang/rust#tech/database
memo

RustでSQLからコードを生成するcornucopiaについて

SQLからRustのコードを生成して安全にデータベース操作ができる。恐らくGoのsqlcと同じ感じなんだと思う。

Read Article

2023/11/15

#tech/lang/rust
memo

Rustでジョブキュー的なもの

実行するコマンド(EnqueueかClear)をチャネルで受け取る

Read Article

2024/4/26

2024/5/22

#tech/lang/rust#hardware/keyboard/keyball
blog

RustとEmbassyでKeyballのファームウェアを作った

以前RustでKeyballのファームウェアを書きたい話で、ATMega32U4向けのファームウェアの作成をRustで試みたという話を書きましたが、結論から言うとこれは諦めてProMicro RP2040向けのファームウェアをRustで書くことにしました。

Read Article

2023/8/27

#tech/lang/rust
memo

Rustのserde_jsonでエラーの発生箇所を知る方法

serde_jsonではパースエラー発生時にどのプロパティでエラーが発生したのかわからない

Read Article

2023/6/27

#tech/lang/rust
memo

Rustのtargetフォルダを軽くする

cargo-sweepを使う

Read Article

2022/2/13

#tech/lang/rust
memo

Rustアプリにwasmerを埋め込む

dioxusを使ってwebでもdesktopでも動くアプリを作りたい

Read Article

2025/3/29

#tech/lang/rust
memo

Rustアプリのメモリ使用量を調査する

主にstatic領域のメモリ使用量を調査するのに有益。embassyの独立したタスクなどのサイズが見れる。

Read Article

2023/9/10

2023/12/18

#tech/lang/rust
blog

SerdeのDeserializerを実装する(Part1)

Serdeで任意の形式のファイルなどをデシリアライズする際にはDeserializerを書く必要があります。この記事では基本的なDeserializerの書き方を解説します。 正直自分もあまり理解していない部分が多々あるのですが世に出ている情報が少ないので書くことにしました。

Read Article

2023/12/18

2023/12/19

#tech/lang/rust
blog

SerdeのDeserializerを実装する(Part2 JSON編)

この記事はRust Advent Calendar 2023 シリーズ3の19日目の記事です。

Read Article

2024/5/24

#hardware/keyboard#tech/lang/rust
blog

USB HIDキーボードでメディアキーを操作する方法

USB HIDでは0x80がVolume Up、0x81がVolume Downに割り当てられており、さらに0xEDや0xEEでもVolume UpやDownができそうですが、実はこれらは全て動きません(Windowsでは)。

Read Article

2023/9/1

#tech/lang/rust
blog

prisma-client-rust入門

prisma-client-rustはJavascript向けのORMであるprismaをRustから使えるようにしたものです。実はprismaのコア部分はRustで書かれているためこういうものも作りやすかったんじゃないかと思います。

Read Article

2021/12/25

#tech/lang/rust
memo

tauriでWindows上でproductionビルドでのみ画像が表示されない(fetchエラーが発生する)

誰の役にも立たない気がするけどハマったのでメモ

Read Article

2023/11/18

#tech/lang/rust
blog

tokioで作ったサーバーをdockerで起動すると終了が遅くなるときの対処法

axumなどを作ってRustでサーバーを作るとdocker compose stopなどが微妙に遅くてイライラだったのでそれを解決する方法です。

Read Article

2025/4/9

2025/11/5

#tech/lang/rust
blog

「Rustが嫌いです。」の感想

https://zenn.dev/miguel/articles/f052de93fc9980

Read Article

© 2025 nazo6. All rights reserved.