GPUI Kitでのクロスプラットフォームなメニュー

#rust #gui

GPUI KitでmacOS/Windows/Linuxの3つを対象にデスクトップアプリを作るとき、メニューをどう組むかの整理。結論としては、

  1. アプリケーションメニュー(File/Edit/Help…)はgpui::Menuで一度だけ定義し、macOSではcx.set_menus()でOSのメニューバーに、Windows/LinuxではAppMenuBarコンポーネントでタイトルバーの中に描く。
  2. 右クリックメニュー・ドロップダウンは、既定ではGPUIが自前で描くPopupMenuを使う。ウィンドウの外にはみ出したいときだけNativeMenuにする。
  3. どちらもActionをdispatchする仕組みは共通なので、メニュー項目の定義自体はプラットフォームで分岐しない。分岐するのは「どこに描くか」だけ。

GPUIのset_menusはmacOSでしか実体を持たない

GPUIのApp::set_menus()はPlatform::set_menus()に委譲される。この実装がプラットフォームごとにまるで違う。

プラットフォーム set_menusの挙動
macOS NSMenuを組み立ててNSApplication::setMainMenu_に渡す。画面上部に本物のメニューバーが出る
Windows 受け取ったMenuをOwnedMenuに変換して構造体に保持するだけ。UIは一切出ない
Linux 同じく保持するだけ。UIは一切出ない

Windows/Linuxの実装(crates/gpui_linux/src/linux/platform.rs、crates/gpui_windows/src/platform.rs)は文字通りこれだけ。

fn set_menus(&self, menus: Vec<Menu>, _keymap: &Keymap) {
    self.inner.with_common(|common| {
        common.menus = menus.into_iter().map(|menu| menu.owned()).collect();
    })
}

保持された内容はcx.get_menus()で読み出せる。つまりGPUIは「メニューの定義を持っておくストア」までは面倒を見るが、macOS以外ではそれを描画する責任をアプリ側に投げている。TauriのTAOや、そのメニュー部分を担うmudaのようにOSのメニューバー抽象を持っているわけではない。

Zed本体も同じ構造で、crates/title_bar/src/application_menu.rsのApplicationMenuがcx.get_menus()を読んで自前で描いている。macOSではこれを作らない(ZED_USE_CROSS_PLATFORM_MENU環境変数を付けたときだけ描く)分岐が入っている。

flowchart TD A["build_menus() が返す gpui::Menu のリスト"] --> B["cx.set_menus()"] A --> C["GlobalState::set_app_menus()"] B -->|macOS| D["NSMenu / 画面上部のメニューバー"] B -->|Windows / Linux| E["保持されるだけ"] C --> F["AppMenuBar"] F -->|Windows / Linux| G["TitleBar内に描画"] F -->|macOS| H["表示しない<br/>(OSメニューと二重になるため)"]

AppMenuBar — Windows/Linux向けのアプリケーションメニュー

GPUI Kitはこの穴を埋めるgpui_kit::component::menu::AppMenuBarを持っている。ドキュメンテーションコメントにもそのまま「The application menu bar, for Windows and Linux.」と書かれている。

注意点として、AppMenuBarが読むのはcx.get_menus()ではなくGPUI Kit側のGlobalStateである。したがってメニューを更新するときは2箇所に同じものを流し込む必要がある。story(公式のデモアプリ)のcrates/story/src/app_menus.rsがその手本になっている。

fn update_app_menu(title: impl Into<SharedString>, app_menu_bar: Entity<AppMenuBar>, cx: &mut App) {
    let title: SharedString = title.into();

    // macOS: 本物のメニューバーを作る
    cx.set_menus(build_menus(title.clone(), cx));
    // Windows/Linux: AppMenuBarが読むストアにも同じものを入れる
    let menus = build_menus(title, cx)
        .into_iter()
        .map(|menu| menu.owned())
        .collect();
    GlobalState::global_mut(cx).set_app_menus(menus);

    app_menu_bar.update(cx, |menu_bar, cx| {
        menu_bar.reload(cx);
    })
}

メニュー定義自体は素のgpui::Menu / gpui::MenuItem。

Menu {
    name: title.into(),
    items: vec![
        MenuItem::action("About", About),
        MenuItem::Separator,
        MenuItem::Submenu(Menu {
            name: "Appearance".into(),
            items: vec![
                MenuItem::action("Light", SwitchThemeMode(ThemeMode::Light))
                    .checked(!cx.theme().mode.is_dark()),
                MenuItem::action("Dark", SwitchThemeMode(ThemeMode::Dark))
                    .checked(cx.theme().mode.is_dark()),
            ],
            disabled: false,
        }),
        MenuItem::Separator,
        MenuItem::action("Quit", Quit),
    ],
    disabled: false,
}

表示の分岐はcfg!(target_os = "macos")で

storyでは「タイトルバーの中にメニューバーを描くか」をアプリの状態として持ち、既定値をmacOSかどうかで切り替えている。macOSで両方出すとOSのメニューバーと同じものが2つ並ぶことになるので、これが基本形。

// macOS draws the app menus in the system menu bar, so an in-window
// menu bar would be a second copy of them. Off by default there,
// but still switchable so the component stays demoable on a Mac.
show_app_menu_bar: !cfg!(target_os = "macos"),

AppMenuBarはTitleBarの子として置く。

TitleBar::new()
    .child(div().flex().items_center().child(app_menu_bar.clone()))

タイトルバーを自前で描くので、ウィンドウ生成時にTitleBar::window_options()を使う(titlebarオプションと、macOS向けのapp_owns_titlebar_drag: trueがセットされる)。

動的な更新は作り直してreload()

チェック状態(テーマのLight/Dark)や、ロケール切替でのラベル変更は、Menuを組み直してupdate_app_menuを呼び直すのが公式storyのやり方。cx.observe_global::<Theme>()やcx.on_action(|s: &SelectLocale, ...|)にフックしている。メニュー項目を部分的に書き換えるAPIは無い。

フォーカスの引き継ぎ

Edit系のメニュー(Copy/Paste/Undo)をAppMenuBarから出すときの落とし穴。メニューを開くとフォーカスがメニュー側に移るので、そのままactionをdispatchすると入力欄に届かない。AppMenuBarはメニューを開く直前のwindow.focused(cx)をaction_contextとして覚えておき、PopupMenu::set_action_context()経由で「元のフォーカス先に戻してからdispatchする」ようになっている。自前でメニューバーを実装する場合はここを再発明する必要がある。

右クリックメニュー・ドロップダウン

こちらはアプリケーションメニューと別系統で、2つの選択肢がある。

PopupMenu(GPUIが描く)

ContextMenuExt::context_menu()(右クリック)とDropdownMenu::dropdown_menu()(ボタン等)が、内部で共通のPopupMenuを使う。全プラットフォームで同一の見た目・同一のテーマ・同一のキーボード操作(↑↓で項目、←→でサブメニュー、Enter/Space決定、Escape閉じる)になる。キーバインドが張られているactionには自動でショートカット表記が付く。

Button::new("menu-btn")
    .label("Open Menu")
    .dropdown_menu(|menu, window, cx| {
        menu.menu("New File", Box::new(NewFile))
            .menu("Open File", Box::new(OpenFile))
            .separator()
            .menu("Exit", Box::new(Exit))
    })

context_menu()はstd::panic::Location::caller()から安定したElementIdを作るので、明示的なIDを付けなくても再レンダリングでメニューが閉じない。

欠点はウィンドウの内側にクリップされること。小さいウィンドウの下端付近で右クリックすると、メニューが切れる。

NativeMenu(OSが描く)

その欠点を埋めるのがgpui_kit::component::native_menu::NativeMenu。ウィンドウ境界を超えて出せる。

NativeMenu::new()
    .menu("Copy", Box::new(Copy))
    .menu("Paste", Box::new(Paste))
    .separator()
    .menu("Delete", Box::new(Delete))
    .show(position, window, cx);

バックエンドはプラットフォームごとに違う。

プラットフォーム 実装
macOS objc2でNSMenuを組み、popUpMenuPositioningItem_atLocation_inViewで表示
Windows CreatePopupMenu + AppendMenuW + TrackPopupMenuEx
Linuxほか ネイティブポップアップが無いのでPopupMenuによる描画にフォールバック(native_menu/fallback.rs)。ウィンドウにクリップされる制約は戻ってくるが、APIは全プラットフォームで通る

アイコンの扱いも分かれる。macOSはテンプレート画像としてNSMenuItem::imageに、WindowsはHBITMAPにしてMENUITEMINFOW::hbmpItemに入る(SVGはresvgでラスタライズ、他形式はGDI+がデコード)。Linuxのフォールバックでは通常のIconとして描かれる。

NativeMenuはgpui::MenuからFromで変換できるので、アプリケーションメニューの定義を右クリックメニューに使い回すこともできる。ただしSystemMenu(macOSのServices)は対応物が無いので黙って落ちる。

show()はOSのトラッキングループをGPUIのコールスタックの外で回すので、開いている間GPUIをborrowしない設計になっている。

タイトルバーそのものの差異

TitleBarはOS標準のタイトルバーを置き換えるコンポーネントなので、メニューを載せる土台としてプラットフォーム差を吸収してくれる。

  • macOS — 信号機ボタン(traffic lights)はネイティブのものが(9px, 9px)に出る。そのぶん左パディングが80px入るので、AppMenuBarを左端に置くとボタンと重ならないよう寄る。ダブルクリックはwindow.titlebar_double_click()。
  • Windows — 最小化/最大化/閉じるを自前で描く(各34px幅)。WindowControlAreaでOSにスナップレイアウト等を伝える。左パディングは12px。
  • Linux — コントロールボタンを自前で描き、クリックも自前で処理する。閉じる動作はon_close_window()で差し替えられる(Linux専用API)。タイトルバー上の右クリックでwindow.show_window_menu(position)を呼び、コンポジタのウィンドウメニューを出す。

落とし穴まとめ

  • cx.set_menus()だけ書いてWindows/Linuxで「メニューが出ない」となるのが最初の罠。無視されているのではなく、描画する側が居ないだけ。
  • GPUI KitのAppMenuBarはcx.get_menus()ではなくGlobalStateを見るので、cx.set_menus()とGlobalState::set_app_menus()の両方を呼ぶ。片方だけだとmacOSとそれ以外で内容がずれる。
  • MenuItem::os_action(name, action, OsAction::Copy)のOsAction(Cut/Copy/Paste/SelectAll/Undo/Redo)はmacOSのメニュー構築でしか参照されない。MenuItem::SystemMenu(OsMenu { menu_type: SystemMenuType::Services })も同様にmacOS専用で、AppMenuBar側の変換ではOwnedMenuItem::SystemMenu(_) => {}と読み飛ばされる。
  • 逆にmacOSに無いものもある。cx.set_dock_menu()はmacOSではDockアイコンの右クリックメニュー、Windowsではタスクバーのジャンプリストになり、Linuxでは// todo(linux)で未実装。
  • Linuxにはグローバルメニュー(Unity/KDEのDBus appmenu)への対応が無い。GNOME/KDEのパネル側にメニューを出したいという要求は現状GPUIでは満たせず、ウィンドウ内に描くしかない。コミュニティフォークのGPUI Community Edition (gpui-ce)でもここは埋まっていない。
  • ドキュメントサイトの例はAppMenuBar::new(window, cx)になっているが、実際のシグネチャはAppMenuBar::new(cx: &mut App)(0.6.0時点)。

出典

作成日時: 2026-09-05 14:53 / 更新日時: 2026-09-06 23:14