macOS menu bar: fall back instead of failing to start; throttle sync

- A muda error now logs a warning and keeps the in-window menu bar
  instead of aborting startup (never crash).
- sync() walked ~500 leaves with a command lookup and a Cocoa call each
  frame; run it at most 4x/s and right after a click.
- About / Session Info from the application menu open their window
  rather than toggling it closed.
- The title carries Edit/Mix and the dirty mark only when the system
  menu bar replaces the egui one (no duplicate on Linux/Windows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
t 2026-10-09 11:12:12 -04:00
parent b44aaade42
commit dd52eb7dad
2 changed files with 50 additions and 16 deletions

View File

@ -16,15 +16,19 @@ use soundcraft_engine::Engine;
use soundcraft_ui_egui::{Services, SoundApp, UiState};
use std::sync::Arc;
struct App(SoundApp, #[cfg(target_os = "macos")] native_menu::NativeMenu);
struct App(SoundApp, #[cfg(target_os = "macos")] Option<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);
if let Some(menu) = &mut self.1 {
menu.process(&mut self.0);
}
self.0.logic(ctx);
#[cfg(target_os = "macos")]
self.1.sync(&self.0);
if let Some(menu) = &mut self.1 {
menu.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());
@ -34,12 +38,18 @@ 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,
if self.0.engine.is_dirty() { " *" } else { "" },
if self.0.ui.window == soundcraft_ui_egui::MainWindow::Edit { "Edit" } else { "Mix" }
);
// With the system menu bar there is no egui bar showing the window and dirty state, so the
// title carries them; elsewhere the egui menu bar already does.
let title = if self.0.native_menu_bar {
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" }
)
} else {
format!("{} — SoundCraft", self.0.engine.session().name)
};
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
@ -197,12 +207,18 @@ fn main() -> eframe::Result {
for f in &files {
open_path(&mut app, f);
}
// If the system menu bar cannot be built, keep the egui menu bar rather than fail to start.
#[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;
}
let menu = match native_menu::NativeMenu::new(&app, &cc.egui_ctx) {
Ok(m) => {
app.native_menu_bar = true;
Some(m)
}
Err(e) => {
log::warn!("system menu bar unavailable, using the in-window menu bar: {e}");
None
}
};
Ok(Box::new(App(
app,
#[cfg(target_os = "macos")]

View File

@ -15,14 +15,20 @@ pub struct NativeMenu {
menu: Menu,
entries: Vec<Entry>,
events: Receiver<MenuEvent>,
/// When `sync` last walked the entries; see `SYNC_EVERY`.
last_sync: Option<std::time::Instant>,
}
/// `sync` walks every menu leaf (~500), so it runs a few times a second rather than every frame,
/// and right after a menu click.
const SYNC_EVERY: std::time::Duration = std::time::Duration::from_millis(250);
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 mut native = Self { menu, entries: Vec::new(), events, last_sync: None };
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()))?;
@ -73,17 +79,29 @@ impl NativeMenu {
pub fn process(&mut self, app: &mut SoundApp) {
while let Ok(event) = self.events.try_recv() {
// A click changes state (and Cocoa flips the item's check mark): sync on the next pass.
self.last_sync = None;
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);
if entry.path.is_empty() && command.starts_with("window.") {
// The application menu's About / Session Info open their window (never close it).
let _ = app.run(command, serde_json::json!({"value": true}));
} else {
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) {
let now = std::time::Instant::now();
if self.last_sync.is_some_and(|t| now.duration_since(t) < SYNC_EVERY) {
return;
}
self.last_sync = Some(now);
for entry in &mut self.entries {
let (enabled, checked) = menus::item_state(app, &entry.path, entry.command.as_deref());
if enabled != entry.enabled {