実行中にメニュー項目を変更する(無効化・文言の書き換え)

setEnabled()・setText()(Rust は set_enabled()・set_text())で項目をグレーアウトしたり文言を変えたりする。menu.get() が直下しか探さない落とし穴と State での持ち方も示す。

メニュー・トレイ 対象: Tauri 2.x 更新日: 読了目安: 約9分 menu-006
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 作った項目を変数に持って変更する
  4. ID から項目を探す
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

「変更が無いうちは『保存』を押せなくする」「エクスポート中は項目をグレーアウトして『エクスポート中…』と出す」のように、アプリの状態に合わせてメニューを変える場面はよくあります。メニューを作り直す必要はなく、作った項目に setEnabled() / setText()(Rust は set_enabled() / set_text())を呼べば、表示中のメニューにそのまま反映されます。無効にした項目はグレーで表示され、クリックできません。つまずきやすいのは「変えたい項目をどうやって手に入れるか」なので、JS と Rust それぞれの持ち方を中心に説明します。メニュー自体の作り方は アプリケーションのメニューバーを作る を参照してください。

前提条件

プラグインは不要です。項目の変更や検索に使う権限(core:menu:allow-set-enabled・core:menu:allow-set-text・core:menu:allow-get など)はすべて core:menu:default にあり、これは core:default に含まれるので追加は要りません。

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

変えられる内容は項目の種類で決まっています。コピーや貼り付けなどの標準項目(PredefinedMenuItem)は文言しか変えられず、無効化はできません。

変えたいものJSRust使える項目
有効 / 無効setEnabled()set_enabled()通常・チェック・アイコン付きの項目、サブメニュー
文言setText()set_text()すべて(標準項目を含む)
チェックsetChecked()set_checked()チェック項目(menu-003)
ショートカットsetAccelerator()set_accelerator()通常・チェック・アイコン付きの項目(menu-004)

もう 1 つ大事なのが、メニューを作った側で変更することです。JS には「いま設定されているアプリのメニュー」を取得する API が無いので、Rust で作ったメニューは Rust で変えます(画面の操作がきっかけならコマンドを呼ぶ)。JS で作ってアプリのメニューに設定したものは、Rust からも app.menu() で取得できます。

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

作った項目を変数に持って変更する

一番確実なのは、MenuItem.new() の戻り値を変数に持っておく方法です。無効で始めるなら作成時に enabled: false を渡します(オブジェクト形式の { id, text, enabled: false } でも同じです)。

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

let dirty = false;
let exporting = false;

// 変えたい項目は new() で作り、戻り値を持っておく
const saveItem = await MenuItem.new({ id: 'save', text: '保存', enabled: false, action: () => void save() });
const exportItem = await MenuItem.new({ id: 'export', text: 'エクスポート', action: () => void exportFile() });

const file = await Submenu.new({ id: 'file', text: 'ファイル', items: [saveItem, { item: 'Separator' }, exportItem] });
await (await Menu.new({ items: [file] })).setAsAppMenu();

// 未保存の変更の有無に合わせて「保存」を切り替える
export async function setDirty(value: boolean) {
  if (value === dirty) return; // 変わったときだけ IPC を送る
  dirty = value;
  await saveItem.setEnabled(dirty);
  console.log('save enabled:', dirty);
}

async function save() {
  if (!dirty) return; // 無効化とは別に、処理側でも状態を確かめる
  // ...保存処理...
  await setDirty(false);
}

// 処理中はグレーアウトして文言も変え、終わったら必ず戻す
async function exportFile() {
  if (exporting) return; // 無効化は非同期に反映されるので、連打はフラグでも防ぐ
  exporting = true;
  await exportItem.setEnabled(false);
  await exportItem.setText('エクスポート中…');
  try {
    await new Promise((resolve) => setTimeout(resolve, 3000)); // 実際の処理の代わり
  } finally {
    await exportItem.setText('エクスポート');
    await exportItem.setEnabled(true);
    exporting = false;
  }
}

document.querySelector('textarea')?.addEventListener('input', () => void setDirty(true));

ID から項目を探す

作成時の変数が手元に無いとき(オブジェクト形式で作った、別のモジュールから触りたいなど)は ID で探します。ただし menu.get(id) は直下の項目しか探しません。「ファイル」の中の「保存」は menu.get('save') では null になるので、サブメニューの ID を順にたどります。サブメニューにも id を付けておくのが前提です。

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

// ['file', 'save'] のように、サブメニューの ID から順にたどる
export async function findItem(menu: Menu, path: string[]) {
  let parent: Menu | Submenu = menu;
  for (const id of path.slice(0, -1)) {
    const next: unknown = await parent.get(id);
    if (!(next instanceof Submenu)) return null; // 途中はサブメニューのはず
    parent = next;
  }
  return parent.get(path[path.length - 1]);
}

export async function setEnabledByPath(menu: Menu, path: string[], enabled: boolean) {
  const item = await findItem(menu, path);
  // 標準項目には setEnabled が無いので、持っている種類だけ呼ぶ
  if (item && 'setEnabled' in item) await item.setEnabled(enabled);
  else console.warn(`cannot toggle: ${path.join('/')}`);
}

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

Rust では作った項目を State に入れておくのが簡単です(State を使ってアプリの状態を管理する)。MenuItem はクローンしても同じ項目を指すハンドルで、変更メソッドは &self で呼べるため Mutex で包む必要はありません。ID でたどるなら app.menu() から get() と as_submenu() / as_menuitem() を使います。JS と同じく get() は直下しか探しません。

use tauri::menu::{MenuBuilder, MenuItem, MenuItemBuilder, SubmenuBuilder};
use tauri::{AppHandle, Manager, State, Wry};

struct FileMenu {
    save: MenuItem<Wry>,
}

/// 画面から未保存の有無を知らせてもらい、「保存」を切り替える
#[tauri::command]
fn set_dirty(menu: State<'_, FileMenu>, dirty: bool) -> Result<(), String> {
    menu.save.set_enabled(dirty).map_err(|e| e.to_string())
}

/// ID でたどる例: エクスポート中は項目を無効にして文言も変える
#[tauri::command]
fn set_exporting(app: AppHandle, exporting: bool) -> Result<(), String> {
    // get() は直下しか探さないので「ファイル」→「エクスポート」の順にたどる
    let item = app
        .menu()
        .and_then(|m| m.get("file"))
        .and_then(|file| file.as_submenu().and_then(|s| s.get("export")))
        .and_then(|i| i.as_menuitem().cloned())
        .ok_or("export item not found")?;
    item.set_text(if exporting { "エクスポート中…" } else { "エクスポート" })
        .map_err(|e| e.to_string())?;
    item.set_enabled(!exporting).map_err(|e| e.to_string())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let save = MenuItemBuilder::with_id("save", "保存")
                .enabled(false) // 変更が無いうちは押せない状態で始める
                .build(app)?;
            let file = SubmenuBuilder::with_id(app, "file", "ファイル")
                .item(&save)
                .separator()
                .text("export", "エクスポート")
                .build()?;
            app.set_menu(MenuBuilder::new(app).item(&file).build()?)?;
            app.manage(FileMenu { save });
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![set_dirty, set_exporting])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

フロントからはコマンドを呼ぶだけです。項目のクリックを Rust で受ける方法は メニューがクリックされた時の処理を書く を参照してください。

import { invoke } from '@tauri-apps/api/core';

document.querySelector('textarea')?.addEventListener('input', () => {
  void invoke('set_dirty', { dirty: true });
});

export async function runExport() {
  await invoke('set_exporting', { exporting: true });
  try {
    // ...エクスポート処理...
  } finally {
    await invoke('set_exporting', { exporting: false });
  }
}

動作確認

npm run tauri dev で起動すると、「ファイル」→「保存」はグレーで選べない状態です。テキストエリアに入力すると選べるようになり、JS 版で選ぶと保存処理の後に再びグレーに戻ります。コンソールには次のように出ます。

save enabled: true
save enabled: false

JS 版で「エクスポート」を選ぶと、3 秒間「エクスポート中…」のグレー表示になってから元に戻ります。Rust 版は runExport() を呼ぶと同じ変化になります。

よくあるエラーと対処法

  • menu.get() が null を返す: サブメニューの中の項目を最上位のメニューから探しています。get() は直下しか探さないので、サブメニューの ID を経由します。サブメニューに id を付け忘れていないかも確認します。
  • setEnabled が存在しないという趣旨の型エラー: get() の戻り値は項目の種類の共用体で、標準項目には setEnabled がありません。'setEnabled' in item や instanceof で絞り込みます。
  • 標準項目(コピーなど)を無効にできない: 標準項目は文言以外を変えられません。状態に応じて止めたい項目は、自前の MenuItem で作って処理を書きます。
  • 文言に入れた & が表示されない: 文言中の & は、次の文字をアクセスキー(ニーモニック)にする指定として扱われます。ファイル名など任意の文字列を入れるときは text.replaceAll('&', '&&') のように && にします。
  • 「menu.set_enabled not allowed. Permissions associated with this command: core:menu:allow-set-enabled, core:menu:default」: capability から core:default を外したときのデバッグビルドのエラーです。リリースビルドでは「Command plugin:menu|set_enabled not allowed by ACL」になります。core:menu:default を足します。

OS ごとの違いと注意点

  • macOS: メニューはアプリ全体で 1 つで、最上位に置けるのはサブメニューだけです。最上位に置いた項目は無視されるので、変えたい項目はサブメニューに入れます。ウィンドウ個別のメニュー(setAsWindowMenu())は使えません。
  • Windows / Linux: setAsWindowMenu() でウィンドウ個別のメニューを付けた場合、それはアプリのメニューとは別の Menu で、app.menu() では取れません。Rust では window.menu() で取得し、そのメニューの項目を変更します。
  • iOS / Android: メニューの API はデスクトップ専用です。
  • 共通: 無効化で止まるのはメニューからの操作だけです。同じ処理を呼ぶ画面上のボタンや、自前で登録したキー操作は別に止めるか、処理側で状態を確かめます。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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