「変更が無いうちは『保存』を押せなくする」「エクスポート中は項目をグレーアウトして『エクスポート中…』と出す」のように、アプリの状態に合わせてメニューを変える場面はよくあります。メニューを作り直す必要はなく、作った項目に 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)は文言しか変えられず、無効化はできません。
| 変えたいもの | JS | Rust | 使える項目 |
|---|---|---|---|
| 有効 / 無効 | 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 はデスクトップ専用です。
- 共通: 無効化で止まるのはメニューからの操作だけです。同じ処理を呼ぶ画面上のボタンや、自前で登録したキー操作は別に止めるか、処理側で状態を確かめます。
