macOS: move application menus to the system menu bar
This commit is contained in:
parent
9334ba0a50
commit
b44aaade42
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)>);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue