チェックボックス付きのメニュー項目を作る

CheckMenuItem(JS)と CheckMenuItemBuilder(Rust)でオン・オフを持つ項目を作る。クリック後の状態の読み方、Rust だけ既定がオンになる落とし穴、ラジオボタン風の排他選択も示す。

メニュー・トレイ 対象: Tauri 2.x 更新日: 読了目安: 約9分 menu-003
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 作って、クリック後の状態を読む
  4. ラジオボタンのように 1 つだけ選ばせる
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

「ツールバーを表示」「行番号を表示」のようにオン・オフを持つ設定は、チェック付きのメニュー項目(CheckMenuItem)で作ります。メニューは tauri.conf.json では定義できないので、JS なら CheckMenuItem.new()、Rust なら CheckMenuItemBuilder でコードから作ります。クリックされるとチェックは自動で切り替わるため、アプリ側は切り替わった後の状態を読んで反映するだけです。メニューバー自体の組み立ては アプリケーションのメニューバーを作る、クリック処理の書き方全般は メニューがクリックされた時の処理を書く を参照してください。

前提条件

プラグインは不要です。メニューの作成とチェック状態の読み書きに使う権限はすべて core:menu:default にあり、これは core:default に含まれるので追加は要りません。capability から core:default を外している場合は、core:menu:default(または core:menu:allow-new・core:menu:allow-set-as-app-menu・core:menu:allow-is-checked・core:menu:allow-set-checked など個別の権限)を足します。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default"
  ]
}

作り方はいくつかあり、checked を省略したときの初期状態が JS と Rust で逆になっています。

作り方checked を省略したとき
JS の CheckMenuItem.new({ text })オフ
JS のオブジェクト形式 { id, text, checked }チェック項目にならず、普通の項目になる
Rust の CheckMenuItemBuilder::with_id()オン
Rust の MenuBuilder / SubmenuBuilder の .check(id, text)常にオン(指定する引数が無い)
Rust の CheckMenuItem::with_id(app, id, text, enabled, checked, None::<&str>)省略できない

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

作って、クリック後の状態を読む

クリックで呼ばれる action に渡されるのは項目の ID だけなので、状態は項目の isChecked() で読みます。action が呼ばれた時点でチェックはもう切り替わっているため、読んだ値をそのまま反映します。オブジェクト形式で作った項目はインスタンスが手元に残らないので、状態を読む項目は CheckMenuItem.new() で作って変数に持っておきます。

import { CheckMenuItem, Menu, Submenu } from '@tauri-apps/api/menu';

const KEY = 'show-toolbar';

function applyToolbar(visible: boolean) {
  const bar = document.querySelector<HTMLElement>('#toolbar');
  if (bar) bar.hidden = !visible;
  localStorage.setItem(KEY, String(visible));
  console.log('toolbar:', visible);
}

export async function setupViewMenu() {
  const initial = localStorage.getItem(KEY) !== 'false'; // 保存が無ければ表示
  const toolbarItem = await CheckMenuItem.new({
    id: 'view-toolbar',
    text: 'ツールバーを表示',
    checked: initial, // 前回の設定から初期状態を決める
    action: async () => {
      // 呼ばれた時点でチェックは切り替わっている。その値を反映する
      applyToolbar(await toolbarItem.isChecked());
    },
  });
  applyToolbar(initial);

  const view = await Submenu.new({ text: '表示', items: [toolbarItem] });
  const menu = await Menu.new({ items: [view] });
  await menu.setAsAppMenu();
  // 画面のボタンなど別の場所で切り替えたときは toolbarItem.setChecked() でメニューを揃える
  return toolbarItem;
}

ラジオボタンのように 1 つだけ選ばせる

Tauri のメニューにはラジオボタン型の項目がありません。「ライト / ダーク / システム」のような排他選択は、チェック項目を並べてクリックのたびに選ばれた 1 つだけをオンにし直します。選択中の項目をもう一度クリックするとチェックが外れてしまうので、クリックされた項目自身にも setChecked(true) を呼んで戻すのがポイントです。

import { CheckMenuItem, Menu, Submenu } from '@tauri-apps/api/menu';

type Theme = 'light' | 'dark' | 'system';
const LABELS: Record<Theme, string> = { light: 'ライト', dark: 'ダーク', system: 'システムに合わせる' };

export async function setupThemeMenu(current: Theme) {
  const items = new Map<Theme, CheckMenuItem>();
  for (const theme of Object.keys(LABELS) as Theme[]) {
    items.set(theme, await CheckMenuItem.new({
      id: `theme-${theme}`,
      text: LABELS[theme],
      checked: theme === current,
      action: async () => {
        // 選ばれた項目だけオンにする(選択中を再クリックして外れた場合も戻る)
        for (const [t, item] of items) await item.setChecked(t === theme);
        document.documentElement.dataset.theme = theme;
      },
    }));
  }
  const menu = await Menu.new({
    items: [await Submenu.new({ text: 'テーマ', items: [...items.values()] })],
  });
  await menu.setAsAppMenu();
}

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

Rust の on_menu_event にも ID しか届かないので、状態を読むには項目のハンドルが要ります。作った項目を app.manage() で State に入れておけば、ハンドラーやコマンドから取り出せます。項目はクローンしても同じ項目を指すハンドルなので、Mutex で包む必要はありません。初期状態は表のとおり既定がオンなので、オフで始めうる項目には checked() を必ず書きます。起動時に保存済みの設定から初期値を決めるなら、Rust からも読める Store プラグイン に保存しておくと扱いやすくなります。

use tauri::menu::{CheckMenuItem, CheckMenuItemBuilder, MenuBuilder, SubmenuBuilder};
use tauri::{Emitter, Manager, Wry};

struct ViewMenu {
    toolbar: CheckMenuItem<Wry>,
    themes: Vec<(&'static str, CheckMenuItem<Wry>)>,
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let saved_theme = "system"; // 実際は保存した設定から読む
            let toolbar = CheckMenuItemBuilder::with_id("view-toolbar", "ツールバーを表示")
                .checked(true) // 既定もオンだが、意図を明示しておく
                .build(app)?;
            let mut themes = Vec::new();
            for (id, label) in [("light", "ライト"), ("dark", "ダーク"), ("system", "システムに合わせる")] {
                let item = CheckMenuItemBuilder::with_id(format!("theme-{id}"), label)
                    .checked(id == saved_theme) // false も必ず渡す
                    .build(app)?;
                themes.push((id, item));
            }
            let mut view = SubmenuBuilder::new(app, "表示").item(&toolbar).separator();
            for (_, item) in &themes {
                view = view.item(item);
            }
            let menu = MenuBuilder::new(app).item(&view.build()?).build()?;
            app.set_menu(menu)?;
            app.manage(ViewMenu { toolbar, themes });
            Ok(())
        })
        .on_menu_event(|app, event| {
            let view = app.state::<ViewMenu>();
            let id: &str = event.id().as_ref();
            if id == "view-toolbar" {
                // ここでもチェックは切り替わった後の状態
                let visible = view.toolbar.is_checked().unwrap_or(true);
                let _ = app.emit("toolbar-visibility", visible);
            } else if let Some(theme) = id.strip_prefix("theme-") {
                for (t, item) in &view.themes {
                    let _ = item.set_checked(*t == theme);
                }
                let _ = app.emit("theme-changed", theme);
            }
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

画面への反映は、Rust から送ったイベントをフロントで受け取って行います(Rust からのイベントを受信する)。

import { listen } from '@tauri-apps/api/event';

await listen<boolean>('toolbar-visibility', (e) => {
  const bar = document.querySelector<HTMLElement>('#toolbar');
  if (bar) bar.hidden = !e.payload;
});
await listen<string>('theme-changed', (e) => {
  document.documentElement.dataset.theme = e.payload;
});

動作確認

ページに id="toolbar" の要素を置き、npm run tauri dev で起動します。「表示」→「ツールバーを表示」を選ぶとチェックが外れてツールバーが隠れ、もう一度選ぶと戻ります。JS 版は localStorage に保存しているので、再起動しても最後の状態で開きます。DevTools のコンソールには次のように出ます。

toolbar: true
toolbar: false
toolbar: true

テーマの項目で「ダーク」を選ぶと他の 2 つのチェックが外れ、選択中の「ダーク」をもう一度選んでもチェックは付いたままになります。

よくあるエラーと対処法

  • Rust で作った項目が最初からチェック済みになる: CheckMenuItemBuilder の既定と .check(id, text) はオンです。オフで始めるなら CheckMenuItemBuilder::with_id(...).checked(false) を使います。
  • オブジェクト形式で書いた項目にチェックが付かない: checked を省略すると普通の項目になります。オフで始める場合も checked: false と書きます。
  • 排他選択のつもりが、全部のチェックが外れる: 選択中の項目をクリックすると自動でオフになります。クリックされた項目にも setChecked(true) を呼び直します。
  • Rust のハンドラーでチェック状態が分からない: on_menu_event に届くのは ID だけです。項目を State に入れておくか、app.menu() から ID でたどります(実行中にメニュー項目を変更する(無効化・文言の書き換え))。
  • 「menu.new not allowed. Permissions associated with this command: core:menu:allow-new, core:menu:default」: capability から core:default を外したときのデバッグビルドのエラーです。リリースビルドでは「Command plugin:menu|new not allowed by ACL」だけになります。core:menu:default を足して tauri dev を再起動します。

OS ごとの違いと注意点

  • macOS: メニューバーの最上位に置けるのはサブメニューだけです。最上位に置いたチェック項目は無視されるので、必ずサブメニューに入れます。また最初のサブメニューは名前に関係なくアプリ名のメニューの位置に置かれるため、上の例の「表示」だけのメニューでは中身がアプリ名のメニューに入ります。先頭にアプリ用のサブメニューを置きます。
  • Windows / Linux: メニューはウィンドウごとのメニューバーとして表示されます。JS で作るとページの読み込み後に付くので、起動直後から出しておきたいなら Rust の setup で作ります。
  • iOS / Android: メニューの API はデスクトップ専用です。
  • 共通: チェック項目は右クリックメニューやトレイアイコンのメニューでも同じ API で使えます。チェック状態は再起動で元に戻るので、保存と復元はアプリ側で行います。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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