Files
tolaria/src-tauri/src/menu.rs
2026-05-26 14:56:39 +02:00

684 lines
21 KiB
Rust

#[cfg(not(target_os = "macos"))]
use crate::window_state::MAIN_WINDOW_LABEL;
use serde::{Deserialize, Deserializer};
use std::{
borrow::Cow,
collections::{BTreeMap, HashSet},
error::Error,
sync::OnceLock,
};
#[cfg(not(target_os = "macos"))]
use tauri::{menu::MenuEvent, Manager};
use tauri::{
menu::{
MenuBuilder, MenuItem, MenuItemBuilder, MenuItemKind, Submenu, SubmenuBuilder,
WINDOW_SUBMENU_ID,
},
App, AppHandle, Emitter,
};
const APP_COMMAND_MANIFEST_JSON: &str = include_str!("../../src/shared/appCommandManifest.json");
const NOTE_DEPENDENT_GROUP: &str = "noteDependent";
const EDITOR_FIND_DEPENDENT_GROUP: &str = "editorFindDependent";
const NOTE_LIST_SEARCH_DEPENDENT_GROUP: &str = "noteListSearchDependent";
const RESTORE_DELETED_DEPENDENT_GROUP: &str = "restoreDeletedDependent";
const GIT_COMMIT_DEPENDENT_GROUP: &str = "gitCommitDependent";
const GIT_CONFLICT_DEPENDENT_GROUP: &str = "gitConflictDependent";
const GIT_NO_REMOTE_DEPENDENT_GROUP: &str = "gitNoRemoteDependent";
type MenuResult = Result<Submenu<tauri::Wry>, Box<dyn Error>>;
type AppSubmenuBuilder<'a> = SubmenuBuilder<'a, tauri::Wry, App>;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AppCommandManifest {
commands: BTreeMap<String, ManifestCommand>,
menus: Vec<ManifestMenuSection>,
app_menu: Vec<ManifestMenuItem>,
menu_state_groups: BTreeMap<String, Vec<MenuStateGroupReference>>,
}
#[derive(Debug, Deserialize)]
struct ManifestCommand {
id: String,
shortcut: Option<ManifestShortcut>,
}
#[derive(Debug, Deserialize)]
struct ManifestShortcut {
accelerator: String,
}
#[derive(Debug, Deserialize)]
struct ManifestMenuSection {
label: String,
items: Vec<ManifestMenuItem>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "kind")]
enum ManifestMenuItem {
#[serde(rename = "separator")]
Separator,
#[serde(rename = "command")]
Command {
command: String,
id: Option<String>,
label: PlatformLabel,
#[serde(default, deserialize_with = "deserialize_accelerator")]
accelerator: ManifestAccelerator,
enabled: Option<bool>,
},
#[serde(rename = "menu-event")]
MenuEvent {
id: String,
label: PlatformLabel,
#[serde(default, deserialize_with = "deserialize_accelerator")]
accelerator: ManifestAccelerator,
enabled: Option<bool>,
},
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PlatformLabel {
Plain(String),
Platform {
macos: Option<String>,
windows: Option<String>,
linux: Option<String>,
default: String,
},
}
#[derive(Debug, Default)]
enum ManifestAccelerator {
#[default]
Inherit,
Suppressed,
Explicit(String),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum MenuStateGroupReference {
Command { command: String },
Id { id: String },
}
fn deserialize_accelerator<'de, D>(deserializer: D) -> Result<ManifestAccelerator, D::Error>
where
D: Deserializer<'de>,
{
Option::<String>::deserialize(deserializer).map(|accelerator| match accelerator {
Some(accelerator) => ManifestAccelerator::Explicit(accelerator),
None => ManifestAccelerator::Suppressed,
})
}
impl PlatformLabel {
fn resolve(&self, target_os: &str) -> &str {
match self {
Self::Plain(label) => label.as_str(),
Self::Platform {
macos,
windows,
linux,
default,
} => match target_os {
"macos" => macos.as_deref().unwrap_or(default.as_str()),
"windows" => windows.as_deref().unwrap_or(default.as_str()),
"linux" => linux.as_deref().unwrap_or(default.as_str()),
_ => default.as_str(),
},
}
}
}
impl ManifestMenuItem {
fn command_id<'a>(&'a self, manifest: &'a AppCommandManifest) -> Option<&'a str> {
match self {
Self::Command { command, .. } => manifest
.commands
.get(command)
.map(|command| command.id.as_str()),
Self::MenuEvent { id, .. } => Some(id.as_str()),
Self::Separator => None,
}
}
fn menu_item_id<'a>(&'a self, manifest: &'a AppCommandManifest) -> Option<&'a str> {
match self {
Self::Command { command, id, .. } => id.as_deref().or_else(|| {
manifest
.commands
.get(command)
.map(|command| command.id.as_str())
}),
Self::MenuEvent { id, .. } => Some(id.as_str()),
Self::Separator => None,
}
}
fn label(&self, target_os: &str) -> Option<&str> {
match self {
Self::Command { label, .. } | Self::MenuEvent { label, .. } => {
Some(label.resolve(target_os))
}
Self::Separator => None,
}
}
fn accelerator<'a>(&'a self, manifest: &'a AppCommandManifest) -> Option<&'a str> {
match self {
Self::Command {
command,
accelerator,
..
} => match accelerator {
ManifestAccelerator::Explicit(accelerator) => Some(accelerator.as_str()),
ManifestAccelerator::Suppressed => None,
ManifestAccelerator::Inherit => manifest
.commands
.get(command)
.and_then(|command| command.shortcut.as_ref())
.map(|shortcut| shortcut.accelerator.as_str()),
},
Self::MenuEvent { accelerator, .. } => match accelerator {
ManifestAccelerator::Explicit(accelerator) => Some(accelerator.as_str()),
ManifestAccelerator::Suppressed | ManifestAccelerator::Inherit => None,
},
Self::Separator => None,
}
}
fn enabled(&self) -> bool {
match self {
Self::Command { enabled, .. } | Self::MenuEvent { enabled, .. } => {
enabled.unwrap_or(true)
}
Self::Separator => true,
}
}
}
static APP_COMMAND_MANIFEST: OnceLock<AppCommandManifest> = OnceLock::new();
static CUSTOM_MENU_IDS: OnceLock<HashSet<String>> = OnceLock::new();
fn manifest() -> &'static AppCommandManifest {
APP_COMMAND_MANIFEST.get_or_init(|| {
serde_json::from_str(APP_COMMAND_MANIFEST_JSON)
.expect("shared app command manifest must be valid JSON")
})
}
fn manifest_menu_items() -> impl Iterator<Item = &'static ManifestMenuItem> {
let manifest = manifest();
manifest
.menus
.iter()
.flat_map(|section| section.items.iter())
.chain(manifest.app_menu.iter())
}
fn custom_menu_ids() -> &'static HashSet<String> {
CUSTOM_MENU_IDS.get_or_init(|| {
manifest_menu_items()
.filter_map(|item| item.menu_item_id(manifest()))
.map(str::to_owned)
.collect()
})
}
fn manifest_section(label: &str) -> Result<&'static ManifestMenuSection, Box<dyn Error>> {
manifest()
.menus
.iter()
.find(|section| section.label == label)
.ok_or_else(|| format!("Missing menu section in command manifest: {label}").into())
}
fn app_menu_includes_services(target_os: &str) -> bool {
target_os == "macos"
}
fn window_menu_event_handler_required(target_os: &str) -> bool {
target_os != "macos"
}
fn native_window_menu_submenu_id(target_os: &str) -> Option<&'static str> {
if target_os == "macos" {
Some(WINDOW_SUBMENU_ID)
} else {
None
}
}
fn native_menu_label(label: &str) -> Cow<'_, str> {
if label.contains('&') {
Cow::Owned(label.replace('&', "&&"))
} else {
Cow::Borrowed(label)
}
}
fn build_manifest_menu_item(
app: &App,
item: &ManifestMenuItem,
) -> Result<Option<MenuItem<tauri::Wry>>, Box<dyn Error>> {
let Some(id) = item.menu_item_id(manifest()) else {
return Ok(None);
};
let Some(label) = item.label(std::env::consts::OS) else {
return Ok(None);
};
let label = native_menu_label(label);
let mut builder = MenuItemBuilder::new(label.as_ref())
.id(id)
.enabled(item.enabled());
if let Some(accelerator) = item.accelerator(manifest()) {
builder = builder.accelerator(accelerator);
}
Ok(Some(builder.build(app)?))
}
fn append_manifest_item<'a>(
app: &'a App,
builder: AppSubmenuBuilder<'a>,
item: &ManifestMenuItem,
) -> Result<AppSubmenuBuilder<'a>, Box<dyn Error>> {
if matches!(item, ManifestMenuItem::Separator) {
return Ok(builder.separator());
}
let Some(item) = build_manifest_menu_item(app, item)? else {
return Ok(builder);
};
Ok(builder.item(&item))
}
fn build_manifest_menu(app: &App, label: &str) -> MenuResult {
let section = manifest_section(label)?;
let mut builder = SubmenuBuilder::new(app, section.label.as_str());
for item in &section.items {
builder = append_manifest_item(app, builder, item)?;
}
Ok(builder.build()?)
}
fn build_app_menu(app: &App) -> MenuResult {
let mut builder = SubmenuBuilder::new(app, "Tolaria").about(None).separator();
for item in &manifest().app_menu {
builder = append_manifest_item(app, builder, item)?;
}
builder = builder.separator();
if app_menu_includes_services(std::env::consts::OS) {
builder = builder
.services()
.separator()
.hide()
.hide_others()
.show_all()
.separator();
}
Ok(builder.quit().build()?)
}
fn build_file_menu(app: &App) -> MenuResult {
build_manifest_menu(app, "File")
}
fn build_edit_menu(app: &App) -> MenuResult {
let section = manifest_section("Edit")?;
let mut items = section.items.iter();
let mut builder = SubmenuBuilder::new(app, "Edit");
for item in items.by_ref() {
if matches!(item, ManifestMenuItem::Separator) {
break;
}
builder = append_manifest_item(app, builder, item)?;
}
builder = builder.separator().cut().copy().paste();
if let Some(paste_plain_text) = items.next() {
builder = append_manifest_item(app, builder, paste_plain_text)?;
}
builder = builder.separator().select_all().separator();
if matches!(items.clone().next(), Some(ManifestMenuItem::Separator)) {
items.next();
}
for item in items {
builder = append_manifest_item(app, builder, item)?;
}
Ok(builder.build()?)
}
fn build_view_menu(app: &App) -> MenuResult {
build_manifest_menu(app, "View")
}
fn build_go_menu(app: &App) -> MenuResult {
build_manifest_menu(app, "Go")
}
fn build_note_menu(app: &App) -> MenuResult {
build_manifest_menu(app, "Note")
}
fn build_vault_menu(app: &App) -> MenuResult {
build_manifest_menu(app, "Vault")
}
fn build_window_menu(app: &App) -> MenuResult {
let mut builder = SubmenuBuilder::new(app, "Window");
if let Some(id) = native_window_menu_submenu_id(std::env::consts::OS) {
builder = builder.id(id);
}
Ok(builder
.minimize()
.maximize()
.separator()
.close_window()
.build()?)
}
pub fn setup_menu(app: &App) -> Result<(), Box<dyn Error>> {
let app_menu = build_app_menu(app)?;
let file_menu = build_file_menu(app)?;
let edit_menu = build_edit_menu(app)?;
let view_menu = build_view_menu(app)?;
let go_menu = build_go_menu(app)?;
let note_menu = build_note_menu(app)?;
let vault_menu = build_vault_menu(app)?;
let window_menu = build_window_menu(app)?;
let menu = MenuBuilder::new(app)
.item(&app_menu)
.item(&file_menu)
.item(&edit_menu)
.item(&view_menu)
.item(&go_menu)
.item(&note_menu)
.item(&vault_menu)
.item(&window_menu)
.build()?;
app.set_menu(menu)?;
app.on_menu_event(|app_handle, event| {
let id = event.id().0.as_str();
let _ = emit_custom_menu_event(app_handle, id);
});
register_window_menu_event_handler(app)?;
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn register_window_menu_event_handler(app: &App) -> Result<(), Box<dyn Error>> {
debug_assert!(window_menu_event_handler_required(std::env::consts::OS));
let window = app.get_webview_window(MAIN_WINDOW_LABEL).ok_or_else(|| {
format!("setup_menu: window '{MAIN_WINDOW_LABEL}' not found; menu events will not fire")
})?;
let app_handle = app.handle().clone();
window.on_menu_event(move |_window, event: MenuEvent| {
let id = event.id().0.as_str();
let _ = emit_custom_menu_event(&app_handle, id);
});
Ok(())
}
#[cfg(target_os = "macos")]
fn register_window_menu_event_handler(_app: &App) -> Result<(), Box<dyn Error>> {
debug_assert!(!window_menu_event_handler_required(std::env::consts::OS));
Ok(())
}
fn emitted_menu_event_id(id: &str) -> Option<&'static str> {
manifest_menu_items().find_map(|item| {
if item.menu_item_id(manifest()) == Some(id) {
item.command_id(manifest())
} else {
None
}
})
}
pub fn emit_custom_menu_event(app_handle: &AppHandle, id: &str) -> Result<(), String> {
if !custom_menu_ids().contains(id) {
return Err(format!("Unknown custom menu event: {id}"));
}
let emitted_id = emitted_menu_event_id(id)
.ok_or_else(|| format!("Missing emitted command for custom menu event: {id}"))?;
app_handle
.emit("menu-event", emitted_id)
.map_err(|err| format!("Failed to emit menu-event {emitted_id}: {err}"))
}
fn menu_state_group_ids(group_name: &str) -> Vec<&'static str> {
manifest()
.menu_state_groups
.get(group_name)
.into_iter()
.flatten()
.filter_map(|reference| match reference {
MenuStateGroupReference::Command { command } => manifest()
.commands
.get(command)
.map(|command| command.id.as_str()),
MenuStateGroupReference::Id { id } => Some(id.as_str()),
})
.collect()
}
fn set_items_enabled<'a>(
app_handle: &AppHandle,
ids: impl IntoIterator<Item = &'a str>,
enabled: bool,
) {
let Some(menu) = app_handle.menu() else {
return;
};
for id in ids {
if let Some(MenuItemKind::MenuItem(mi)) = menu.get(id) {
let _ = mi.set_enabled(enabled);
}
}
}
fn set_menu_state_group_enabled(app_handle: &AppHandle, group_name: &str, enabled: bool) {
set_items_enabled(app_handle, menu_state_group_ids(group_name), enabled);
}
/// Enable or disable menu items that depend on having an active note tab.
pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_menu_state_group_enabled(app_handle, NOTE_DEPENDENT_GROUP, enabled);
}
/// Enable or disable menu items that depend on the editor being the active surface.
pub fn set_editor_find_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_menu_state_group_enabled(app_handle, EDITOR_FIND_DEPENDENT_GROUP, enabled);
}
/// Enable or disable menu items that depend on the note list being the active surface.
pub fn set_note_list_search_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_menu_state_group_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_GROUP, enabled);
}
/// Enable or disable menu items that depend on having uncommitted changes.
pub fn set_git_commit_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_menu_state_group_enabled(app_handle, GIT_COMMIT_DEPENDENT_GROUP, enabled);
}
/// Enable or disable menu items that depend on having merge conflicts.
pub fn set_git_conflict_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_menu_state_group_enabled(app_handle, GIT_CONFLICT_DEPENDENT_GROUP, enabled);
}
/// Enable or disable menu items that depend on the active vault having no remote.
pub fn set_git_no_remote_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_menu_state_group_enabled(app_handle, GIT_NO_REMOTE_DEPENDENT_GROUP, enabled);
}
/// Enable or disable menu items that depend on a deleted note preview being active.
pub fn set_restore_deleted_item_enabled(app_handle: &AppHandle, enabled: bool) {
set_menu_state_group_enabled(app_handle, RESTORE_DELETED_DEPENDENT_GROUP, enabled);
}
#[cfg(test)]
mod tests {
use super::*;
fn menu_item_by_id(id: &str) -> &'static ManifestMenuItem {
manifest_menu_items()
.find(|item| item.menu_item_id(manifest()) == Some(id))
.unwrap_or_else(|| panic!("missing menu item {id}"))
}
#[test]
fn custom_ids_are_manifest_menu_item_ids() {
let expected: HashSet<_> = manifest_menu_items()
.filter_map(|item| item.menu_item_id(manifest()))
.map(str::to_owned)
.collect();
assert_eq!(custom_menu_ids(), &expected);
assert!(custom_menu_ids().contains("file-quick-open-alias"));
}
#[test]
fn manifest_command_items_reference_known_commands() {
for item in manifest_menu_items() {
if let ManifestMenuItem::Command { command, .. } = item {
assert!(
manifest().commands.contains_key(command),
"menu item references missing command key {command}"
);
}
}
}
#[test]
fn overridden_menu_item_ids_emit_their_primary_command() {
assert_eq!(
emitted_menu_event_id("file-quick-open-alias"),
Some("file-quick-open")
);
assert_eq!(
emitted_menu_event_id("edit-toggle-note-list-search"),
Some("edit-toggle-note-list-search")
);
assert_eq!(emitted_menu_event_id("file-save"), Some("file-save"));
}
#[test]
fn state_group_ids_are_manifest_menu_items() {
for (group, references) in &manifest().menu_state_groups {
for reference in references {
let id = match reference {
MenuStateGroupReference::Command { command } => manifest()
.commands
.get(command)
.map(|command| command.id.as_str())
.unwrap_or_else(|| panic!("state group {group} references {command}")),
MenuStateGroupReference::Id { id } => id.as_str(),
};
assert!(
custom_menu_ids().contains(id),
"state group {group} references non-menu item {id}"
);
}
}
}
#[test]
fn view_toggle_properties_keeps_renderer_owned_accelerator() {
let item = menu_item_by_id("view-toggle-properties");
assert_eq!(item.accelerator(manifest()), None);
}
#[test]
fn view_menu_exposes_ai_panel_toggle() {
let view_menu = manifest_section("View").expect("view menu exists");
let item = view_menu
.items
.iter()
.find(|item| item.command_id(manifest()) == Some("view-toggle-ai-chat"))
.expect("View menu exposes the AI panel toggle");
assert_eq!(item.menu_item_id(manifest()), Some("view-toggle-ai-chat"));
assert_eq!(item.label("macos"), Some("Toggle AI Panel"));
assert_eq!(item.accelerator(manifest()), Some("CmdOrCtrl+Shift+L"));
}
#[test]
fn no_duplicate_custom_ids() {
let mut seen = HashSet::new();
for id in manifest_menu_items().filter_map(|item| item.menu_item_id(manifest())) {
assert!(seen.insert(id), "duplicate custom ID: {id}");
}
}
#[test]
fn app_services_menu_is_macos_only() {
assert!(app_menu_includes_services("macos"));
assert!(!app_menu_includes_services("windows"));
assert!(!app_menu_includes_services("linux"));
}
#[test]
fn window_menu_event_handler_is_required_off_macos() {
assert!(!window_menu_event_handler_required("macos"));
assert!(window_menu_event_handler_required("windows"));
assert!(window_menu_event_handler_required("linux"));
}
#[test]
fn window_menu_uses_native_nsapp_integration_on_macos_only() {
assert_eq!(
native_window_menu_submenu_id("macos"),
Some(WINDOW_SUBMENU_ID)
);
assert_eq!(native_window_menu_submenu_id("windows"), None);
assert_eq!(native_window_menu_submenu_id("linux"), None);
}
#[test]
fn native_menu_labels_escape_literal_ampersands() {
assert_eq!(native_menu_label("Commit & Push"), "Commit && Push");
assert_eq!(
native_menu_label("Research && Development"),
"Research &&&& Development"
);
}
#[test]
fn native_menu_labels_without_ampersands_are_unchanged() {
assert_eq!(native_menu_label("Pull from Remote"), "Pull from Remote");
}
#[test]
fn vault_commit_push_menu_label_is_native_menu_safe() {
let item = menu_item_by_id("vault-commit-push");
let label = item.label("windows").expect("commit push label exists");
assert_eq!(label, "Commit & Push");
assert_eq!(native_menu_label(label), "Commit && Push");
assert_eq!(item.accelerator(manifest()), None);
}
}