前回の記事では、Zedの高速性を支えるRust製GUIフレームワークgpuiの概要と、Hello Worldアプリの作り方を紹介しました。
今回は、簡単なカウンターアプリを作成し、ユーザーイベント処理と状態更新について解説します。
今回作るもの
- +ボタンでカウントアップ
- -ボタンでカウントダウン
- Resetボタンまたは
Crtl+Rでリセット -
Ctrl+Qで終了
動作はこれだけです。
実装
ウィンドウの作成
Hello Worldの例のようにウィンドウを作成していきます。
cx.open_windowの際に渡すオプションを設定することで、ウィンドウタイトルなどを設定できます。
fn main() {
Application::new().run(|cx: &mut App| {
// ウィンドウサイズは320x240
let bounds = Bounds::centered(None, size(px(320f32), px(240f32)), cx);
// ウィンドウタイトルはCounter App
let option = WindowOptions {
titlebar: Some(TitlebarOptions { title: Some("Counter App".into()), ..Default::default() }),
window_bounds: Some(
WindowBounds::Windowed(bounds)
),
..Default::default()
};
cx.open_window(option, |_, cx| {
cx.new(|cx| Counter::new(cx))
})
.unwrap();
});
}
コンポーネント作成
実は、GPUI自体にはボタンなどのコンポーネントは含まれていません。
GPUIはあくまで、アプリケーション全体の状態管理やレンダリングなどの機能を担当し、デザインやUI機能は分離されています。
そのため、必要なコンポーネントはコミュニティから提供されるライブラリを使用するか、ユーザー自身で実装する必要があります。
ボタンの作成
以下のように#[derive(IntoElement)]を指定して構造体を用意し、RenderOnceを実装することで、コンポーネントが作成できます。
#[derive(IntoElement)]
struct Button {
id: ElementId,
label: SharedString, // ボタンに表示するテキスト
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>> // クリック時のコールバック処理用
}
impl Button {
fn new(id: impl Into<ElementId>, label: SharedString) -> Self {
Button {
id: id.into(),
label,
on_click: None,
}
}
fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Box::new(handler));
self
}
}
impl RenderOnce for Button {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
div()
.id(self.id)
.flex()
.justify_center()
.items_center()
.text_3xl()
.font_weight(FontWeight(900f32))
.border_2()
.pb_2()
.min_w_16()
.rounded_md()
.border_color(rgb(0x8CA9FF))
.text_color(white())
.bg(rgb(0xAAC4F5))
.hover(|style| style.bg(rgb(0xB2CCFD)).cursor_pointer()) // ホバー時のデザイン
.when_some(self.on_click, |this, on_click| {
this.on_click(move |evt, window, cx| (on_click)(evt, window, cx))
})
.child(self.label)
}
}
カウンター表示
あとは以下のように、ボタンコンポーネントを使用して、カウンターの数値などの表示すればUIは完成です。
// カウント表示部
fn display(count: u32) -> impl IntoElement {
div()
.text_3xl()
.min_w_24()
.text_3xl()
.flex_grow()
.pt_1()
.font_weight(FontWeight(900f32))
.text_color(rgb(0x0C2B4E))
.text_center()
.items_center()
.child(count.to_string())
}
struct Counter {
count: u32,
}
impl Counter {
fn new(cx: &mut Context<Self>) -> Self {
Self {
count: 0,
}
}
fn reset<E>(&mut self, _: &E, _: &mut Window, cx: &mut Context<Self>) {
self.count = 0;
cx.notify();
}
}
impl Render for Counter {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.gap_2()
.p_8()
.bg(rgb(0xFFF8DE))
.size_full()
.justify_center()
.items_center()
.child(
div()
.flex()
.gap_2()
.w_full()
.child(
Button::new("decrement-button", "-".into())
.on_click(cx.listener(|this, _evt, _window, _cx| {
this.count -= if this.count > 0 { 1 } else { 0 };
}))
)
.child(
display(self.count)
)
.child(
Button::new("increment-button", "+".into())
.on_click(cx.listener(|this, _evt, _window, _cx| {
this.count += 1;
}))
)
).child(
div()
.w_full()
.child(
Button::new("reset-button", "Reset".into())
.on_click(cx.listener(Self::reset::<ClickEvent>))
)
)
}
}
イベント処理・状態の更新
マウス操作
マウス操作などのイベントは、.on_mouse_downや.on_mouse_upといったコールバック登録用のAPIにコールバックを登録することで取得できますが、状態を変更する場合は注意が必要です。
GPUIの内部では、Entityという単位で状態を管理しています。
今回の例では、
struct Counter {
count: u32,
}
がEntityとなります。
EntityはすべてAppContextが所有するため、Entityに紐づくViewであっても直接変更はできません。
変更する場合は、以下のようにAppContextから一時的に所有権を借りて変更します。
Button::new("increment-button", "+".into())
.on_click(cx.listener(|this, _evt, _window, _cx| {
this.count += 1;
}))
キーボード操作
GPUIはキーボードファーストの操作性を実現するように設計されており、非常に柔軟にキーボードイベントを処理できます。
キーボードイベントの定義はaction!マクロを使用します。
// keyboard_actions名前空間の下にResetとQuitユニット構造体を作成する。
actions!(keyboard_actions, [Reset, Quit]);
あとは、アプリケーションの設定でキーボードイベントを登録し、アプリケーションのon_actionにコールバックを設定することで、キーボードイベントを処理できます。
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(320f32), px(240f32)), cx);
let option = WindowOptions {
titlebar: Some(TitlebarOptions { title: Some("Counter App".into()), ..Default::default() }),
window_bounds: Some(
WindowBounds::Windowed(bounds)
),
..Default::default()
};
// キーボードイベントを登録
cx.bind_keys([
gpui::KeyBinding::new("ctrl-q", Quit, None), // Ctrl+Qで終了イベント
gpui::KeyBinding::new("ctrl-r", Reset, None) // Ctrl+Rでカウントリセットイベント
]);
// アプリケーションレベルのキーボードイベントのコールバックを登録
cx.on_action(|_ev: &Quit, app| {
app.quit(); // アプリケーションを終了する
});
cx.open_window(option, |_, cx| {
cx.new(|cx| Counter::new(cx))
})
.unwrap();
});
}
キーボードイベントを受け取るビューはフォーカスしている必要があります。
以下のようにCounterビューを変更し、フォーカス可能にします。
struct Counter {
count: u32,
focus_handle: FocusHandle, // ビューにフォーカスハンドルを追加
}
impl Counter {
fn new(cx: &mut Context<Self>) -> Self {
Self {
count: 0,
focus_handle: cx.focus_handle(),
}
}
fn reset<E>(&mut self, _: &E, _: &mut Window, cx: &mut Context<Self>) {
self.count = 0;
cx.notify(); // 画面を更新
}
}
// フォーカス可能にする
impl Focusable for Counter {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Counter {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.gap_2()
.p_8()
.bg(rgb(0xFFF8DE))
.size_full()
.justify_center()
.items_center()
.track_focus(&self.focus_handle(cx)) // GPUIがフォーカスを追跡できるようにする
.on_action(cx.listener(Self::reset::<Reset>)) // リセット時のコールバックを登録
.child(
div()
.flex()
.gap_2()
.w_full()
.child(
Button::new("decrement-button", "-".into())
.on_click(cx.listener(|this, _evt, _window, _cx| {
this.count -= if this.count > 0 { 1 } else { 0 };
}))
)
.child(
display(self.count)
)
.child(
Button::new("increment-button", "+".into())
.on_click(cx.listener(|this, _evt, _window, _cx| {
this.count += 1;
}))
)
).child(
div()
.w_full()
.child(
Button::new("reset-button", "Reset".into())
.on_click(cx.listener(Self::reset::<ClickEvent>))
)
)
}
}
また、Entityの値が書き換わったら、cx.notify();を呼び出し、明示的にGPUIにビューの更新を通知する必要があります。
以上で、プログラムは完成ですので、あとはcargo runで起動できます。
最後に最終的なソースコードは以下になります。
use gpui::{prelude::FluentBuilder, *};
actions!(keyboard_actions, [Reset, Quit]);
#[derive(IntoElement)]
struct Button {
id: ElementId,
label: SharedString,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>
}
impl Button {
fn new(id: impl Into<ElementId>, label: SharedString) -> Self {
Button {
id: id.into(),
label,
on_click: None,
}
}
fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Box::new(handler));
self
}
}
impl RenderOnce for Button {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
div()
.id(self.id)
.flex()
.justify_center()
.items_center()
.text_3xl()
.font_weight(FontWeight(900f32))
.border_2()
.pb_2()
.min_w_16()
.rounded_md()
.border_color(rgb(0x8CA9FF))
.text_color(white())
.bg(rgb(0xAAC4F5))
.hover(|style| style.bg(rgb(0xB2CCFD)).cursor_pointer())
.when_some(self.on_click, |this, on_click| {
this.on_click(move |evt, window, cx| (on_click)(evt, window, cx))
})
.child(self.label)
}
}
fn display(count: u32) -> impl IntoElement {
div()
.text_3xl()
.min_w_24()
.text_3xl()
.flex_grow()
.pt_1()
.font_weight(FontWeight(900f32))
.text_color(rgb(0x0C2B4E))
.text_center()
.items_center()
.child(count.to_string())
}
struct Counter {
count: u32,
focus_handle: FocusHandle,
}
impl Counter {
fn new(cx: &mut Context<Self>) -> Self {
Self {
count: 0,
focus_handle: cx.focus_handle(),
}
}
fn reset<E>(&mut self, _: &E, _: &mut Window, cx: &mut Context<Self>) {
self.count = 0;
cx.notify();
}
}
impl Focusable for Counter {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Counter {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.gap_2()
.p_8()
.bg(rgb(0xFFF8DE))
.size_full()
.justify_center()
.items_center()
.track_focus(&self.focus_handle(cx))
.on_action(cx.listener(Self::reset::<Reset>))
.child(
div()
.flex()
.gap_2()
.w_full()
.child(
Button::new("decrement-button", "-".into())
.on_click(cx.listener(|this, _evt, _window, _cx| {
this.count -= if this.count > 0 { 1 } else { 0 };
}))
)
.child(
display(self.count)
)
.child(
Button::new("increment-button", "+".into())
.on_click(cx.listener(|this, _evt, _window, _cx| {
this.count += 1;
}))
)
).child(
div()
.w_full()
.child(
Button::new("reset-button", "Reset".into())
.on_click(cx.listener(Self::reset::<ClickEvent>))
)
)
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(320f32), px(240f32)), cx);
let option = WindowOptions {
titlebar: Some(TitlebarOptions { title: Some("Counter App".into()), ..Default::default() }),
window_bounds: Some(
WindowBounds::Windowed(bounds)
),
..Default::default()
};
cx.bind_keys([
gpui::KeyBinding::new("ctrl-q", Quit, None),
gpui::KeyBinding::new("ctrl-r", Reset, None)
]);
cx.on_action(|_ev: &Quit, app| {
app.quit();
});
cx.open_window(option, |_, cx| {
cx.new(|cx| Counter::new(cx))
})
.unwrap();
});
}
