gpuiとgpui-componentを使ってみる

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

gpui-componentを試す
gpui-componentにはかなりいい感じのサンプルアプリが例としてある。というかこれを使おうと思った決め手もこれぐらい高機能なものが使えるのだったらいけるだろうということから。
git clone https://github.com/longbridge/gpui-component
cargo run --release
で実行できる。ちなみにREADMEにあるDemoアプリがこれと同じもの。

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

gpui-componentでアプリを作ってみる
残念ながらGPUIはほとんど学習リソースがない。唯一見つけたのが
このリポジトリ。何も無いよりは遥かに良い。あとはgpuiのcargo docもそれなりに充実しているのでそれを見る。

プロジェクト作成
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のフォークを使っているようなのでそれも追加。

カウンタープログラムの作成
上に挙げたチュートリアルを見ながら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(())
}

状態にRender
トレイトで表されるUIをimplするという感じになっており、状態を変更した後でcx.notify
を実行すると再レンダーされるようだ。
またRenderOnce
トレイトを実装すると再レンダーされない要素を表せる。flutterに似ている…?
ちなみに、Render
のrender
メソッドでは&mut self
だがRenderOnce
ではself
が渡ってくる。こういうところでRustの表現力を感じられて面白い。
signalとかそういうのは無さそうなのでなるべく状態を小さくしないと再レンダーの嵐になる予感がする。
また、スタイルについてはtailwind風のものを採用しているらしく、個人的には非常に馴染みやすくて良い…のだがこれらが実装されているgpui::Styled
トレイトを見てみるとなんと2976ものメソッドがある。こういうのが積み重なってビルドが遅くなるのだろう…

Entity
型について
Entity
というのがどうにも重要なもののように感じる。Model
という用語も出てくるが、これらは全てEntityに統合されたようだ。
上のPRによればEntity<T>
というのはTと表される状態のGPUIにおける表現…のように見える。さらにT
がRenderであればEntity<T>
はElement
であるとも記されている。

「TがRenderであればEntity<T>はElementである」というのが非常に重要。いわゆる「要素」はgpuiではElement
型で表せるが、これを実装できるのはRenderOnce
を実装したものに限られる(正確にはIntoElement
を実装できるということ)。つまり、状態を持つものは直接要素としては使えない。
ではRender
を実装した、状態を持つ要素をどう使うかという答えがEntityとなる。cx.new(|cx| State::new())
を実行することでEntity<State>
が得られ、これはIntoElement
を実装しているため、UI構築時にchild
メソッドなどに渡すことができる。

Asyncなコードとの連携
asyncな関数を待機してUIをアップデートしたりする方法について
spawn
gpuiではContext
のspawn
メソッドを呼ぶことでfutureを実行できる。実行するとTask<T>
という物が返される。Task
はFuture
に似ているがawaitを呼ばなくても実行状態にあるので、FutureというよりJoinHandleやJSのPromiseに近そう?
ただしこのTask
はdropするとキャンセルされる。これを防止するにはTask::detach
を使う。
ただしこれを使うとコンポーネントがdropされても実行され続けるような…?コンポーネントのライフサイクルに合わせてcleanupするにはTaskを構造体に入れておくべきかも
executor
Contextのメソッドをよく見るとbackground_executor
とforeground_executor
というものがあり、これを介してもspawnできる。ソースを見たところcx.spawn
はcx.foreground_executor().spawn()
と等価なようだ。
どうやらforeground_executor
はメインスレッドで実行されるみたい

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)
になっておりよくわからない。とりあえず使わないほうがいいのかもしれない。

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メソッドも使えるっぽい?

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

つまり、アプリ全体の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を実装できる