macOS: move application menus to the system menu bar

This commit is contained in:
reoniazx 2026-10-08 14:10:03 +08:00
parent 9334ba0a50
commit b44aaade42
7 changed files with 207 additions and 17 deletions

43
Cargo.lock generated
View File

@ -464,6 +464,9 @@ name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
dependencies = [
"serde_core",
]
[[package]]
name = "block2"
@ -788,6 +791,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
@ -1830,6 +1842,17 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keyboard-types"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
"bitflags 2.13.2",
"serde",
"unicode-segmentation",
]
[[package]]
name = "khronos-egl"
version = "6.0.0"
@ -2017,6 +2040,25 @@ dependencies = [
"pxfm",
]
[[package]]
name = "muda"
version = "0.19.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"once_cell",
"png",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@ -3366,6 +3408,7 @@ dependencies = [
"eframe",
"egui",
"log",
"muda",
"rfd",
"serde_json",
"soundcraft-engine",

View File

@ -29,7 +29,7 @@ the honest status: what works today, what is missing, and how far we are.
| Elastic Audio / TCE / Beat Detective | Pitch-preserving warp on Elastic tracks, TCE to timeline, conform to tempo, Beat Detective and Identify Beat windows; audio-to-MIDI pitch detection | 45 % | 15 |
| Surround | Main and bus formats from stereo to 9.1.6 and Ambisonics, surround panner with divergence/centre/LFE/height, ITU fold-down, multichannel bounce, Renderer window, object/bed routing | 60 % | 12 |
| Video | Picture track with thumbnails, Video and Video Universe windows, H.264 (Baseline/Main/High), ProRes (all flavours) and Motion JPEG decoders in pure Rust, sync offset, relinking. Missing: HEVC/AV1/DNx, timecode-track placement, video export | 55 % | 15 |
| UI fidelity | Edit + Mix windows, toolbar, rulers, track headers, menus for the whole catalog, floating windows, dialogs (every file command has a path prompt), command palette | 70 % | 20 |
| UI fidelity | Edit + Mix windows, toolbar, rulers, track headers, menus for the whole catalog (macOS system menu bar), floating windows, dialogs (every file command has a path prompt), command palette | 70 % | 20 |
| Agent control | CLI, JSON control channel, MCP server (headless + bridged), offscreen UI renders | 90 % (ahead of the incumbent) | 3 |
| Release engineering | Signed macOS universal, Windows x64/x86, Linux AppImage/deb/rpm/tar/Flatpak, FreeBSD, Web/WASM on every push to `release` | 80 % | 3 |

View File

@ -25,3 +25,6 @@ rfd.workspace = true
[build-dependencies]
# Icon + VERSIONINFO for soundcraft.exe; build.rs only uses it when the target is Windows.
winresource = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.19.2", default-features = false }

View File

@ -9,16 +9,22 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server;
#[cfg(target_os = "macos")]
mod native_menu;
use soundcraft_engine::Engine;
use soundcraft_ui_egui::{Services, SoundApp, UiState};
use std::sync::Arc;
struct App(SoundApp);
struct App(SoundApp, #[cfg(target_os = "macos")] native_menu::NativeMenu);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
self.1.process(&mut self.0);
self.0.logic(ctx);
#[cfg(target_os = "macos")]
self.1.sync(&self.0);
// Files dropped on the window: sessions open, audio/MIDI import.
let dropped: Vec<String> =
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect());
@ -28,7 +34,12 @@ impl eframe::App for App {
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
let title = format!("{} — SoundCraft", self.0.engine.session().name);
let title = format!(
"{}{} — SoundCraft — {}",
self.0.engine.session().name,
if self.0.engine.is_dirty() { " *" } else { "" },
if self.0.ui.window == soundcraft_ui_egui::MainWindow::Edit { "Edit" } else { "Mix" }
);
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
@ -186,7 +197,17 @@ fn main() -> eframe::Result {
for f in &files {
open_path(&mut app, f);
}
Ok(Box::new(App(app)))
#[cfg(target_os = "macos")]
let menu = native_menu::NativeMenu::new(&app, &cc.egui_ctx).map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
#[cfg(target_os = "macos")]
{
app.native_menu_bar = true;
}
Ok(Box::new(App(
app,
#[cfg(target_os = "macos")]
menu,
)))
}),
)
}

View File

@ -0,0 +1,107 @@
//! macOS system menu bar. All actions use the same menu dispatch as the egui host.
use muda::{CheckMenuItem, Menu, MenuEvent, PredefinedMenuItem, Submenu};
use soundcraft_ui_egui::{SoundApp, menus};
use std::sync::mpsc::{Receiver, channel};
struct Entry {
item: CheckMenuItem,
command: Option<String>,
path: String,
enabled: bool,
checked: bool,
}
pub struct NativeMenu {
menu: Menu,
entries: Vec<Entry>,
events: Receiver<MenuEvent>,
}
impl NativeMenu {
/// Called on the UI thread after eframe has created NSApplication.
pub fn new(app: &SoundApp, ctx: &egui::Context) -> muda::Result<Self> {
let menu = Menu::new();
let (tx, events) = channel();
let mut native = Self { menu, entries: Vec::new(), events };
let app_menu = Submenu::new("SoundCraft", true);
native.add_item(app, &app_menu, "About SoundCraft", "", Some("window.about".into()))?;
native.add_item(app, &app_menu, "Session Info", "", Some("window.session_info".into()))?;
app_menu.append(&PredefinedMenuItem::separator())?;
app_menu.append(&PredefinedMenuItem::services(None))?;
app_menu.append(&PredefinedMenuItem::separator())?;
app_menu.append(&PredefinedMenuItem::hide(Some("Hide SoundCraft")))?;
app_menu.append(&PredefinedMenuItem::hide_others(None))?;
app_menu.append(&PredefinedMenuItem::show_all(None))?;
app_menu.append(&PredefinedMenuItem::separator())?;
native.add_item(app, &app_menu, "Quit SoundCraft", "", Some("app.quit".into()))?;
native.menu.append(&app_menu)?;
let aliases = menus::ui_aliases();
for root in menus::tree() {
let submenu = native.add_branch(app, &root, &aliases)?;
native.menu.append(&submenu)?;
}
native.menu.init_for_nsapp();
let ctx = ctx.clone();
MenuEvent::set_event_handler(Some(move |event| {
if tx.send(event).is_ok() {
ctx.request_repaint();
}
}));
Ok(native)
}
fn add_branch(&mut self, app: &SoundApp, node: &menus::MenuNode, aliases: &[(&str, &str)]) -> muda::Result<Submenu> {
let submenu = Submenu::new(&node.label, true);
for child in &node.children {
if child.children.is_empty() {
self.add_item(app, &submenu, &child.label, &child.path, menus::command_for_path(&child.path, aliases))?;
} else {
submenu.append(&self.add_branch(app, child, aliases)?)?;
}
}
Ok(submenu)
}
fn add_item(&mut self, app: &SoundApp, parent: &Submenu, label: &str, path: &str, command: Option<String>) -> muda::Result<()> {
let (enabled, checked) = menus::item_state(app, path, command.as_deref());
// egui owns keyboard shortcuts, so a key press is dispatched only once.
let item = CheckMenuItem::new(label, enabled, checked, None);
parent.append(&item)?;
self.entries.push(Entry { item, command, path: path.into(), enabled, checked });
Ok(())
}
pub fn process(&mut self, app: &mut SoundApp) {
while let Ok(event) = self.events.try_recv() {
if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id)
&& let Some(command) = &entry.command
&& menus::item_state(app, &entry.path, Some(command)).0
{
menus::invoke_menu(app, command, &entry.path);
}
}
}
/// Refresh after engine/control changes; avoid touching Cocoa items with unchanged state.
pub fn sync(&mut self, app: &SoundApp) {
for entry in &mut self.entries {
let (enabled, checked) = menus::item_state(app, &entry.path, entry.command.as_deref());
if enabled != entry.enabled {
entry.item.set_enabled(enabled);
entry.enabled = enabled;
}
// Cocoa toggles check items on click, including ordinary command items.
if checked != entry.checked || entry.item.is_checked() != checked {
entry.item.set_checked(checked);
entry.checked = checked;
}
}
}
}
impl Drop for NativeMenu {
fn drop(&mut self) {
self.menu.remove_for_nsapp();
MenuEvent::set_event_handler(None::<fn(MenuEvent)>);
}
}

View File

@ -197,6 +197,8 @@ pub struct SoundApp {
recorder_failed: bool,
pub ui: UiState,
pub services: Services,
/// Set only by a desktop host after installing an OS menu bar.
pub native_menu_bar: bool,
/// Folder for crash-recovery autosaves (native apps set it).
pub autosave_dir: Option<std::path::PathBuf>,
/// Folder for user plugin presets (native apps set it).
@ -243,6 +245,7 @@ impl SoundApp {
recorder_failed: false,
ui: UiState::default(),
services,
native_menu_bar: false,
autosave_dir: None,
preset_dir: None,
last_autosave: 0.0,
@ -767,7 +770,9 @@ impl SoundApp {
ctx.memory_mut(|m| m.reset_areas());
}
shortcuts::handle(self, &ctx);
menus::menu_bar(self, ui);
if !self.native_menu_bar {
menus::menu_bar(self, ui);
}
match self.ui.window {
MainWindow::Edit => edit_window::show(self, ui),
MainWindow::Mix => mix_window::show(self, ui),

View File

@ -147,13 +147,14 @@ const DIALOG_COMMANDS: &[&str] = &[
"edit.strip_silence",
];
struct MenuNode {
label: String,
path: String,
children: Vec<MenuNode>,
/// Shared menu hierarchy for egui and native desktop hosts.
pub struct MenuNode {
pub label: String,
pub path: String,
pub children: Vec<MenuNode>,
}
fn tree() -> Vec<MenuNode> {
pub fn tree() -> Vec<MenuNode> {
let mut roots: Vec<MenuNode> = Vec::new();
for line in catalog::catalog() {
let parts: Vec<&str> = line.split(" > ").collect();
@ -231,13 +232,8 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str
});
return;
}
let id = catalog::implemented_by(&n.path, extra).or_else(|| MENU_WINDOWS.iter().find(|(p, _)| *p == n.path).map(|(_, w)| w.to_string()));
let enabled = match id.as_deref() {
Some(i) if i.starts_with("window.") || i.starts_with("view.mix_section") || i == "audiosuite.process" => true,
Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()),
None => false,
};
let checked = checked_state(app, &n.path, id.as_deref());
let id = command_for_path(&n.path, extra);
let (enabled, checked) = item_state(app, &n.path, id.as_deref());
let label = if checked { format!("✔ {}", n.label) } else { n.label.clone() };
let shortcut = id.as_deref().and_then(soundcraft_engine::find_command).and_then(|c| c.shortcut).unwrap_or("");
let btn = egui::Button::new(label).shortcut_text(shortcut);
@ -249,6 +245,21 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str
}
}
/// Resolve the command behind a menu leaf, including UI dialog overrides.
pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option<String> {
catalog::implemented_by(path, extra).or_else(|| MENU_WINDOWS.iter().find(|(p, _)| *p == path).map(|(_, w)| w.to_string()))
}
/// Enabled and checked state shared by all menu presentations.
pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) {
let enabled = match id {
Some(i) if i.starts_with("window.") || i.starts_with("view.mix_section") || i == "audiosuite.process" => true,
Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()),
None => false,
};
(enabled, checked_state(app, path, id))
}
fn checked_state(app: &SoundApp, path: &str, id: Option<&str>) -> bool {
let e = &app.engine.session().edit;
match id {