自作のタイトルバーにメニューを組み込む

自作タイトルバーに「ファイル」「編集」のボタンを置き、Submenu の popup() でボタンの真下に OS 標準のメニューを開く。座標の単位、入力欄のフォーカスの保ち方、macOS での置き場所も示す。

メニュー・トレイ 対象: Tauri 2.x 更新日: 読了目安: 約10分 menu-017
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 見出しのボタンを並べる
  4. ボタンの真下にメニューを開く
  5. キー操作を足す
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

decorations: false で標準のタイトルバーを消して自作のバーにすると(フレームレスウィンドウ)、メニューもその中に並べたくなります。ところが Tauri のメニューバー(menu-001)はページの外側に OS が描くもので、HTML の自作バーの中には置けません。そこで自作バーに「ファイル」「編集」のボタンを置き、押されたら Tauri のメニューをボタンの真下にポップアップで開きます。バーはページの一部のまま、開くメニューは OS 標準のものになり、コピーや貼り付けなどの標準項目もそのまま使えます。ドラッグで動かす領域と、最小化・閉じるなどのボタンは win-009 と win-006 で作ったものを使います。

前提条件

メニューの作成と表示の権限(core:menu:allow-new / core:menu:allow-popup)は core:menu:default に含まれ、それが core:default に含まれるので追加は要りません。移動や最小化などの権限は win-009 の表のとおり足します。下の例では、macOS で自作のメニューを隠す判定に OS プラグイン(npm run tauri add os)の platform() を使うので、os:default も入れています。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "core:window:allow-start-dragging",
    "core:window:allow-minimize",
    "core:window:allow-toggle-maximize",
    "core:window:allow-close",
    "os:default"
  ]
}

1. フロントエンドから実装する (TypeScript)

見出しのボタンを並べる

<button> は data-tauri-drag-region の付いた親の中に置いてもドラッグの対象から外れるので、そのまま押せます。

<header class="titlebar" data-tauri-drag-region>
  <nav class="menubar">
    <button type="button" data-menu="file">ファイル(F)</button>
    <button type="button" data-menu="edit">編集(E)</button>
  </nav>
  <span class="titlebar-title" data-tauri-drag-region>My Editor</span>
  <!-- 最小化・最大化・閉じるのボタンは win-009 と同じ -->
</header>
<textarea></textarea>
.titlebar { display: flex; align-items: center; height: 32px; user-select: none; }
.menubar button { height: 32px; padding: 0 10px; border: 0; background: transparent; font: inherit; }
.menubar button:hover { background: rgba(0, 0, 0, 0.08); }
.titlebar-title { flex: 1; text-align: center; }
body.native-menu .menubar { display: none; }

ボタンの真下にメニューを開く

メニューは起動時に 1 回だけ作り、開くたびに使い回します。押すたびに作り直すと、チェックや無効化の状態を保てません。見出しごとに Submenu を作っておけば、その popup() で中身だけを開けます。

popup() に位置を渡さないと、メニューはマウスの位置に開きます。ボタンの真下にそろえるには、getBoundingClientRect() の左端と下端を LogicalPosition で渡します。位置はウィンドウの左上が基準で、CSS の px は(ページをズームしていなければ)論理ピクセルと同じ単位です。PhysicalPosition に渡すと、拡大率 150% の画面ではずれます。

import { Menu, Submenu } from '@tauri-apps/api/menu';
import { LogicalPosition } from '@tauri-apps/api/dpi';
import { platform } from '@tauri-apps/plugin-os';

// 実際の処理は id で振り分ける(menu-005)
export const run = (id: string) => console.log('menu:', id);
const nativeMenu = platform() === 'macos'; // macOS は画面上部のメニューバーを使う

// 起動時に 1 回だけ作り、開くたびに使い回す
export const menus: Record<string, Submenu> = {
  file: await Submenu.new({
    text: 'ファイル',
    items: [
      { id: 'file:new', text: '新規作成', accelerator: 'CmdOrCtrl+N', action: run },
      { id: 'file:open', text: '開く...', accelerator: 'CmdOrCtrl+O', action: run },
      { item: 'Separator' },
      { id: 'file:quit', text: '終了', action: run },
    ],
  }),
  edit: await Submenu.new({
    text: '編集',
    items: [
      { item: 'Cut', text: '切り取り' },
      { item: 'Copy', text: 'コピー' },
      { item: 'Paste', text: '貼り付け' },
      { item: 'Separator' },
      { item: 'SelectAll', text: 'すべて選択' },
    ],
  }),
};

export async function openMenu(name: string) {
  const menu = menus[name];
  const button = document.querySelector<HTMLElement>(`[data-menu="${name}"]`);
  if (!menu || !button) return;
  const r = button.getBoundingClientRect();
  await menu.popup(new LogicalPosition(r.left, r.bottom)); // ウィンドウ左上からの論理ピクセル
}

for (const button of document.querySelectorAll<HTMLButtonElement>('[data-menu]')) {
  // 押してもフォーカスを入力欄に残す(編集メニューのコピーなどが効くように)
  button.addEventListener('mousedown', (e) => e.preventDefault());
  button.addEventListener('click', () => void openMenu(button.dataset.menu ?? ''));
}

// macOS は画面上部のメニューバーに置き、自作のボタンは隠す
if (nativeMenu) {
  document.body.classList.add('native-menu');
  const appMenu = await Submenu.new({ text: 'My Editor', items: [{ item: 'Quit' }] }); // 先頭はアプリ名の位置に入る
  const bar = await Menu.new({ items: [appMenu, menus.file, menus.edit] });
  await bar.setAsAppMenu();
}

ボタンの mousedown で既定の動作を止めているのは、押した瞬間にフォーカスが入力欄からボタンへ移るのを防ぐためです。移ってしまうと、編集メニューの「コピー」などが、それまで選択していた入力欄に効きません。標準項目の Undo / Redo は Windows と Linux では使えないので、元に戻す操作が要るなら自前の項目にしてエディターの処理を呼びます(menu-007)。

キー操作を足す

標準のメニューバーが無いので、Alt+F で「ファイル」を開く操作は自分で作ります。また、ショートカットキーが効くのはウィンドウやアプリのメニューとして付けたメニューで、ポップアップで開くだけのメニューの accelerator はキーの表示にとどまります。ショートカットはページの keydown で同じ処理を呼びます。

// (続き)
const altKeys: Record<string, string> = { KeyF: 'file', KeyE: 'edit' };
const ctrlKeys: Record<string, string> = { KeyN: 'file:new', KeyO: 'file:open' };

window.addEventListener('keydown', (e) => {
  if (nativeMenu) return; // macOS はアプリのメニューとして付けたのでショートカットもそちらで効く
  if (e.altKey && !e.ctrlKey && altKeys[e.code]) {
    e.preventDefault();
    void openMenu(altKeys[e.code]);
  } else if (e.ctrlKey && !e.altKey && ctrlKeys[e.code]) {
    e.preventDefault();
    run(ctrlKeys[e.code]);
  }
});

2. バックエンドから実装する (Rust)

メニューを Rust で持つと、項目の処理を on_menu_event にまとめられ、macOS ではそのままアプリのメニューに使えます。ページからは「どの見出しを、どこに開くか」だけを送り、Rust の popup_menu_at() で開きます。コマンド経由なので JS 側のメニューの権限は関係ありません。

use tauri::menu::{Submenu, SubmenuBuilder};
use tauri::{LogicalPosition, Manager, State, Window, Wry};

/// 自作バーの見出しごとのメニュー
struct TitlebarMenus {
    file: Submenu<Wry>,
    edit: Submenu<Wry>,
}

/// x, y はウィンドウ左上からの論理ピクセル(CSS の px をそのまま渡す)
#[tauri::command]
async fn open_titlebar_menu(
    window: Window,
    menus: State<'_, TitlebarMenus>,
    name: String,
    x: f64,
    y: f64,
) -> Result<(), String> {
    let menu = match name.as_str() {
        "file" => &menus.file,
        "edit" => &menus.edit,
        _ => return Err(format!("unknown menu: {name}")),
    };
    window
        .popup_menu_at(menu, LogicalPosition::new(x, y))
        .map_err(|e| e.to_string())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let file = SubmenuBuilder::new(app, "ファイル")
                .text("file:new", "新規作成")
                .text("file:open", "開く...")
                .separator()
                .text("file:quit", "終了")
                .build()?;
            let edit = SubmenuBuilder::new(app, "編集")
                .cut_with_text("切り取り")
                .copy_with_text("コピー")
                .paste_with_text("貼り付け")
                .separator()
                .select_all_with_text("すべて選択")
                .build()?;

            // macOS では同じメニューを画面上部のメニューバーに置く(先頭はアプリ名のメニュー)
            #[cfg(target_os = "macos")]
            {
                let app_menu = SubmenuBuilder::new(app, "My Editor").about(None).separator().quit().build()?;
                let bar = tauri::menu::MenuBuilder::new(app).items(&[&app_menu, &file, &edit]).build()?;
                app.set_menu(bar)?;
            }

            app.manage(TitlebarMenus { file, edit });
            Ok(())
        })
        .on_menu_event(|app, event| match event.id().as_ref() {
            "file:quit" => app.exit(0),
            id => println!("menu: {id}"), // 画面側で処理するなら emit で知らせる(menu-005)
        })
        .invoke_handler(tauri::generate_handler![open_titlebar_menu])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';

for (const button of document.querySelectorAll<HTMLButtonElement>('[data-menu]')) {
  button.addEventListener('mousedown', (e) => e.preventDefault());
  button.addEventListener('click', () => {
    const r = button.getBoundingClientRect();
    void invoke('open_titlebar_menu', { name: button.dataset.menu, x: r.left, y: r.bottom });
  });
}

Windows と Linux では、アプリのメニューを設定すると、メニューを個別に付けていない各ウィンドウにも割り当てられます。自作バーのメニューと役割が重なるので、例では set_menu() を macOS のときだけ呼んでいます。

動作確認

npm run tauri dev で起動すると、自作バーに「ファイル(F)」「編集(E)」が並びます。押すと、ボタンの左下の角に合わせてメニューが開き、拡大率を変えた画面でも位置はずれません。テキストエリアで文字を選び、「編集」→「コピー」、別の場所で「貼り付け」を選ぶと貼り付けられます。Alt+F で「ファイル」が開き、「新規作成」を選ぶか Ctrl+N を押すと、DevTools のコンソールに次のように出ます(Rust 版で項目を選んだときはターミナルに出ます)。

menu: file:new

macOS では自作のボタンが消え、同じ項目が画面上部のメニューバーに出ます。

よくあるエラーと対処法

  • メニューがボタンではなくマウスの位置に開く: popup() に位置を渡していません。
  • 拡大率 150% などでメニューの位置がずれる: CSS の値を PhysicalPosition で渡しています。LogicalPosition にします。
  • 編集メニューのコピーや貼り付けが効かない: ボタンを押したときに入力欄からフォーカスが外れています。ボタンの mousedown で preventDefault() します。Linux では linux-libxdo 機能も要ります(menu-007)。
  • メニューに表示したショートカットを押しても反応しない: ポップアップで開くだけのメニューでは表示だけです。keydown で処理します。
  • 「menu.popup not allowed. Permissions associated with this command: core:menu:allow-popup, core:menu:default」: capability から core:default を外しています(リリースビルドでは「Command plugin:menu|popup not allowed by ACL」)。core:menu:default を足します。

OS ごとの違いと注意点

  • macOS: メニューは画面上部のメニューバーに 1 つ置くのが標準で、ウィンドウの中に並べる習慣はありません。例では自作のボタンを隠し、同じ項目を setAsAppMenu()(Rust は set_menu())で置いています。信号機のボタンを残すなら titleBarStyle: "Overlay" にして左端を空けます(win-009)。先頭のサブメニューはアプリ名のメニューになります(Windows と macOS でメニューを出し分ける)。
  • Windows / Linux: アプリのメニューを設定しないでおきます。自作バーを使うウィンドウとは別に標準のメニューバー付きのウィンドウも持つなら、そのウィンドウにだけ setAsWindowMenu() で付けます。
  • 見た目: 開くメニューの色や文字は OS のもので、CSS では変えられません。ページと同じ見た目にしたいなら HTML でドロップダウンを作りますが、その場合はウィンドウの外にはみ出して表示できません。
  • ページ内の右クリックで同じ仕組みを使うなら 右クリックメニュー(コンテキストメニュー)を出す、見出しの作り方全般は アプリケーションのメニューバーを作る を参照してください。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する