Open16

gpuiとgpui-componentを使ってみる

nazo6nazo6

gpuiとは

gpuiは、Zedを作るためにイチから作られたRustのUIフレームワーク。
Zed用ではあるがZed以外でも使える。

nazo6nazo6

gpui-componentとは

gpui自体にはコンポーネントがほとんど含まれておらず、文字を表示するぐらいしかできない。
Zed内部では専用のUIクレート(https://github.com/zed-industries/zed/tree/main/crates/ui )が使われているのだが、ライセンスがGPLであったりと外で使われることを想定されていないようだ。

そこで開発されているのがgpui-component。様々なコンポーネントを使える。

ライブラリの作者の方がLongBridgeというアプリを開発するのに使っているのを公開してくれているようだ

nazo6nazo6

gpui-componentを試す

gpui-componentにはかなりいい感じのサンプルアプリが例としてある。というかこれを使おうと思った決め手もこれぐらい高機能なものが使えるのだったらいけるだろうということから。

git clone https://github.com/longbridge/gpui-component
cargo run --release

で実行できる。ちなみにREADMEにあるDemoアプリがこれと同じもの。

nazo6nazo6

実行したものが以下の画像。

RustのGUIライブラリにありがちだったなんだか微妙に重いとかIMEに対応していないとかがなくサクサクだしIMEのインライン入力も普通に使える。これだけで感動。

nazo6nazo6

gpui-componentでアプリを作ってみる

残念ながらGPUIはほとんど学習リソースがない。唯一見つけたのが

https://github.com/hedge-ops/gpui-tutorial

このリポジトリ。何も無いよりは遥かに良い。あとはgpuiのcargo docもそれなりに充実しているのでそれを見る。

nazo6nazo6

プロジェクト作成

cargo new --bin gpui-component-test
cargo add ui --git https://github.com/longbridge/gpui-component
cargo add gpui --git https://github.com/huacnlee/zed.git --branch webview

gpui-componentのクレート名はui。随分と汎用的な名前…
また、どうやらgpui-componentはwebviewに対応するためにgpuiのフォークを使っているようなのでそれも追加。

nazo6nazo6

カウンタープログラムの作成

上に挙げたチュートリアルを見ながらgpui-componentを使ってカウンターを作ってみた。

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use gpui::*;
use ui::{button::Button, init};

struct Root {
    count: i64,
}

impl Render for Root {
    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
        div()
            .flex()
            .flex_col()
            .h_full()
            .w_full()
            .justify_center()
            .items_center()
            .bg(white())
            .child(
                div()
                    .flex()
                    .child(Button::new("minus").label("-").on_mouse_down(
                        MouseButton::Left,
                        cx.listener(|this, _, _, cx| {
                            this.count -= 1;
                            cx.notify();
                        }),
                    ))
                    .child(Button::new("plus").label("+").on_mouse_down(
                        MouseButton::Left,
                        cx.listener(|this, _, _, cx| {
                            this.count += 1;
                            cx.notify();
                        }),
                    )),
            )
            .child(format!("Count: {}", self.count))
    }
}

fn main() -> Result<()> {
    let app = Application::new();

    app.run(move |cx| {
        init(cx);

        cx.open_window(WindowOptions::default(), |_, cx| {
            cx.new(|_| Root { count: 0 })
        })
        .unwrap();
    });

    Ok(())
}
nazo6nazo6

状態にRenderトレイトで表されるUIをimplするという感じになっており、状態を変更した後でcx.notifyを実行すると再レンダーされるようだ。
またRenderOnceトレイトを実装すると再レンダーされない要素を表せる。flutterに似ている…?
ちなみに、Renderrenderメソッドでは&mut selfだがRenderOnceではselfが渡ってくる。こういうところでRustの表現力を感じられて面白い。
signalとかそういうのは無さそうなのでなるべく状態を小さくしないと再レンダーの嵐になる予感がする。

また、スタイルについてはtailwind風のものを採用しているらしく、個人的には非常に馴染みやすくて良い…のだがこれらが実装されているgpui::Styledトレイトを見てみるとなんと2976ものメソッドがある。こういうのが積み重なってビルドが遅くなるのだろう…

nazo6nazo6

Entity型について

Entityというのがどうにも重要なもののように感じる。Modelという用語も出てくるが、これらは全てEntityに統合されたようだ。

https://github.com/zed-industries/zed/pull/22632

上のPRによればEntity<T>というのはTと表される状態のGPUIにおける表現…のように見える。さらにTがRenderであればEntity<T>Elementであるとも記されている。

nazo6nazo6

「TがRenderであればEntity<T>はElementである」というのが非常に重要。いわゆる「要素」はgpuiではElement型で表せるが、これを実装できるのはRenderOnceを実装したものに限られる(正確にはIntoElementを実装できるということ)。つまり、状態を持つものは直接要素としては使えない。

ではRenderを実装した、状態を持つ要素をどう使うかという答えがEntityとなる。cx.new(|cx| State::new())を実行することでEntity<State>が得られ、これはIntoElementを実装しているため、UI構築時にchildメソッドなどに渡すことができる。

nazo6nazo6

Asyncなコードとの連携

asyncな関数を待機してUIをアップデートしたりする方法について

spawn

gpuiではContextspawnメソッドを呼ぶことでfutureを実行できる。実行するとTask<T>という物が返される。TaskFutureに似ているがawaitを呼ばなくても実行状態にあるので、FutureというよりJoinHandleやJSのPromiseに近そう?
ただしこのTaskはdropするとキャンセルされる。これを防止するにはTask::detachを使う。

ただしこれを使うとコンポーネントがdropされても実行され続けるような…?コンポーネントのライフサイクルに合わせてcleanupするにはTaskを構造体に入れておくべきかも

executor

Contextのメソッドをよく見るとbackground_executorforeground_executorというものがあり、これを介してもspawnできる。ソースを見たところcx.spawncx.foreground_executor().spawn()と等価なようだ。
どうやらforeground_executorはメインスレッドで実行されるみたい

nazo6nazo6

ここまでspawnと気軽に書いたがspawnできるということはどこかに非同期ランタイムがいるということである。
gpuiではランタイムとしてsmolを使っているようで、つまりtokio依存のものを実行するにはtokioランタイムが別に必要になるはずである。

複数の非同期エグゼキュータが存在している場合どのように実行されるかいまいちイメージがつかめない…

nazo6nazo6

App, Window, Contextについて

gpuiのあらゆる状態が入っているのがApp構造体であり、このAppへの参照とEntityへの参照を追加で持ったものがContextである。実際、Contextの定義は

pub struct Context<'a, T> {
    #[deref]
    #[deref_mut]
    app: &'a mut App,
    model_state: WeakEntity<T>,
}

である。さらに、このコードを見てわかるようにContextのderefがAppとなっている。ただし色々なメソッドではAppとContextの両方がもらえるようになっている。これを見る限り必要なさそうだが…?

Windowはまあウインドウの情報を持っているのだろうが、メソッドで露出している割にdoc(hidden)になっておりよくわからない。とりあえず使わないほうがいいのかもしれない。

nazo6nazo6

tokioとの連携

tokioのchannelを使ってUIを更新するプログラムを書いてみた。

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use gpui::*;
use tokio::sync::mpsc::UnboundedReceiver;
use ui::init;

struct Root {
    message: SharedString,
    _recv_task: Task<()>,
}

impl Root {
    fn new(cx: &mut Context<'_, Self>, mut mes_receiver: UnboundedReceiver<String>) -> Self {
        let _recv_task = cx.spawn(|this, mut cx| async move {
            loop {
                if let Some(message) = mes_receiver.recv().await {
                    println!("Received in UI: {}", message);
                    this.update(&mut cx, |this, cx| {
                        this.message = SharedString::new(message);
                        cx.notify();
                    })
                    .unwrap();
                };
            }
        });

        Self {
            message: SharedString::new(""),
            _recv_task,
        }
    }
}

impl Render for Root {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
        println!("Render");

        div()
            .bg(white())
            .child(format!("message: {}", self.message))
    }
}

#[tokio::main]
async fn main() -> Result<()> {
    let chan = tokio::sync::mpsc::unbounded_channel();

    tokio::spawn(async move {
        let mut i = 0;
        loop {
            tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
            i += 1;
            chan.0.send(format!("from tokio task: {}", i)).unwrap();
            println!("Sending from tokio task: {}", i);
        }
    });

    let app = Application::new();

    app.run(move |app| {
        init(app);

        app.open_window(WindowOptions::default(), |_, app| {
            app.new(|cx| Root::new(cx, chan.1))
        })
        .unwrap();
    });

    Ok(())
}

tokio::mainを使ってランタイムを開始すればgpuiの中で普通にtokioのasyncメソッドも使えるっぽい?

nazo6nazo6

RenderOnceをimplできるのは末端のコンポーネントだけ?

RenderOnceのrenderメソッド内でcx.new()を使って子にステート付きコンポーネントを使う…ということはできないみたい。
RenderOnceはかなり小さいButtonとかのモジュールでしか使えないと思っていたほうがよさそう

nazo6nazo6

つまり、アプリ全体のstructの構成は例えば以下のようになる。

struct Root {
    view1: Entity<View1>
    view2: Entity<View2>
}
impl Render for Root {...}

struct View1 { ... }
impl Render for View1 { ... }

struct View2
impl RenderOnce for View2

ここでView2が状態を持たない場合RenderOnceを実装できる