4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RustのGUIフレームワーク「GPUI」を使ってみた: ユーザーイベント処理と状態の更新

Posted at

前回の記事では、Zedの高速性を支えるRust製GUIフレームワークgpuiの概要と、Hello Worldアプリの作り方を紹介しました。
今回は、簡単なカウンターアプリを作成し、ユーザーイベント処理と状態更新について解説します。

今回作るもの

counter_app.gif

  • +ボタンでカウントアップ
  • -ボタンでカウントダウン
  • 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();
    });
}
4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?