collective

Browse / github.com/storytold/filmcraft / patch

macOS menu bar: keep the menu tree between frames, don't rebuild it every frame

macOS menu bar: keep the menu tree between frames, don't rebuild it every frame ### Problem `sync_native_menu` runs every frame. It rebuilt the whole tree with `menus::menu_items`: 387 rows of translated labels, a `Shortcuts::primary` scan of all 308 bindings for each row, and a few thousand small allocations. It did that only to compare the result with the tree it sent last time. On an Apple M2 Max that costs **about 0.4 ms a frame**, every frame the app draws: playback, scrubbing, drags and the meter drop after Stop. That is 5% of a 120 Hz frame's budget, and on `fa550ce2` it was a fifth of the CPU time of a frame that only animated the meters (1.74 → 1.40 ms; 39% of i

Status
open
Score
0.00
Adopters
0 other operators
Author
ZJHeepfixer (imported from pull request #3285, open)
Base
a504f3c5adc4cd60d9c1120916cddb50c9b24b86
Size
+263 −31 in 5 files
Review
pending
Shared
2026-10-28
Id
5515488b4477fd89e83bdc40

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/commands.rs crates/engine/src/lib.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs docs/performance.md

diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs--- a/crates/engine/src/commands.rs+++ b/crates/engine/src/commands.rs@@ -35,6 +35,14 @@ pub struct CommandSpec {     pub journal: bool, } +impl CommandSpec {+    /// Whether the command can run on `s`'s current selection ([`Session::is_enabled`], for a+    /// caller that already holds the spec: the menus ask several hundred of them every frame).+    pub fn is_enabled(&self, s: &Session) -> bool {+        (self.enabled)(s).is_ok()+    }+}+ macro_rules! cmd {     ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {         CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -829,7 +829,7 @@ impl Session {      /// Whether the command can run on the current selection (what the menus show).     pub fn is_enabled(&self, id: &str) -> bool {-        commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok())+        commands::find(id).is_some_and(|c| c.is_enabled(self))     }      /// What is open, to store beside the project when it is saved.diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -374,8 +374,8 @@ pub struct FilmcraftApp {     bindings: Vec<menus::KeyBinding>,     /// Shortcut-set revision `bindings` were built from.     bindings_rev: u64,-    /// The menu tree last sent to `hooks.menu_changed` (the native menu bar).-    menu_sent: Vec<menus::MenuItem>,+    /// The menu tree for `hooks.menu_changed` (the native menu bar), kept between frames.+    menu_cache: menus::MenuCache,     /// Keyboard Shortcuts dialog state.     pub shortcut_editor: panels::shortcuts_dialog::EditorState,     pub toast: Option<(String, f64)>,@@ -604,7 +604,7 @@ impl FilmcraftApp {             last_time: 0.0,             bindings: Vec::new(),             bindings_rev: 0,-            menu_sent: Vec::new(),+            menu_cache: menus::MenuCache::default(),             shortcut_editor: Default::default(),             toast: None,             graphics_notice: None,@@ -1531,19 +1531,19 @@ impl FilmcraftApp {      /// Sends the menu tree to the native menu bar when it changed, so its items show the same     /// labels, shortcuts and enablement as the in-window menus (Undo with nothing to undo is-    /// disabled). Without a native menu bar this does nothing.+    /// disabled). Runs every frame, so the tree is not rebuilt each time: see [`menus::MenuCache`].+    /// Without a native menu bar this does nothing.     fn sync_native_menu(&mut self) {         if self.hooks.menu_changed.is_none() {             return;         }-        let items = menus::menu_items(self);-        if items == self.menu_sent {-            return;-        }-        if let Some(hook) = self.hooks.menu_changed.as_mut() {-            hook(&items);+        let mut cache = std::mem::take(&mut self.menu_cache);+        if let Some(items) = cache.changed(self)+            && let Some(hook) = self.hooks.menu_changed.as_mut()+        {+            hook(items);         }-        self.menu_sent = items;+        self.menu_cache = cache;     }      /// Whether keyboard focus is on a dock divider or a floating panel's resize grip, as drawn last pass.@@ -2983,4 +2983,99 @@ mod native_menu_tests {         assert_eq!(last(&sent, "edit.undo"), Some(false));         assert_eq!(last(&sent, "edit.redo"), Some(true), "the undone edit can be redone");     }++    /// The native menu is no longer rebuilt every frame (it cost ~0.5 ms a frame), so every kind+    /// of change it shows must still reach it, on the next frame: enablement, checkmarks, labels+    /// (also a language switched at runtime), shortcuts, workspaces and recent projects. After+    /// each change the tree sent must be exactly what a full rebuild gives.+    #[test]+    fn native_menu_follows_every_change_it_shows() {+        let sent: Rc<RefCell<Vec<Vec<menus::MenuItem>>>> = Rc::default();+        let mut session = Session::default();+        session.execute("file.openDemoProject", json!({})).unwrap();+        let mut app = FilmcraftApp::new(session);+        let sink = sent.clone();+        app.hooks.menu_changed = Some(Box::new(move |items: &[menus::MenuItem]| sink.borrow_mut().push(items.to_vec())));+        let ctx = egui::Context::default();+        let row = |sent: &Rc<RefCell<Vec<Vec<menus::MenuItem>>>>, id: &str| sent.borrow().last().and_then(|items| items.iter().find(|it| it.id == id).cloned());+        app.session.execute("edit.deselectAll", json!({})).unwrap();+        app.sync_native_menu();+        let ids: Vec<(String, Vec<String>)> = sent.borrow()[0].iter().map(|it| (it.id.clone(), it.path.clone())).collect();+        assert_eq!(row(&sent, "edit.copy").map(|it| it.enabled), Some(false), "nothing selected");++        type Change = Box<dyn Fn(&mut FilmcraftApp, &egui::Context)>;+        let menu = |id: &'static str| -> Change {+            Box::new(move |app, ctx| {+                let _ = menus::invoke(app, ctx, id, json!({}));+            })+        };+        let changes: Vec<(&str, Change)> = vec![+            ("select every clip", menu("edit.selectAll")),+            ("quarter playback resolution", menu("view.playbackRes.quarter")),+            ("a guide on the Program monitor", Box::new(|app, _| app.ui.program.guides.push(state::Guide { vertical: true, position: 10.0 }))),+            ("focus the Source monitor", Box::new(|app, _| app.ui.focused = dock::PanelKind::Source)),+            ("dynamic waveforms toggled", Box::new(|app, _| app.ui.extras.dynamic_waveforms = !app.ui.extras.dynamic_waveforms)),+            ("German at runtime", menu("app.language.german")),+            ("rename a label", Box::new(|app, _| app.session.prefs.labels.colors.entry("yellow".into()).or_default().name = "B-Roll".into())),+            ("rebind Save", Box::new(|app, _| assert!(app.session.shortcuts.set("file.save", "Cmd+Shift+9", None, false, true).is_ok()))),+            ("a recent project", Box::new(|app, _| app.session.prefs.general.recent_projects.insert(0, "/tmp/Trailer.fcproj".into()))),+            ("a saved workspace", Box::new(|app, _| app.workspaces.saved.push(dock::SavedWorkspace { name: "Grading".into(), ..Default::default() }))),+            ("the Audio workspace", menu("window.workspace.audio")),+            ("English again", menu("app.language.english")),+        ];+        for (what, change) in &changes {+            let before = menus::menu_items(&app);+            let count = sent.borrow().len();+            change(&mut app, &ctx);+            let expected = menus::menu_items(&app);+            assert_ne!(before, expected, "{what}: the change must show in the menu, or this step tests nothing");+            app.sync_native_menu();+            assert_eq!(sent.borrow().len(), count + 1, "{what}: the changed menu was not sent");+            assert!(sent.borrow().last() == Some(&expected), "{what}: the menu sent differs from a full rebuild");+        }+        assert_eq!(row(&sent, "edit.copy").map(|it| it.enabled), Some(true));+        assert_eq!(row(&sent, "view.playbackRes.quarter").and_then(|it| it.checked), Some(false), "Source monitor focused: its resolution");+        assert_eq!(row(&sent, "edit.label.yellow").map(|it| it.label), Some("B-Roll".to_string()));+        assert_eq!(row(&sent, "file.save").and_then(|it| it.shortcut), Some("Cmd+Shift+9".to_string()));+        assert_eq!(row(&sent, "file.openRecent.0").map(|it| it.label), Some("Trailer.fcproj".to_string()));+        assert_eq!(row(&sent, "file.clearRecent").map(|it| it.enabled), Some(true));+        assert_eq!(row(&sent, "window.workspace.audio").and_then(|it| it.checked), Some(true));+        assert_eq!(row(&sent, "window.workspace.grading").map(|it| it.label), Some("Grading".to_string()));+        assert_eq!(row(&sent, "view.lockGuides").map(|it| it.enabled), Some(false), "no guides on the focused Source monitor");+        assert_eq!(row(&sent, "app.language.english").and_then(|it| it.checked), Some(true));+        // German was shown in between: translated labels and the checkmark, on the same rows+        let german = sent.borrow().iter().find(|items| items.iter().any(|it| it.id == "app.language.german" && it.checked == Some(true))).cloned();+        let german = german.expect("the German menu was sent");+        let save = german.iter().find(|it| it.id == "file.save").map(|it| it.label.clone());+        assert_eq!(save.as_deref(), Some(i18n::Language::De.tr("Save")));+        assert_ne!(save.as_deref(), Some("Save"));+        // The native items were made once, at launch, and are matched by id: a language switch+        // must keep every id and path (designcraft#1548 lost its menu commands that way).+        let german_ids: Vec<(String, Vec<String>)> = german.iter().map(|it| (it.id.clone(), it.path.clone())).collect();+        let without_recent = |v: &[(String, Vec<String>)]| v.iter().filter(|(id, _)| !id.starts_with("file.openRecent.")).cloned().collect::<Vec<_>>();+        assert_eq!(without_recent(&german_ids), without_recent(&ids));+    }++    /// Frames where nothing it shows changed neither rebuild the rows nor resend them; a change+    /// of enablement alone refreshes them without a rebuild.+    #[test]+    fn an_unchanged_native_menu_is_not_rebuilt() {+        let sent = Rc::new(RefCell::new(0usize));+        let mut app = FilmcraftApp::new(Session::default());+        let sink = sent.clone();+        app.hooks.menu_changed = Some(Box::new(move |_: &[menus::MenuItem]| *sink.borrow_mut() += 1));+        for _ in 0..100 {+            app.sync_native_menu();+        }+        assert_eq!((*sent.borrow(), app.menu_cache.builds), (1, 1));+        app.session.execute("file.newSequence", json!({"width":16,"height":16})).unwrap();+        app.sync_native_menu();+        assert_eq!((*sent.borrow(), app.menu_cache.builds), (2, 1), "Undo became enabled: sent, from the same rows");+        app.ui.language = i18n::Language::Ja;+        app.sync_native_menu();+        assert_eq!((*sent.borrow(), app.menu_cache.builds), (3, 2), "new labels: rebuilt");+        let mut none = FilmcraftApp::new(Session::default());+        none.sync_native_menu();+        assert_eq!(none.menu_cache.builds, 0, "no native menu bar, no menu tree");+    } }diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -696,34 +696,156 @@ pub const MENUS: [&str; 9] = ["File", "Edit", "Clip", "Sequence", "Markers", "Gr pub const NATIVE_APP_MENU: [&str; 4] = ["Settings", "Hide FilmCraft", "Hide Others", "Quit FilmCraft"];  pub fn menu_items(app: &FilmcraftApp) -> Vec<MenuItem> {+    let mut v = menu_rows(app);+    let live: Vec<Live> = v.iter().map(|it| Live::of(&it.id)).collect();+    menu_state(app, &mut v, &live);+    v+}++/// The rows of the menu tree: ids, translated labels, menu paths and shortcuts, with Window ▸+/// Workspaces and File ▸ Open Recent spliced in. They are read only from what [`RowsFrom`] keeps+/// a copy of, so [`MenuCache`] can tell when they are out of date. Enablement and checkmarks+/// follow the selection, the playhead, the focused monitor and more: [`menu_state`] sets them.+fn menu_rows(app: &FilmcraftApp) -> Vec<MenuItem> {     let mut v = menu_items_for(&app.session);     for it in &mut v {         it.label = app.ui.language.tr(&it.label).to_string();-        match it.id.as_str() {-            "app.language.english" => it.checked = Some(app.ui.language == crate::i18n::Language::En),-            "app.language.japanese" => it.checked = Some(app.ui.language == crate::i18n::Language::Ja),-            "app.language.spanish" => it.checked = Some(app.ui.language == crate::i18n::Language::Es),-            "app.language.portuguese" => it.checked = Some(app.ui.language == crate::i18n::Language::PtBr),-            "app.language.ukrainian" => it.checked = Some(app.ui.language == crate::i18n::Language::Uk),-            "app.language.chinese" => it.checked = Some(app.ui.language == crate::i18n::Language::ZhCn),-            "app.language.german" => it.checked = Some(app.ui.language == crate::i18n::Language::De),-            "app.language.russian" => it.checked = Some(app.ui.language == crate::i18n::Language::Ru),-            "app.language.french" => it.checked = Some(app.ui.language == crate::i18n::Language::Fr),-            "app.language.indonesian" => it.checked = Some(app.ui.language == crate::i18n::Language::Id),-            "app.language.italian" => it.checked = Some(app.ui.language == crate::i18n::Language::It),-            "app.language.korean" => it.checked = Some(app.ui.language == crate::i18n::Language::Ko),-            _ => {}-        }-        if it.id.starts_with("view.") {-            it.checked = crate::panels::monitor_view::checked(app, &it.id).or(crate::panels::menu_dialogs::checked(app, &it.id));-            it.enabled &= crate::panels::monitor_view::enabled(app, &it.id);-        }     }     crate::panels::workspaces::menu(app, &mut v);     open_recent(&mut v, &app.session, |n| app.ui.language.tr(n).to_string());     v } +/// Where a row's enablement and checkmark come from when they change without the rows changing.+#[derive(Clone, Copy)]+enum Live {+    /// Nothing: set with the rows (frontend commands, panels, workspaces, recent projects).+    Fixed,+    /// An engine command: enabled when it can run now.+    Command(&'static filmcraft_engine::CommandSpec),+    /// A View item, engine command or not: also enabled and checked by the monitors' state.+    View(Option<&'static filmcraft_engine::CommandSpec>),+    /// Edit ▸ Language: checked for the interface language.+    Language(crate::i18n::Language),+}++impl Live {+    fn of(id: &str) -> Self {+        use crate::i18n::Language;+        let command = filmcraft_engine::find_command(id);+        if id.starts_with("view.") {+            return Self::View(command);+        }+        if let Some(c) = command {+            return Self::Command(c);+        }+        let language = match id {+            "app.language.english" => Language::En,+            "app.language.japanese" => Language::Ja,+            "app.language.spanish" => Language::Es,+            "app.language.portuguese" => Language::PtBr,+            "app.language.ukrainian" => Language::Uk,+            "app.language.chinese" => Language::ZhCn,+            "app.language.german" => Language::De,+            "app.language.russian" => Language::Ru,+            "app.language.french" => Language::Fr,+            "app.language.indonesian" => Language::Id,+            "app.language.italian" => Language::It,+            "app.language.korean" => Language::Ko,+            _ => return Self::Fixed,+        };+        Self::Language(language)+    }+}++/// The live part of each row (`live` says which part that is): whether an engine command can run+/// now, the View items' enablement and checkmarks, the Language checkmarks. Each value is set+/// afresh, never from what the row held before, so the same rows can be refreshed every frame.+fn menu_state(app: &FilmcraftApp, items: &mut [MenuItem], live: &[Live]) {+    for (it, live) in items.iter_mut().zip(live) {+        match *live {+            Live::Fixed => {}+            Live::Command(c) => it.enabled = c.is_enabled(&app.session),+            Live::View(c) => {+                it.enabled = c.is_none_or(|c| c.is_enabled(&app.session)) && crate::panels::monitor_view::enabled(app, &it.id);+                it.checked = crate::panels::monitor_view::checked(app, &it.id).or(crate::panels::menu_dialogs::checked(app, &it.id));+            }+            Live::Language(language) => it.checked = Some(app.ui.language == language),+        }+    }+}++/// Copies of everything [`menu_rows`] reads: the interface language (labels), the shortcut set+/// (by its revision), Settings ▸ Labels (Edit ▸ Label names), the saved workspaces and the one in+/// use (Window ▸ Workspaces and its checkmark) and the recent projects (File ▸ Open Recent).+#[derive(PartialEq)]+struct RowsFrom {+    language: crate::i18n::Language,+    shortcuts: u64,+    labels: filmcraft_engine::settings::LabelPrefs,+    workspaces: Vec<String>,+    workspace: String,+    recent: Vec<String>,+}++impl RowsFrom {+    fn of(app: &FilmcraftApp) -> Self {+        Self {+            language: app.ui.language,+            shortcuts: app.session.shortcuts.revision,+            labels: app.session.prefs.labels.clone(),+            workspaces: app.workspaces.saved.iter().map(|w| w.name.clone()).collect(),+            workspace: app.ui.workspace.clone(),+            recent: app.session.prefs.general.recent_projects.clone(),+        }+    }++    /// Whether rows built from `self` are still `app`'s rows (compared in place, no copies).+    fn matches(&self, app: &FilmcraftApp) -> bool {+        self.language == app.ui.language+            && self.shortcuts == app.session.shortcuts.revision+            && self.labels == app.session.prefs.labels+            && self.workspaces.iter().eq(app.workspaces.saved.iter().map(|w| &w.name))+            && self.workspace == app.ui.workspace+            && self.recent == app.session.prefs.general.recent_projects+    }+}++/// The menu tree kept between frames for the native menu bar. Building it from scratch (several+/// hundred rows of translated labels, shortcut lookups and small allocations) took about half a+/// millisecond, every frame. The rows are rebuilt only when what they are made from changed;+/// their enablement and checkmarks are still read every time, so nothing they show can lag.+#[derive(Default)]+pub struct MenuCache {+    from: Option<RowsFrom>,+    rows: Vec<MenuItem>,+    /// Which part of each row [`menu_state`] refreshes, worked out when the rows are built.+    live: Vec<Live>,+    /// The tree as last handed out by [`MenuCache::changed`].+    sent: Vec<MenuItem>,+    /// How often the rows were built (tests).+    pub(crate) builds: u64,+}++impl MenuCache {+    /// The menu tree ([`menu_items`]) when it differs from the one this returned last time, else+    /// `None`.+    pub fn changed(&mut self, app: &FilmcraftApp) -> Option<&[MenuItem]> {+        if !self.from.as_ref().is_some_and(|from| from.matches(app)) {+            self.rows = menu_rows(app);+            self.live = self.rows.iter().map(|it| Live::of(&it.id)).collect();+            self.from = Some(RowsFrom::of(app));+            self.builds += 1;+        }+        menu_state(app, &mut self.rows, &self.live);+        if self.rows == self.sent {+            return None;+        }+        self.sent.clone_from(&self.rows);+        Some(&self.sent)+    }+}+ /// File ▸ Open Recent, after Open Project…: the recent projects (General ▸ `recentProjects`), /// newest first, then Clear Recent Files. Clear is always there, so the submenu exists (in the /// native menu too, built once at launch) before the first project is saved.diff --git a/docs/performance.md b/docs/performance.md--- a/docs/performance.md+++ b/docs/performance.md@@ -18,6 +18,13 @@ finishes. Transcript words and paragraphs are cached by project snapshot and act search highlights by query. Playhead movement reuses them; project edits, undo/redo and view changes refresh them. Highlight lookup is constant work per word, including searches with many matches. +The native macOS menu bar is brought up to date every frame, but its rows (translated labels, menu+paths, shortcuts, Window ▸ Workspaces and File ▸ Open Recent) are kept between frames and rebuilt+only when the language, the shortcut set, Settings ▸ Labels, the workspaces or the recent projects+change (`menus::MenuCache`). Enablement and checkmarks are still read every frame. Rebuilding the+whole tree took about 0.4 ms a frame on an Apple M2 Max (5% of a 120 Hz frame, every frame of+playback, scrubbing or a drag); the frame-by-frame check now takes about 0.01 ms.+ To measure a long transcript, run `cargo test -p filmcraft-ui-egui --test it transcript_ui::long_transcript_redraw_benchmark -- --ignored --nocapture` alone. It reports median and p95 redraw time for 10,000 words with an empty query and a query matching