4. イベント・アクション・非同期処理
マウスイベントとリスナーの登録
画面の部品がクリックされたり、マウスが動かされたりしたときに処理を実行するには、要素に対してイベントハンドラを登録します。
このとき最もよく使うのが、コンテキストが提供する cx.listener() メソッドです。
普通のクロージャでは、イベントが起きたときに「どのビューの状態を書き換えればよいか」を取り出すのが面倒ですが、cx.listener() で包むと、第 1 引数として自分自身の構造体(&mut Self)と、最後の引数として専用コンテキスト(&mut Context<Self>)を自動的に渡してくれます。
#![allow(unused)]
fn main() {
use gpui::*;
pub struct ClickDemo {
click_count: usize,
}
impl Render for ClickDemo {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.id("clickable-box") // クリックを受け取る要素には .id() が必要
.p_4()
.bg(rgb(0x2563eb))
.text_color(white())
.cursor_pointer()
.child(format!("クリック回数: {}", self.click_count))
// 左クリック(押して離したとき)のイベント
.on_click(cx.listener(|this: &mut Self, event: &ClickEvent, _window, cx| {
this.click_count += 1;
println!("クリック位置: {:?}", event.position());
cx.notify();
}))
// マウスの右ボタンが押された瞬間のイベント
.on_mouse_down(
MouseButton::Right,
cx.listener(|this: &mut Self, _event: &MouseDownEvent, _window, cx| {
this.click_count = 0; // 右クリックでリセット
cx.notify();
}),
)
}
}
}
主なマウスイベント用メソッドは以下のとおりです。
.on_click(...): 要素がクリックされたとき(.id(...)の指定が必須です)。.on_mouse_down(MouseButton, ...): 指定したマウスボタン(Left,Right,Middle)が押し込まれた瞬間。.on_mouse_up(MouseButton, ...): マウスボタンが離された瞬間。.on_mouse_move(...): 要素の上でマウスカーソルが移動したとき。.on_scroll_wheel(...): マウスホイールやトラックパッドでスクロールされたとき。
また、イベントハンドラの中で cx.stop_propagation() を呼ぶと、そのイベントが親要素へ伝わっていく(バブリングする)のを止めることができます。
キーボードフォーカスとFocusHandle
デスクトップアプリにおいて、キーボード操作はとても重要です。
今どの部品がキーボードの入力を受け付けているか(フォーカスを持っているか)を管理するために、GPUI では FocusHandle を使います。
キーボード操作を受け取りたいビューは、自身のフィールドに FocusHandle を 1 つ保持し、Focusable トレイトを実装します。
#![allow(unused)]
fn main() {
use gpui::*;
pub struct EditorView {
focus_handle: FocusHandle,
last_key: String,
}
impl EditorView {
pub fn new(cx: &mut Context<Self>) -> Self {
Self {
// 新しい FocusHandle を発行して保持する
focus_handle: cx.focus_handle(),
last_key: "まだキーが押されていません".to_string(),
}
}
}
// Focusable トレイトを実装する
impl Focusable for EditorView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for EditorView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_focused = self.focus_handle.is_focused(window);
div()
// 1. この要素に FocusHandle を紐づける
.track_focus(&self.focus_handle)
// 2. キーが押されたときのイベントを受け取る
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
this.last_key = format!("押されたキー: {}", event.keystroke.key);
cx.notify();
}))
.p_4()
.border_2()
.border_color(if is_focused { rgb(0x3b82f6) } else { rgb(0x94a3b8) })
.child(self.last_key.clone())
}
}
}
プログラムから特定のビューにフォーカスを当てたいときは、window.focus(&self.focus_handle)(または self.focus_handle.focus(window))を呼び出します。
また、div().track_focus(&self.focus_handle) を設定しておくと、その要素がマウスでクリックされたときに自動的にフォーカスが当たるようになります。
アクション(Action)とショートカットキー
小さなアプリなら .on_key_down() で直接キーの名前を調べても動きますが、本格的なデスクトップアプリでは「メニューバーから『保存』を選んだとき」も「ショートカットキー Ctrl+S(macOS なら Cmd+S)を押したとき」も、同じ処理を呼び出したいはずです。
さらに、ユーザーが自分でショートカットキーの設定を変更できるようにしたい場合もあります。
そこで GPUI は、物理的なキー入力と実際の処理の間に 「Action(論理コマンド)」 という仕組みを挟みます。
graph LR
Key["キー入力<br/>(ctrl-s / cmd-s)"] -->|KeyBinding| Action["Action<br/>(SaveDocument)"]
Menu["メニュー・ボタン<br/>(window.dispatch_action)"] --> Action
Action -->|フォーカス階層を遡る| Handler["ビューの .on_action()<br/>で処理を実行"]
アクションの定義から実行までは、以下の 3 ステップで行います。
▸ 1. actions! マクロでコマンドを定義する
引数を持たないシンプルなコマンドは、actions! マクロで一気に定義できます。
#![allow(unused)]
fn main() {
use gpui::*;
// "task_app" という名前空間に 3 つのアクションを定義する
actions!(task_app, [AddTask, DeleteSelected, ClearAll]);
}
もしアクションにデータ(たとえば検索ワードや ID など)を持たせたい場合は、#[derive(Clone, PartialEq, Action)](または JSON デシリアライズ対応)を付けた通常の構造体として定義することもできます。
▸ 2. KeyBinding でショートカットキーを登録する
アプリの初期化時(app.run の中など)に、どのキーとどのアクションを結びつけるかを cx.bind_keys で登録します。
#![allow(unused)]
fn main() {
// Secondary-n は、macOS では Cmd+N、Windows/Linux では Ctrl+N として扱われる便利な指定です
cx.bind_keys([
KeyBinding::new("secondary-n", AddTask, None),
KeyBinding::new("backspace", DeleteSelected, Some("TaskList")),
]);
}
第 3 引数の Some("TaskList") は キーコンテキスト(KeyContext) です。
これを指定すると、.key_context("TaskList") が設定された画面領域にフォーカスがあるときだけ、そのショートカットが有効になります。
▸ 3. .on_action() で処理を受け取る
最後に、ビューの render メソッド内で .on_action() を登録します。
#![allow(unused)]
fn main() {
div()
.key_context("TaskList")
.track_focus(&self.focus_handle)
.on_action(cx.listener(|this: &mut Self, _: &AddTask, _window, cx| {
this.create_new_task(cx);
}))
.on_action(cx.listener(|this: &mut Self, _: &DeleteSelected, _window, cx| {
this.delete_current_task(cx);
}))
}
これにより、現在フォーカスされている要素(またはその親要素)に向かってアクションが飛び、対応するメソッドがきれいに呼び出されます。
非同期ランタイムとcx.spawn
デスクトップアプリで Web API からデータを取得したり、大きなファイルを読み込んだりするとき、メインスレッド(UI スレッド)でそのまま処理を待つと、処理が終わるまでウィンドウが固まって(フリーズして)しまいます。
GPUI は独自の軽量な非同期ランタイムを内蔵しており、すべてのコンテキストから cx.spawn() を使って非同期処理(async ブロック)を起動できます。
#![allow(unused)]
fn main() {
use gpui::*;
use std::time::Duration;
pub struct DataFetcher {
status: String,
is_loading: bool,
}
impl DataFetcher {
pub fn start_fetch(&mut self, cx: &mut Context<Self>) {
self.is_loading = true;
self.status = "データを取得中...".to_string();
cx.notify();
// cx.spawn で非同期タスクを開始する
// 第1引数 this は WeakEntity<Self>、第2引数 cx は &mut AsyncApp として渡される
cx.spawn(async move |this: WeakEntity<Self>, cx: &mut AsyncApp| {
// 例として 2 秒間タイマーで待機する(非同期処理のシミュレーション)
cx.background_executor()
.timer(Duration::from_secs(2))
.await;
// 非同期処理が終わったら、this.update() でメインスレッドのビューを安全に更新する
let _ = this.update(cx, |this: &mut Self, cx: &mut Context<Self>| {
this.is_loading = false;
this.status = "データの取得が完了しました!".to_string();
cx.notify();
});
})
.detach(); // タスクをバックグラウンドで自走させる
}
}
}
ここで注目してほしいポイントが 2 つあります。
WeakEntity<Self>による安全なアクセス: 通信に 2 秒かかっている間に、ユーザーがその画面(ビュー)を閉じてしまうかもしれません。cx.spawnが渡してくれるthisは弱参照(WeakEntity)になっているため、もし画面がすでに閉じられて破棄されていた場合は、this.update(...)が単にErrを返して何もしないため、クラッシュやメモリリークが絶対に起きません。Task<T>の扱い(.detach()またはフィールド保持):cx.spawnは戻り値としてTask<R>を返します。 戻り値のTaskをそのまま捨ててしまうと、その瞬間に非同期処理がキャンセルされてしまいます。 最後まで走らせたい場合は.detach()を呼ぶか、あるいはビューの構造体にcurrent_task: Option<Task<()>>のようなフィールドを作ってそこに代入しておきます(フィールドに保存しておけば、ビューが破棄されたときに非同期タスクも自動でキャンセルされるため非常に安全です)。
バックグラウンドスレッドでの並列計算
cx.spawn() 自体は、メインスレッド上で非同期タスクの待ち合わせ(I/O 待ちなど)を行う仕組みです。
もし「数万件の JSON データを解析する」「画像の圧縮計算をする」といった CPU をフル回転させる重い計算 をそのまま cx.spawn の中に書くと、やはりメインスレッドの描画を邪魔してしまいます。
CPU 負荷の高い計算を別のワーカースレッド(裏側のスレッドプール)へ完全に逃がしたいときは、cx.background_executor().spawn(...) を組み合わせます。
#![allow(unused)]
fn main() {
pub fn run_heavy_calculation(&mut self, cx: &mut Context<Self>) {
cx.spawn(async move |this: WeakEntity<Self>, cx: &mut AsyncApp| {
// 1. 重い計算だけをバックグラウンドのスレッドプールに投げて .await で結果を待つ
let calc_result: u64 = cx
.background_executor()
.spawn(async move {
// ここは別スレッドで並列実行されるため、UI は 120 FPS のまま一切カクつかない
(1..=10_000_000).sum()
})
.await;
// 2. 計算結果が出たら、メインスレッド側で画面の状態を更新する
let _ = this.update(cx, |this, cx| {
this.status = format!("計算結果: {}", calc_result);
cx.notify();
});
})
.detach();
}
}
「重い計算は background_executor で別スレッドに投げ、結果が出たら this.update で画面に反映する」というパターンを覚えておけば、どれほど重い処理を行うアプリでも常に滑らかな操作感を保つことができます。
ここまでで、GPUI の基礎となる状態管理、レイアウト、イベント、非同期処理のすべてを習得しました。 次章からはいよいよ GPUI-Component(GPUI Kit) を導入し、豊富なウィジェットとテーマ機能を使って実用的な画面を素早く組み立てる方法に入っていきます。