diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 8f37e89..5928d6f 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -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); 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 = 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 { 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")] diff --git a/apps/soundcraft/src/native_menu.rs b/apps/soundcraft/src/native_menu.rs index f1752d7..563d174 100644 --- a/apps/soundcraft/src/native_menu.rs +++ b/apps/soundcraft/src/native_menu.rs @@ -15,14 +15,20 @@ pub struct NativeMenu { menu: Menu, entries: Vec, events: Receiver, + /// When `sync` last walked the entries; see `SYNC_EVERY`. + last_sync: Option, } +/// `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 { 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 {