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:
parent
b44aaade42
commit
dd52eb7dad
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue