From 6bb9bd8dabfc1c441944229ed2b24f2186af5784 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 18:25:05 +0700 Subject: [PATCH 1/5] Menus: arrow keys and Enter drive the menu bar; shortcuts wait while a menu is open (#279) The in-window menu bar now works from the keyboard as After Effects' does on Windows: Alt pressed and released on its own focuses the bar, Left / Right move between its menus and Down, Enter or Space opens one. In an open menu Up / Down move the highlight (skipping disabled entries, wrapping around), Right opens the highlighted submenu and Left closes it (on a plain entry they open the next / previous menu), Enter or Space chooses the entry and Escape closes the menus. Hovering an entry moves the highlight there, and a highlighted entry scrolls into view in long menus. While a menu, context menu or popup list is open, or the bar has the keyboard, keys go to the menus only: the app's shortcuts don't run (Space started the preview behind an open menu) and the panels don't see the keys (Enter on an entry no longer reaches the Project panel's rename). Tall menus already scroll inside the window since #277 (ui_scrolling's a_menu_taller_than_the_window_scrolls). F10 stays View > Switch 3D View > Front, as in After Effects. Test: ui_menus (shortcuts_wait_while_a_menu_is_open, arrow_keys_drive_the_menu_bar, enter_chooses_the_highlighted_entry). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menu_keys.rs | 327 +++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 42 ++-- crates/ui-egui/src/widgets.rs | 14 ++ crates/ui-egui/tests/ui_menus.rs | 122 ++++++++++++ docs/parity.md | 2 +- 6 files changed, 496 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/menu_keys.rs create mode 100644 crates/ui-egui/tests/ui_menus.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2d659543..e18264c3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,7 @@ pub mod gpu_failure; pub mod header; pub mod i18n; pub mod icons; +pub mod menu_keys; pub mod menus; pub mod native_menu; pub mod panels; diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs new file mode 100644 index 00000000..897f01f8 --- /dev/null +++ b/crates/ui-egui/src/menu_keys.rs @@ -0,0 +1,327 @@ +//! The keyboard in the in-window menu bar (#279), as in After Effects on Windows: Alt pressed and +//! released on its own focuses the menu bar (and leaves it again); Left / Right move between its +//! menus, Down, Enter or Space opens one. In an open menu Up / Down move the highlight through +//! the entries (skipping disabled ones, wrapping around), Right opens the highlighted submenu and +//! Left closes it again (in the top menu they open the next / previous menu), Enter or Space +//! chooses the highlighted entry and Escape closes the menus. The highlight is egui's keyboard +//! focus, so Enter and Space are egui's own clicks on it. +//! +//! While a menu is open or the bar has the keyboard, keys go to the menus only: the app's +//! shortcuts and the panels don't see them (Space must not start a preview). + +use egui::containers::menu::{MenuState, SubMenu, find_menu_root}; +use egui::{Context, Event, Id, Key, Modifiers, Popup, Pos2, Response, Ui}; + +/// An entry of an open menu as drawn this frame. +#[derive(Clone, Copy)] +struct Row { + id: Id, + enabled: bool, + submenu: bool, +} + +/// An open menu (or submenu) as drawn this frame. +#[derive(Default)] +struct Level { + /// The menu's root `Ui` (where egui keeps which submenu is open). + menu: Option, + /// The submenu entry it opened from (none for a top-level menu). + owner: Option, + /// Drawn only to measure it (its first frame): its entries can't take the highlight yet. + sizing: bool, + rows: Vec, +} + +/// The menu bar's buttons and open menus, collected while they are drawn. +#[derive(Default)] +pub struct Nav { + /// The bar's menu buttons and their menus' popups. + tops: Vec<(Id, Id)>, + /// Open menus by depth: the top-level menu, then each open submenu. + levels: Vec, +} + +/// What to highlight once a menu opened from the keyboard has been drawn: the first entry of the +/// menu at this depth. +#[derive(Clone, Copy)] +struct FirstEntry(usize); + +/// Submenus opened from the keyboard, with where the pointer was: egui closes an open submenu +/// while the pointer rests on another entry of its menu, so they stay open until it moves. +#[derive(Clone, Default)] +struct KeyOpened { + submenus: Vec<(Id, Id)>, + pointer: Option, +} + +fn pending_id() -> Id { + Id::new("menu-keys-first") +} +fn opened_id() -> Id { + Id::new("menu-keys-opened") +} +fn active_id() -> Id { + Id::new("menu-keys-active") +} +fn alt_id() -> Id { + Id::new("menu-keys-alt") +} + +impl Nav { + /// A top-level menu button of the bar. + pub fn top(&mut self, button: &Response) { + self.tops.push((button.id, Popup::default_response_id(button))); + } + + /// An entry drawn at `depth` (0: a top-level menu) in the menu `ui`. Hovering an entry moves + /// the keyboard highlight to it, and a highlighted entry scrolls into view. + pub fn entry(&mut self, ui: &Ui, depth: usize, r: &Response, submenu: bool) { + while self.levels.len() <= depth { + self.levels.push(Level::default()); + } + let Some(level) = self.levels.get_mut(depth) else { return }; + if level.menu.is_none() { + level.menu = Some(find_menu_root(ui).id); + level.sizing = ui.is_sizing_pass(); + } + level.rows.push(Row { id: r.id, enabled: r.enabled(), submenu }); + if r.gained_focus() { + r.scroll_to_me(None); + } + let ctx = ui.ctx(); + let keyboard = active(ctx) && ctx.memory(|m| m.focused()).is_some(); + if keyboard && r.enabled() && !r.has_focus() && r.hovered() && ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO) { + r.request_focus(); + } + } + + /// Submenu entry `button` at `depth` showed its submenu this frame (drawn at `depth + 1`). + pub fn opened(&mut self, depth: usize, button: &Response) { + if let Some(level) = self.levels.get_mut(depth + 1) { + level.owner = Some(button.id); + } + } + + /// The keyboard highlight: (depth, index) of the focused entry. + fn focused(&self, focus: Option) -> Option<(usize, usize)> { + let f = focus?; + self.levels.iter().enumerate().rev().find_map(|(d, l)| l.rows.iter().position(|r| r.id == f).map(|i| (d, i))) + } +} + +/// Whether the menu bar had the keyboard last frame (a menu open or the bar focused). +pub fn active(ctx: &Context) -> bool { + ctx.data(|d| d.get_temp(active_id())).unwrap_or(false) +} + +/// Alt pressed and released with nothing else in between (no key, click or wheel), off macOS. +fn alt_tapped(ctx: &Context) -> bool { + if cfg!(target_os = "macos") { + return false; + } + let mut armed: bool = ctx.data(|d| d.get_temp(alt_id())).unwrap_or(false); + let mut tapped = false; + ctx.input(|i| { + for e in &i.events { + match e { + Event::ModifiersChanged(m) if *m == Modifiers::ALT => armed = true, + Event::ModifiersChanged(m) => { + tapped |= armed && *m == Modifiers::NONE; + armed = false; + } + Event::PointerMoved(_) | Event::MouseMoved(_) | Event::Zoom(_) => {} + _ => armed = false, + } + } + }); + ctx.data_mut(|d| d.insert_temp(alt_id(), armed)); + tapped +} + +/// The next enabled entry of `rows` after `from` (`step` 1) or before it (-1), wrapping around; +/// from none: the first (or last) one. +fn step(rows: &[Row], from: Option, step: isize) -> Option { + let n = rows.len() as isize; + let start = from.map_or(if step > 0 { -1 } else { n }, |i| i as isize); + (1..=n).map(|k| (start + step * k).rem_euclid(n.max(1))).filter_map(|i| rows.get(i as usize)).find(|r| r.enabled).map(|r| r.id) +} + +fn focus(ctx: &Context, id: Id) { + ctx.memory_mut(|m| m.request_focus(id)); +} + +/// The bar's menu `i`, wrapping around. +fn nth_top(nav: &Nav, i: isize) -> Option<(Id, Id)> { + nav.tops.get(i.rem_euclid((nav.tops.len() as isize).max(1)) as usize).copied() +} + +/// Move the bar's keyboard focus to its menu `i` (wrapping). +fn focus_top(ctx: &Context, nav: &Nav, i: isize) { + if let Some((button, _)) = nth_top(nav, i) { + focus(ctx, button); + } +} + +/// Open the top-level menu `i` (wrapping) with its first entry highlighted once drawn. +fn open_top(ctx: &Context, nav: &Nav, i: isize) { + let Some((button, popup)) = nth_top(nav, i) else { return }; + Popup::open_id(ctx, popup); + focus(ctx, button); + ctx.data_mut(|d| { + d.insert_temp(pending_id(), FirstEntry(0)); + d.remove::(opened_id()); + }); +} + +/// Open the submenu of entry `row` in the menu at `depth`, its first entry highlighted once drawn. +fn open_submenu(ctx: &Context, nav: &Nav, depth: usize, row: Id) { + let Some(menu) = nav.levels.get(depth).and_then(|l| l.menu) else { return }; + let sub = SubMenu::id_from_widget_id(row); + show_submenu(ctx, menu, sub); + let pointer = ctx.pointer_hover_pos(); + ctx.data_mut(|d| { + let mut o: KeyOpened = d.get_temp(opened_id()).unwrap_or_default(); + o.submenus.retain(|(m, _)| *m != menu); + o.submenus.push((menu, sub)); + o.pointer = pointer; + d.insert_temp(opened_id(), o); + d.insert_temp(pending_id(), FirstEntry(depth + 1)); + }); +} + +/// Handle this frame's keys once the bar and its open menus are drawn (`nav`). +pub fn handle(ctx: &Context, nav: &Nav) { + let was_active = active(ctx); + let open = nav.tops.iter().position(|(_, p)| Popup::is_id_open(ctx, *p)); + let focus_id = ctx.memory(|m| m.focused()); + let bar = focus_id.and_then(|f| nav.tops.iter().position(|(b, _)| *b == f)); + if alt_tapped(ctx) { + if open.is_some() || bar.is_some() { + Popup::close_all(ctx); + if let Some(f) = focus_id { + ctx.memory_mut(|m| m.surrender_focus(f)); + } + } else if let Some((first, _)) = nav.tops.first() { + focus(ctx, *first); + } + ctx.data_mut(|d| d.insert_temp(active_id(), true)); + strip_keys(ctx); + return; + } + let now_active = open.is_some() || bar.is_some(); + ctx.data_mut(|d| d.insert_temp(active_id(), now_active)); + if !now_active { + ctx.data_mut(|d| { + d.remove::(pending_id()); + d.remove::(opened_id()); + }); + if was_active { + // (The key that closed the menus, e.g. Enter on an entry, stays with them.) + strip_keys(ctx); + } + return; + } + keep_key_opened(ctx); + // A menu opened from the keyboard and now shown: highlight its first entry (if it has an + // enabled one). + if let Some(FirstEntry(depth)) = ctx.data(|d| d.get_temp(pending_id())) + && let Some(level) = nav.levels.get(depth).filter(|l| !l.sizing) + { + if let Some(id) = step(&level.rows, None, 1) { + focus(ctx, id); + } + ctx.data_mut(|d| d.remove::(pending_id())); + } + let pressed = + |k: Key| ctx.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key, pressed: true, modifiers, .. } if *key == k && !modifiers.any()))); + let cur = nav.focused(focus_id); + let row = cur.and_then(|(d, i)| nav.levels.get(d)?.rows.get(i).copied()); + for (key, dir) in [(Key::ArrowDown, 1), (Key::ArrowUp, -1)] { + if !pressed(key) { + continue; + } + match (open, cur) { + (Some(_), Some((d, i))) => { + if let Some(id) = nav.levels.get(d).and_then(|l| step(&l.rows, Some(i), dir)) { + focus(ctx, id); + } + } + // Opened with the pointer: the deepest open menu's first (last) entry. + (Some(_), None) => { + if let Some(id) = nav.levels.last().and_then(|l| step(&l.rows, None, dir)) { + focus(ctx, id); + } + } + (None, _) if dir > 0 => open_top(ctx, nav, bar.unwrap_or(0) as isize), + (None, _) => {} + } + } + let top = open.or(bar).unwrap_or(0) as isize; + if pressed(Key::ArrowRight) { + match (cur, row) { + (Some((d, _)), Some(r)) if r.submenu => open_submenu(ctx, nav, d, r.id), + _ if open.is_some() => open_top(ctx, nav, top + 1), + _ => focus_top(ctx, nav, top + 1), + } + } + if pressed(Key::ArrowLeft) { + match cur { + Some((d, _)) if d > 0 => { + let (menu, owner) = (nav.levels.get(d - 1).and_then(|l| l.menu), nav.levels.get(d).and_then(|l| l.owner)); + if let Some(menu) = menu { + MenuState::from_id(ctx, menu, |s| s.open_item = None); + ctx.data_mut(|data| { + if let Some(mut o) = data.get_temp::(opened_id()) { + o.submenus.retain(|(m, _)| *m != menu); + data.insert_temp(opened_id(), o); + } + }); + } + if let Some(owner) = owner { + focus(ctx, owner); + } + } + _ if open.is_some() => open_top(ctx, nav, top - 1), + _ => focus_top(ctx, nav, top - 1), + } + } + if pressed(Key::Enter) || pressed(Key::Space) { + match (cur, row) { + // egui toggles a submenu on Enter: it opens (again), highlighting its first entry. + (Some((d, _)), Some(r)) if r.submenu => open_submenu(ctx, nav, d, r.id), + // A top-level menu that the press opened. + (None, _) if bar.is_some() && open.is_some() => ctx.data_mut(|d| { + d.insert_temp(pending_id(), FirstEntry(0)); + }), + _ => {} + } + } + // The arrows move the highlight here, not egui's spatial focus search. + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + strip_keys(ctx); + ctx.request_repaint(); +} + +/// Keep the submenus opened from the keyboard open while the pointer hasn't moved. +fn keep_key_opened(ctx: &Context) { + let Some(o) = ctx.data(|d| d.get_temp::(opened_id())) else { return }; + if ctx.pointer_hover_pos() != o.pointer { + ctx.data_mut(|d| d.remove::(opened_id())); + return; + } + for (menu, sub) in o.submenus { + show_submenu(ctx, menu, sub); + } +} + +/// Make `sub` the open submenu of `menu`. (egui forgets an open submenu that wasn't shown last +/// frame, so it counts as shown now.) +fn show_submenu(ctx: &Context, menu: Id, sub: Id) { + MenuState::mark_shown(ctx, sub); + MenuState::from_id(ctx, menu, |s| s.open_item = Some(sub)); +} + +/// The rest of the frame (shortcuts, panels) doesn't see this frame's keys. +fn strip_keys(ctx: &Context) { + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, Event::Key { .. } | Event::Text(_)))); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 91765d7a..d1bd1880 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1480,7 +1480,9 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { if ctx.input(|i| i.pointer.any_down()) || ctx.egui_wants_keyboard_input() { ctx.data_mut(|d| d.remove::(space_tap_id())); } - if ctx.egui_wants_keyboard_input() { + // While a menu or popup list is open, keys go to it, not to the app (Space must not start a + // preview behind the menu, #279). + if ctx.egui_wants_keyboard_input() || crate::widgets::any_menu_open(ctx) { return; } // Text editing in the viewer takes the clipboard events itself. @@ -1581,6 +1583,7 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); let mut clicked: Option<(String, Value)> = None; + let mut nav = crate::menu_keys::Nav::default(); ui.horizontal_centered(|ui| { ui.add_space(6.0); egui::MenuBar::new().ui(ui, |ui| { @@ -1588,13 +1591,15 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { if let MenuNode::Submenu { label, children } = node { let r = ui.menu_button(crate::i18n::label(app, "", label), |ui| { ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 }); - crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, &mut clicked)); + crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, &mut clicked, &mut nav, 0)); }); + nav.top(&r.response); app.auto.add(&format!("menu.{label}"), r.response.rect, label); } } }); }); + crate::menu_keys::handle(&ctx, &nav); if let Some((id, params)) = clicked && let Err(e) = invoke(app, &ctx, &id, if params.is_null() { json!({}) } else { params }) { @@ -1609,12 +1614,20 @@ pub(crate) fn menu_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, name: & if let Some(MenuNode::Submenu { children, .. }) = effectcraft_engine::menus::menu_bar().iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == name)) { - menu_nodes(app, ui, children, &mut clicked); + menu_nodes(app, ui, children, &mut clicked, &mut crate::menu_keys::Nav::default(), 0); } clicked.map(|(id, params)| (id, if params.is_null() { json!({}) } else { params })) } -fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], clicked: &mut Option<(String, Value)>) { +/// The entries `nodes` of a menu `depth` submenus deep, registered with `nav` for the keyboard. +fn menu_nodes( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + nodes: &[MenuNode], + clicked: &mut Option<(String, Value)>, + nav: &mut crate::menu_keys::Nav, + depth: usize, +) { for n in nodes { match n { MenuNode::Separator => { @@ -1644,6 +1657,7 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c None => r, }; app.auto.add(&format!("menu.{name}.{i}"), r.rect, &e.label); + nav.entry(ui, depth, &r, false); if r.clicked() { *clicked = Some((e.command.clone(), e.params.clone())); ui.close(); @@ -1653,13 +1667,19 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c MenuNode::Submenu { label, children } => { let ws = app.ui.workspace.clone(); let shown = crate::i18n::submenu(app, label, effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws, &[]))); - ui.menu_button((gutter(false), shown.as_str()), |ui| { + let r = ui.menu_button((gutter(false), shown.as_str()), |ui| { ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 }); - crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, clicked)); + crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1)); }); + nav.entry(ui, depth, &r.response, true); + if r.inner.is_some() { + nav.opened(depth, &r.response); + } } MenuNode::Item(e) => { - if menu_entry(app, ui, e) { + let r = menu_entry(app, ui, e); + nav.entry(ui, depth, &r, false); + if r.clicked() { *clicked = Some((e.command.clone(), e.params.clone())); ui.close(); } @@ -1693,7 +1713,7 @@ pub(crate) fn submenu_at(app: &mut EffectcraftApp, ui: &mut egui::Ui, path: &[&s /// The menu bar's entry for `command` in a panel's context menu (see [`submenu_at`]). pub(crate) fn entry_for(app: &EffectcraftApp, ui: &mut egui::Ui, command: &str, clicked: &mut Option<(String, Value)>) { if let Some((_, e)) = effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.command == command) - && menu_entry(app, ui, e) + && menu_entry(app, ui, e).clicked() { *clicked = Some((e.command.clone(), e.params_or_empty())); ui.close(); @@ -1711,7 +1731,7 @@ fn gutter(checked: bool) -> egui::Atom<'static> { (if checked { "✔" } else { "" }).atom_size(egui::vec2(14.0, 14.0)) } -fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> bool { +fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> egui::Response { let (label, check, enabled) = (entry_label(app, e), gutter(entry_checked(app, e) == Some(true)), entry_enabled(app, e)); // Edit ▸ Label's entries show the label's colour before its name. if e.command == "edit.label" @@ -1719,13 +1739,13 @@ fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> bool { { let out = ui.add_enabled_ui(enabled, |ui| egui::Button::new((check, crate::widgets::label_swatch(), label)).atom_ui(ui)).inner; crate::widgets::paint_label_swatch(ui, &out, l, &app.tokens); - return out.response.clicked(); + return out.response; } let mut b = egui::Button::new((check, label)); if let Some(s) = entry_shortcut(app, e) { b = b.shortcut_text(shortcut_text(&s)); } - ui.add_enabled(enabled, b).clicked() + ui.add_enabled(enabled, b) } #[cfg(test)] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5a7971a2..003cb9ad 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -459,6 +459,8 @@ pub fn popup_list(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, r egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| chosen = body(ui)); }); }); + let pass = ui.ctx().cumulative_pass_nr(); + ui.ctx().data_mut(|d| d.insert_temp(popup_list_pass_id(), pass)); let outside = pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| anchor.contains(p)); if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { ui.data_mut(|d| d.insert_temp(id.with("open"), false)); @@ -466,6 +468,18 @@ pub fn popup_list(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, r chosen } +/// The pass a [`popup_list`] was last drawn in. +fn popup_list_pass_id() -> egui::Id { + egui::Id::new("popup-list-pass") +} + +/// Whether a menu, context menu or popup list is open, or the menu bar has the keyboard (as of +/// the last frame): the app's keyboard shortcuts wait until it closes (#279). +pub fn any_menu_open(ctx: &egui::Context) -> bool { + let list_pass: Option = ctx.data(|d| d.get_temp(popup_list_pass_id())); + egui::Popup::is_any_open(ctx) || crate::menu_keys::active(ctx) || list_pass.is_some_and(|p| p + 1 >= ctx.cumulative_pass_nr()) +} + /// A menu's (or submenu's) entries, scrolling with a scroll bar when they are taller than the /// window instead of running off it (Edit on a short screen, #269; Blending Mode). Others show /// whole: egui sizes a new menu from a default 400 pt area, so without a minimum a longer one diff --git a/crates/ui-egui/tests/ui_menus.rs b/crates/ui-egui/tests/ui_menus.rs new file mode 100644 index 00000000..3d252d90 --- /dev/null +++ b/crates/ui-egui/tests/ui_menus.rs @@ -0,0 +1,122 @@ +//! The menu bar from the keyboard, and the app's shortcuts waiting while a menu is open (#279) +//! (egui_kittest, UI logic only). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Key, Modifiers, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +/// Press and release `key` (one frame each), then let the UI settle. +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// The label of the widget with keyboard focus (the highlighted menu entry). +fn focused(h: &Harness<'_, EffectcraftApp>) -> String { + h.query_by(|n| n.is_focused()).and_then(|n| n.accesskit_node().label()).unwrap_or_default().trim().to_string() +} + +fn shown(h: &Harness<'_, EffectcraftApp>, label: &str) -> bool { + h.query_by_label_contains(label).is_some() +} + +/// Space (or any shortcut) while a menu is open goes to the menu, not the app: it started the +/// preview behind the open menu. +#[test] +fn shortcuts_wait_while_a_menu_is_open() { + let mut h = harness(); + let edit = rect(&h, "menu.Edit"); + click_at(&mut h, edit.center()); + assert!(shown(&h, "Keyboard Shortcuts"), "the Edit menu is open"); + key(&mut h, Key::Space); + assert!(!h.state().playback.playing, "Space didn't start the preview"); + // With the menu closed, Space previews again. + key(&mut h, Key::Escape); + assert!(!shown(&h, "Keyboard Shortcuts"), "Escape closed the menu"); + key(&mut h, Key::Space); + assert!(h.state().playback.playing, "Space previews with the menus closed"); +} + +/// Alt focuses the menu bar; Down opens the menu, Up / Down move the highlight, Right opens a +/// submenu and Left closes it, Right on an entry opens the next menu, Escape closes them. +#[test] +fn arrow_keys_drive_the_menu_bar() { + if cfg!(target_os = "macos") { + // (macOS menus are the system's.) + return; + } + let mut h = harness(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::ALT)); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(focused(&h), "File", "Alt focused the menu bar"); + key(&mut h, Key::ArrowDown); + assert!(shown(&h, "Open Project"), "Down opened File"); + assert!(focused(&h).starts_with("New"), "its first entry is highlighted: {:?}", focused(&h)); + key(&mut h, Key::ArrowRight); + assert!(focused(&h).contains("New Project"), "Right opened the New submenu: {:?}", focused(&h)); + key(&mut h, Key::ArrowDown); + assert!(focused(&h).contains("New Project from Template"), "Down moved down the submenu: {:?}", focused(&h)); + key(&mut h, Key::ArrowLeft); + assert!(!shown(&h, "New Project from Template"), "Left closed the submenu"); + assert!(focused(&h).starts_with("New"), "and went back to its entry: {:?}", focused(&h)); + key(&mut h, Key::ArrowDown); + assert!(focused(&h).contains("Open Project"), "{:?}", focused(&h)); + key(&mut h, Key::ArrowRight); + assert!(!shown(&h, "Open Project"), "Right on an entry left File"); + assert!(shown(&h, "Keyboard Shortcuts"), "for Edit"); + key(&mut h, Key::ArrowLeft); + assert!(shown(&h, "Open Project"), "Left went back to File"); + key(&mut h, Key::Escape); + assert!(!shown(&h, "Open Project"), "Escape closed the menus"); + assert!(!h.state().playback.playing, "no key reached the app"); +} + +/// Up in a menu opened with the pointer highlights its last entry (wrapping around); Enter on a +/// submenu entry opens it with its first entry highlighted, and Enter chooses an entry. +#[test] +fn enter_chooses_the_highlighted_entry() { + let mut h = harness(); + let edit = rect(&h, "menu.Edit"); + click_at(&mut h, edit.center()); + key(&mut h, Key::ArrowUp); + if cfg!(target_os = "macos") { + assert!(focused(&h).starts_with("Keyboard Shortcuts"), "Up went to the last entry: {:?}", focused(&h)); + key(&mut h, Key::Enter); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "Enter chose it"); + } else { + assert!(focused(&h).starts_with("Preferences"), "Up went to the last entry: {:?}", focused(&h)); + key(&mut h, Key::Enter); + assert!(focused(&h).starts_with("General"), "Enter opened the submenu: {:?}", focused(&h)); + key(&mut h, Key::Enter); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Settings), "Enter chose General..."); + } + assert!(!shown(&h, "Quick Apply"), "and closed the menu"); +} diff --git a/docs/parity.md b/docs/parity.md index 9a4f2ad0..10cbf174 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -171,7 +171,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Web | 98% | 0.1 | No shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`) (M13.30: Render Queue, analyses and Content-Aware Fill render on each job worker's own WebGPU device in passes, particles and Advanced 3D read back under keys, layer buffers in the browser's disk cache; browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) | | 3D | 88% | 5.5 | reflections of other layers (Advanced 3D surfaces reflect only the Environment light; Material Options ▸ Appears in Reflections is stored but has no visible effect yet, #261), multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 | | Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; Fractal and the analysis / tool effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | -| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | +| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | | Masks & roto | 74% | 5.0 | (audit estimates; Roto Brush 2.0 / 3.0's trained model, MobileSAM in the swappable `segment` module, has since landed in M13.35; variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | | Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems; content-keyed RAM preview frames (an edit drops every comp's), audio scrubbing, more snapshot slots (G2 on 7 October: Audio, Lock and Shy switches keep the cached frames and playback with audio waits for frames instead of skipping them, #103; running out of video memory renders the frame on the CPU and keeps fewer GPU frames instead of crashing, #106; RAM preview keys with the render options, least-recently-shown eviction, Auto resolution while playing, Cache Before Playback that fits, an achieved-fps readout, Cache Frames When Idle and purges that clear what they name landed in M4.5–M4.8; 3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | From 7056801eb210d1108f537b0fb2a17438a8bc32a9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 18:27:13 +0700 Subject: [PATCH 2/5] Timeline: the composition marker bin adds comp markers where they are dropped (#275) As in After Effects, the right end of the Timeline's time ruler has a composition marker bin (an original icon drawn in code). Dragging out of it shows a marker following the pointer along the ruler's marker strip, snapping like a dragged marker (current time, work area, other markers); releasing over the time ruler's width adds a composition marker there with comp.addMarker (one "Add Marker" undo step). Dropped back on the bin, nothing is added. The time graph ends 18 px before the panel edge to leave the bin a column above the layers' scroll bar. Automation id: timeline.markerBin. Numpad * (or * typed with Shift+8), the keymap's Add Marker, now works in the window too: egui has no key for it, so it comes as text. The marker glyph and colour are shared helpers now (markers, nested markers, the dragged marker). Test: ui_timeline_zoom dragging_out_of_the_marker_bin_adds_a_comp_marker. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/icons.rs | 6 +++ crates/ui-egui/src/menus.rs | 10 ++++ crates/ui-egui/src/panels/markers_ui.rs | 61 ++++++++++++++++++++---- crates/ui-egui/src/panels/timeline.rs | 17 +++++-- crates/ui-egui/tests/ui_timeline_zoom.rs | 48 +++++++++++++++++-- docs/parity.md | 2 +- 6 files changed, 124 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 1068acab..c2e335d2 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -59,6 +59,8 @@ pub enum Icon { Keyframe, PickWhip, Flowchart, + /// The Timeline's composition marker bin: a marker tab resting in an open tray. + MarkerBin, // project items Folder, Comp, @@ -412,6 +414,10 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.rect(10.0, 9.5, 14.5, 13.5); pen.line(&[(6.0, 4.5), (12.25, 4.5), (12.25, 9.5)]); } + MarkerBin => { + pen.fill(&[(5.0, 2.0), (11.0, 2.0), (11.0, 7.5), (8.0, 10.5), (5.0, 7.5)]); + pen.line(&[(2.0, 9.0), (2.0, 14.0), (14.0, 14.0), (14.0, 9.0)]); + } Folder => pen.closed(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)]), Comp => { pen.rect(2.0, 3.0, 14.0, 13.0); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d1bd1880..3b7110e2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1512,6 +1512,16 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { }) .collect() }); + // Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it + // comes as text. + if clipboard && app.dialog.is_none() && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "*"))) { + let bound = app.session.shortcuts().bindings().into_iter().find(|(k, _)| *k == "Num*").map(|(_, b)| (b.command.clone(), b.params.clone())); + if let Some((id, params)) = bound + && let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params }) + { + app.ui.status = e; + } + } if events.is_empty() { return; } diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index 16fff58e..e66d9c3c 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -4,15 +4,17 @@ //! Gestures, as in After Effects: drag moves a marker (snapping to the current time, the work //! area and other markers), Alt-drag sets its duration, Cmd-click (Ctrl-click elsewhere) //! deletes it, double-click opens the Marker dialog, right-click offers Settings / Convert / -//! Delete. Protected regions (Responsive Design) shade the timeline. Every change is a -//! `markers.*` engine command, so it is undoable and agent-drivable. +//! Delete. Protected regions (Responsive Design) shade the timeline. A marker dragged out of the +//! composition marker bin, at the right end of the time ruler, adds a composition marker where it +//! is dropped. Every change is an engine command, so it is undoable and agent-drivable. use effectcraft_engine::project::{Comp, Layer, Marker}; use effectcraft_engine::time::Tick; -use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2}; use serde_json::{Value, json}; use super::timeline::TMap; +use crate::icons::{self, Icon}; use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; @@ -253,6 +255,17 @@ fn snap(app: &EffectcraftApp, comp: &Comp, tm: TMap, t: f64, skip: MarkerRef) -> t } +/// A marker's colour: its label's, pale yellow without one. +fn marker_color(t: &Tokens, label: effectcraft_engine::color::Label) -> Color32 { + if label == effectcraft_engine::color::Label::None { Color32::from_rgb(0xd8, 0xd8, 0x60) } else { t.label(label) } +} + +/// The marker glyph at `x` from `y0`, `h` points tall: a tab pointing down at the time. +fn marker_shape(x: f32, y0: f32, h: f32) -> Vec { + let ym = y0 + h * 0.6; + vec![pos2(x - 4.0, y0), pos2(x + 4.0, y0), pos2(x + 4.0, ym), pos2(x, y0 + h), pos2(x - 4.0, ym)] +} + /// Draw one marker (triangle + duration bar + comment) at `x` in a strip `[y0, y1]` and handle /// its gestures. `comp_t` is the marker's comp time. #[allow(clippy::too_many_arguments)] @@ -260,9 +273,8 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: let t = app.tokens; let ctx = ui.ctx().clone(); let x = tm.x(comp_t); - let col = if m.label == effectcraft_engine::color::Label::None { Color32::from_rgb(0xd8, 0xd8, 0x60) } else { t.label(m.label) }; + let col = marker_color(&t, m.label); let h = (y1 - y0).min(10.0); - let ym = y0 + h * 0.6; if m.duration > Tick(0) { let xe = tm.x(comp_t + m.duration.seconds()); let bar = Rect::from_min_max(pos2(x, y0 + 1.0), pos2(xe, y0 + h - 1.0)); @@ -276,7 +288,7 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: } p.line_segment([pos2(xe, y0), pos2(xe, y0 + h)], Stroke::new(1.0, col)); } - p.add(egui::Shape::convex_polygon(vec![pos2(x - 4.0, y0), pos2(x + 4.0, y0), pos2(x + 4.0, ym), pos2(x, y0 + h), pos2(x - 4.0, ym)], col, Stroke::NONE)); + p.add(egui::Shape::convex_polygon(marker_shape(x, y0, h), col, Stroke::NONE)); if !m.comment.is_empty() { p.text(pos2(x + 7.0, y0 + h / 2.0), Align2::LEFT_CENTER, m.comment.lines().next().unwrap_or_default(), Tokens::ui(10.0), t.text); } @@ -365,6 +377,37 @@ pub(crate) fn comp_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &C } } +/// The composition marker bin at the right end of the time ruler (`bin`), as in After Effects +/// (#275): drag a marker out of it and it follows the pointer along the marker strip `strip` +/// (snapping like a dragged marker); dropping it there adds a composition marker at that time, +/// one undo step. Dropped back on the bin or outside the time graph's width, nothing is added. +pub(crate) fn marker_bin(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &Comp, tm: TMap, bin: Rect, strip: Rect) { + let t = app.tokens; + let resp = ui.interact(bin, egui::Id::new("marker-bin"), Sense::drag()).on_hover_text("Composition marker bin: drag a marker to the time ruler"); + app.auto.add("timeline.markerBin", bin, "Composition marker bin"); + let hot = resp.hovered() || resp.dragged(); + if hot { + ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); + } + icons::paint(ui.painter(), bin, Icon::MarkerBin, if hot { t.icon_active } else { t.icon }); + let at = resp.interact_pointer_pos().filter(|p| p.x >= strip.min.x && p.x < strip.max.x).map(|p| { + let snapped = snap(app, comp, tm, tm.t(p.x), MarkerRef { layer: None, index: usize::MAX }); + snapped.min(comp.duration.seconds()) + }); + let Some(at) = at else { return }; + if resp.dragged() { + let h = strip.height().min(10.0); + let col = marker_color(&t, effectcraft_engine::color::Label::None); + let p = ui.painter().with_clip_rect(strip); + p.add(egui::Shape::convex_polygon(marker_shape(tm.x(at), strip.min.y, h), col, Stroke::NONE)); + } + if resp.drag_stopped() + && let Err(e) = app.session.execute("comp.addMarker", json!({"time": at})) + { + app.ui.status = e.to_string(); + } +} + /// Layer markers on a layer's row `r`; on a precomp layer, its comp's markers too (read-only, /// outlined: hover names them, a double-click opens the nested comp at the marker). pub(crate) fn layer_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, clip: Rect, comp: &Comp, layer: &Layer, tm: TMap, r: Rect) { @@ -399,10 +442,8 @@ fn nested_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter let (y0, h) = (r.min.y + 3.0, (r.height() - 6.0).min(10.0)); for (ct, i, m) in list { let x = tm.x(ct.seconds()); - let col = if m.label == effectcraft_engine::color::Label::None { Color32::from_rgb(0xd8, 0xd8, 0x60) } else { t.label(m.label) }.gamma_multiply(0.75); - let ym = y0 + h * 0.6; - let pts = vec![pos2(x - 4.0, y0), pos2(x + 4.0, y0), pos2(x + 4.0, ym), pos2(x, y0 + h), pos2(x - 4.0, ym)]; - p.add(egui::Shape::closed_line(pts, Stroke::new(1.2, col))); + let col = marker_color(&t, m.label).gamma_multiply(0.75); + p.add(egui::Shape::closed_line(marker_shape(x, y0, h), Stroke::new(1.2, col))); if !m.comment.is_empty() { p.text(pos2(x + 7.0, y0 + h / 2.0), Align2::LEFT_CENTER, m.comment.lines().next().unwrap_or_default(), Tokens::ui(10.0), t.text_dim); } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index fe19e376..e1059c56 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -394,6 +394,9 @@ fn tl_map_id() -> egui::Id { egui::Id::new("timeline-map") } +/// The column right of the time graph: the composition marker bin over the layers' scroll bar. +const GUTTER_W: f32 = 18.0; + /// The most a time ruler zooms in (pixels per second). const MAX_PPS: f64 = 4000.0; @@ -1128,7 +1131,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let oscroll = app.ui.timeline.outline_scroll; let cw = cols(rect.min.x - oscroll, left_w + overflow, vis); let graph_x0 = rect.min.x + left_w + 1.0; - let graph_x1 = rect.max.x - 10.0; + let graph_x1 = rect.max.x - GUTTER_W; ctx.data_mut(|d| d.insert_temp(egui::Id::new("timeline-graph-area"), (graph_x0, graph_x1 - graph_x0))); let fit_pps = fit_pps(graph_x1 - graph_x0, &comp); let pps = app.ui.timeline.pps.unwrap_or(fit_pps); @@ -1140,7 +1143,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let top = rect.min.y; let rows_top = top + header_h + colhdr_h; let rows_rect = Rect::from_min_max(pos2(rect.min.x, rows_top), pos2(rect.max.x, rect.max.y - footer_h)); - let graph_rect = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h + colhdr_h), pos2(graph_x1 + 10.0, rect.max.y - footer_h)); + let graph_rect = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h + colhdr_h), pos2(rect.max.x, rect.max.y - footer_h)); p.rect_filled(Rect::from_min_max(pos2(graph_x0, top), rect.max), 0.0, t.tl_bg); p.line_segment([pos2(graph_x0 - 1.0, top), pos2(graph_x0 - 1.0, rect.max.y)], Stroke::new(1.0, t.app_bg)); @@ -1208,6 +1211,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // ---- ruler (right). let ruler = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h), pos2(rect.max.x, top + header_h)); + // The composition marker bin, at the right end of the ruler above the layers' scroll bar. + let bin = Rect::from_min_max(pos2(graph_x1 + 2.0, ruler.max.y - 16.0), pos2(rect.max.x - 2.0, ruler.max.y - 2.0)); let nav = Rect::from_min_max(pos2(graph_x0 + 6.0, top + 6.0), pos2(graph_x1, top + 14.0)); // Time navigator: the visible span as a grey bar with blue end handles (as in After // Effects), over a dark track. @@ -1366,7 +1371,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let secs_per_label = label_frames as f64 * fd; let first = (tm.start / secs_per_label).floor() * secs_per_label; let mut s = first; - let pr = p.with_clip_rect(Rect::from_min_max(pos2(graph_x0, ruler.min.y), ruler.max)); + let pr = p.with_clip_rect(Rect::from_min_max(pos2(graph_x0, ruler.min.y), pos2(bin.min.x, ruler.max.y))); while s <= tm.t(graph_x1) + secs_per_label { let x = tm.x(s); pr.line_segment([pos2(x, ruler.max.y - 10.0), pos2(x, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick)); @@ -1385,7 +1390,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Scrub in the ruler: the current time jumps to the pointer as soon as the button goes down // (After Effects), then follows it. - let rresp = ui.interact(Rect::from_min_max(pos2(graph_x0, ruler.min.y + 12.0), ruler.max), egui::Id::new("tl-ruler"), Sense::click_and_drag()); + let rresp = + ui.interact(Rect::from_min_max(pos2(graph_x0, ruler.min.y + 12.0), pos2(bin.min.x, ruler.max.y)), egui::Id::new("tl-ruler"), Sense::click_and_drag()); if ((rresp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down())) || rresp.clicked()) && let Some(pt) = rresp.interact_pointer_pos() { @@ -2721,9 +2727,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Composition markers in the ruler; protected regions shade the layer rows. - let mstrip = Rect::from_min_max(pos2(graph_x0, ruler.max.y - 12.0), pos2(graph_x1 + 10.0, ruler.max.y)); + let mstrip = Rect::from_min_max(pos2(graph_x0, ruler.max.y - 12.0), pos2(bin.min.x, ruler.max.y)); let mrows = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)); super::markers_ui::comp_markers(app, ui, &comp, tm, mstrip, mrows); + super::markers_ui::marker_bin(app, ui, &comp, tm, bin, mstrip); // Spacebar held: the Hand tool. Dragging the ruler or the time graph scrolls it in time when // zoomed in (registered last, so it takes the drag from the bars, keys and markers there). diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index 6a238a93..48b19cb8 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -1,6 +1,6 @@ -//! Zooming the Timeline's time ruler with real input: Alt+wheel zooms out until the whole comp -//! shows (#158); the Time Navigator's ends drag to zoom and `;` toggles frame level / the whole -//! comp (#159). +//! The Timeline's time ruler with real input: Alt+wheel zooms out until the whole comp shows +//! (#158); the Time Navigator's ends drag to zoom and `;` toggles frame level / the whole comp +//! (#159); markers dragged out of the composition marker bin (#275). use effectcraft_engine::Session; use effectcraft_ui_egui::EffectcraftApp; @@ -89,7 +89,8 @@ fn drag_with(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifier fn visible(h: &Harness<'_, EffectcraftApp>) -> (f64, f64) { let e = h.state().auto.find("timeline.ruler").unwrap().clone(); let (start, pps) = e.label.split_once(',').map(|(a, b)| (a.parse::().unwrap(), b.parse::().unwrap())).unwrap(); - (start, start + (e.rect[2] - 16.0) as f64 / pps) + // (The time graph ends 18 px before the ruler, at the marker bin; it maps time from 6 px in.) + (start, start + (e.rect[2] - 24.0) as f64 / pps) } /// #159: dragging the navigator's end handles changes that side of the visible span; pulling @@ -271,3 +272,42 @@ fn a_press_in_the_ruler_moves_the_current_time() { h.event(Event::PointerButton { pos: at + vec2(100.0, 0.0), button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); h.step(); } + +/// #275: a marker dragged out of the composition marker bin, at the right end of the time ruler, +/// adds a composition marker where it is dropped, as one undo step; dragged back onto the bin, +/// none. Numpad * (typed `*`) adds one at the current time. +#[test] +fn dragging_out_of_the_marker_bin_adds_a_comp_marker() { + let (mut h, _) = harness(); + h.state_mut().ui.timeline.pps = Some(200.0); + h.state_mut().ui.timeline.start = 10.0; + h.run_steps(2); + let markers = |h: &Harness<'_, EffectcraftApp>| -> Vec { h.state().session.active_comp().unwrap().markers.iter().map(|m| m.time.seconds()).collect() }; + let bin = rect(&h, "timeline.markerBin"); + let ruler = rect(&h, "timeline.ruler"); + assert!(ruler.contains_rect(bin) && bin.max.x > ruler.max.x - 20.0, "at the ruler's right end: {bin:?} in {ruler:?}"); + // Out to the ruler and back onto the bin: nothing. + let at = pos2(ruler.min.x + 6.0 + 600.0, ruler.center().y); + h.event(Event::PointerMoved(bin.center())); + h.step(); + h.event(Event::PointerButton { pos: bin.center(), button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for p in [at, bin.center()] { + h.event(Event::PointerMoved(p)); + h.step(); + } + h.event(Event::PointerButton { pos: bin.center(), button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(markers(&h).is_empty(), "dropped on the bin: {:?}", markers(&h)); + // Dropped 3 s into the visible span (from 10 s at 200 px/s): a marker at 13 s. + let undo = h.state().session.history.undo.len(); + drag(&mut h, bin.center(), at); + assert_eq!(markers(&h), vec![13.0]); + let history = &h.state().session.history.undo; + assert_eq!((history.len(), history.last().map(|u| u.0.as_str())), (undo + 1, Some("Add Marker")), "one undo step"); + // Numpad * (it arrives as text): Add Marker at the current time. + h.state_mut().session.execute("time.set", json!({"time": 20.0})).unwrap(); + h.event(Event::Text("*".into())); + h.run_steps(2); + assert_eq!(markers(&h), vec![13.0, 20.0]); +} diff --git a/docs/parity.md b/docs/parity.md index 10cbf174..11c97bf8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -171,7 +171,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Web | 98% | 0.1 | No shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`) (M13.30: Render Queue, analyses and Content-Aware Fill render on each job worker's own WebGPU device in passes, particles and Advanced 3D read back under keys, layer buffers in the browser's disk cache; browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) | | 3D | 88% | 5.5 | reflections of other layers (Advanced 3D surfaces reflect only the Environment light; Material Options ▸ Appears in Reflections is stored but has no visible effect yet, #261), multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 | | Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; Fractal and the analysis / tool effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | -| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | +| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | | Masks & roto | 74% | 5.0 | (audit estimates; Roto Brush 2.0 / 3.0's trained model, MobileSAM in the swappable `segment` module, has since landed in M13.35; variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | | Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems; content-keyed RAM preview frames (an edit drops every comp's), audio scrubbing, more snapshot slots (G2 on 7 October: Audio, Lock and Shy switches keep the cached frames and playback with audio waits for frames instead of skipping them, #103; running out of video memory renders the frame on the CPU and keeps fewer GPU frames instead of crashing, #106; RAM preview keys with the render options, least-recently-shown eviction, Auto resolution while playing, Cache Before Playback that fits, an achieved-fps readout, Cache Frames When Idle and purges that clear what they name landed in M4.5–M4.8; 3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | From 8e4d9988af1c0ecdb48c3cebc2369186914bda34 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 18:27:56 +0700 Subject: [PATCH 3/5] Settings: the dialog no longer scrolls sideways (#284) The page column had a fixed 590 pt width, a few points more than the dialog had beside the page list, so the whole Settings dialog scrolled sideways a little. It takes the rest of the dialog's width now. Test: ui_scrolling settings_dialog_does_not_scroll_sideways. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/settings.rs | 4 +++- crates/ui-egui/tests/ui_scrolling.rs | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 86cbd8fd..d69e1435 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -459,7 +459,9 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.separator(); ui.vertical(|ui| { - ui.set_width(590.0); + // The rest of the dialog's width: a fixed width ran past it, so the whole + // dialog scrolled sideways a little (#284). + ui.set_width(ui.available_width()); let p = &all[idx]; ui.label(RichText::new(p.title).font(Tokens::semibold(14.0)).color(t.tab_text_active)); ui.add_space(6.0); diff --git a/crates/ui-egui/tests/ui_scrolling.rs b/crates/ui-egui/tests/ui_scrolling.rs index 8368f083..5f681623 100644 --- a/crates/ui-egui/tests/ui_scrolling.rs +++ b/crates/ui-egui/tests/ui_scrolling.rs @@ -143,3 +143,23 @@ fn effect_values_stay_clear_of_the_keyframe_navigator() { assert!(value.max.x <= nav.min.x, "the value ({value:?}) ends before the navigator ({nav:?})"); assert!(value.min.x > rect(&h, &format!("effectControls.prop.{uid}.stopwatch")).max.x, "and after the stopwatch"); } + +/// The Settings dialog fits its width: its page column was wider than the dialog, so the whole +/// dialog scrolled sideways a little (#284). +#[test] +fn settings_dialog_does_not_scroll_sideways() { + let mut h = harness(1400.0, 900.0); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "general"})).unwrap(); + h.run_steps(3); + let (ok, page) = (rect(&h, "settings.ok"), rect(&h, "settings.page.general")); + h.input_mut().events.push(Event::PointerMoved(page.center())); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(-400.0, 0.0), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + }); + h.run_steps(20); + assert_eq!((rect(&h, "settings.ok"), rect(&h, "settings.page.general")), (ok, page), "nothing moved sideways"); +} From 7a9c459e4206c1dbaf18382af8cc6e96642eb9f7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 18:31:29 +0700 Subject: [PATCH 4/5] Settings: Appearance > UI Scale sizes the whole interface (#284) Settings > Appearance > UI Scale (appearance.uiScale, 75-200 %, default 100) scales the whole interface on top of the display's own scaling, live as it is chosen; Cancel puts it back, OK saves it and a saved scale applies from the first frame. The Settings dialog's choice rows show a number setting with choices as its text. Automation id: settings.appearance.uiScale. egui's own Ctrl+= / Ctrl+- / Ctrl+0 zoom is off now: Ctrl+0 (Window > Project) also reset the zoom behind the app's back, and the scale is a setting. Test: engine tests_settings ui_scale_is_a_percentage_from_75_to_200; ui_panels ui_scale_setting_sizes_the_interface. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/prefs.rs | 7 +++++ crates/engine/src/tests_settings.rs | 22 ++++++++++++++ crates/ui-egui/src/lib.rs | 4 +++ crates/ui-egui/src/panels/settings.rs | 3 +- crates/ui-egui/tests/ui_panels.rs | 42 +++++++++++++++++++++++++++ docs/parity.md | 2 +- docs/preferences.md | 2 ++ 7 files changed, 80 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 881724c0..f4c8700b 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -25,6 +25,9 @@ pub const PREFS_FILE: &str = "prefs.json"; /// Settings ▸ General ▸ Language: (label, `general.language` value). pub const LANGUAGES: &[(&str, &str)] = &[("Match System", "system"), ("English", "en"), ("日本語", "ja"), ("简体中文", "zh-hans"), ("繁體中文", "zh-hant")]; +/// Settings ▸ Appearance ▸ UI Scale: (label, `appearance.uiScale` percent). +pub const UI_SCALES: &[(&str, &str)] = &[("75%", "75"), ("100%", "100"), ("125%", "125"), ("150%", "150"), ("175%", "175"), ("200%", "200")]; + /// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value). pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")]; @@ -140,6 +143,8 @@ page!(Appearance { theme: String = "dark".into(), /// User interface brightness, -1 (darker) … 1 (lighter). brightness: f64 = 0.0, + /// The size of the whole interface in percent (75–200), on top of the display's own scale. + ui_scale: u32 = 100, use_label_color_for_handles: bool = true, use_label_color_for_tabs: bool = true, cycle_mask_colors: bool = true, @@ -454,6 +459,7 @@ impl Prefs { a.interval_minutes = a.interval_minutes.clamp(1, 240); a.max_versions = a.max_versions.clamp(1, 99); self.appearance.brightness = self.appearance.brightness.clamp(-1.0, 1.0); + self.appearance.ui_scale = self.appearance.ui_scale.clamp(75, 200); let defaults = default_labels(); self.labels.truncate(defaults.len()); for d in defaults.iter().skip(self.labels.len()) { @@ -1009,6 +1015,7 @@ pub fn pages() -> Vec { items: vec![ s("appearance.theme", "Theme", Kind::Choice(&[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")]), true), s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true), + s("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true), Section("Labels and Colors"), s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true), s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, true), diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 52d3fab7..98a49643 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -87,6 +87,28 @@ fn prefs_set_validates_keys_types_and_ranges() { assert!(p.reset(Some("bogus")).is_err()); } +/// Settings ▸ Appearance ▸ UI Scale (#284): a percentage, 100 by default, kept within 75–200; +/// the dialog's choices set it as a number. +#[test] +fn ui_scale_is_a_percentage_from_75_to_200() { + let mut p = Prefs::default(); + assert_eq!(p.appearance.ui_scale, 100); + p.set("appearance.uiScale", json!("150")).unwrap(); + assert_eq!(p.appearance.ui_scale, 150); + assert_eq!(p.get("appearance.uiScale"), Some(json!(150))); + p.set("appearance.uiScale", json!(500)).unwrap(); + assert_eq!(p.appearance.ui_scale, 200); + p.set("appearance.uiScale", json!(10)).unwrap(); + assert_eq!(p.appearance.ui_scale, 75); + let page = crate::prefs::pages().into_iter().find(|pg| pg.id == "appearance").unwrap(); + let listed = page.items.iter().any(|i| { + matches!(i, crate::prefs::Item::Setting { key: "appearance.uiScale", kind: crate::prefs::Kind::Choice(c), live: true, .. } if c.contains(&("200%", "200"))) + }); + assert!(listed, "Settings ▸ Appearance offers it"); + // Older settings files without it get the default. + assert_eq!(Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "light"}}"#).appearance.ui_scale, 100); +} + #[test] fn every_schema_key_exists_and_docs_list_the_todo_settings() { let p = Prefs::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e18264c3..aba24063 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -373,6 +373,10 @@ impl EffectcraftApp { theme::apply_visuals(ctx, &self.tokens); let tips = p.general.show_tool_tips; ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY }); + // Settings ▸ Appearance ▸ UI Scale (#284). egui's own Ctrl+= / Ctrl+- / Ctrl+0 zoom stays + // off: Ctrl+0 is Window ▸ Project, and the scale is a setting. + ctx.options_mut(|o| o.zoom_with_keyboard = false); + ctx.set_zoom_factor(p.appearance.ui_scale as f32 / 100.0); self.ui.cache_when_idle = p.previews.cache_frames_when_idle; self.ui.viewer.fast_preview = p.previews.fast_previews; self.frames.set_budget(p.cache_budgets(self.session.sys_memory).preview); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index d69e1435..bd0af97a 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -141,7 +141,8 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value r } Kind::Choice(opts) => { - let curv = v.as_str().unwrap_or("").to_string(); + // (A number with choices, UI Scale, shows as its text.) + let curv = v.as_str().map_or_else(|| v.to_string(), str::to_string); let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string(); egui::ComboBox::from_id_salt(("settings", key)) .selected_text(shown) diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index 9fd7d94b..d58c2146 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -190,6 +190,48 @@ fn leaving_the_learn_workspace_closes_its_tutorials() { } } +/// #284: Settings ▸ Appearance ▸ UI Scale sizes the whole interface as soon as it is chosen, +/// Cancel puts it back, OK keeps it; Ctrl+0 (Window ▸ Project) doesn't reset it. +#[test] +fn ui_scale_setting_sizes_the_interface() { + let mut h = harness(); + let ctx = h.ctx.clone(); + let choose = |h: &mut Harness<'_, EffectcraftApp>, label: &str| { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "appearance"})).unwrap(); + h.run_steps(3); + click(h, "settings.appearance.uiScale"); + let option = h.query_by_label(label).unwrap_or_else(|| panic!("the {label} choice")).rect().center(); + click_at(h, option); + h.run_steps(2); + }; + assert_eq!(ctx.zoom_factor(), 1.0); + choose(&mut h, "150%"); + assert_eq!(h.state().session.prefs.appearance.ui_scale, 150); + assert_eq!(ctx.zoom_factor(), 1.5, "live"); + click(&mut h, "settings.cancel"); + h.run_steps(2); + assert_eq!((h.state().session.prefs.appearance.ui_scale, ctx.zoom_factor()), (100, 1.0), "Cancel put it back"); + choose(&mut h, "125%"); + click(&mut h, "settings.ok"); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert_eq!((h.state().session.prefs.appearance.ui_scale, ctx.zoom_factor()), (125, 1.25), "OK kept it"); + // Ctrl+0 opens the Project panel; egui's own Ctrl+0 zoom reset stays out of it. + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key: egui::Key::Num0, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.step(); + } + h.run_steps(2); + assert_eq!(ctx.zoom_factor(), 1.25); + // A saved scale applies from the first frame. + let mut s = Session::default(); + s.prefs.set("appearance.uiScale", json!(175)).unwrap(); + let mut h2 = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h2.run_steps(3); + assert_eq!(h2.ctx.zoom_factor(), 1.75); +} + /// Headless look at the panels (wgpu offscreen; needs a GPU adapter). Run with /// `PANELS_SNAPSHOT=/abs/dir cargo test -p effectcraft-ui-egui --test ui_panels -- --ignored`. #[test] diff --git a/docs/parity.md b/docs/parity.md index 11c97bf8..53be4d85 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -171,7 +171,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Web | 98% | 0.1 | No shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`) (M13.30: Render Queue, analyses and Content-Aware Fill render on each job worker's own WebGPU device in passes, particles and Advanced 3D read back under keys, layer buffers in the browser's disk cache; browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) | | 3D | 88% | 5.5 | reflections of other layers (Advanced 3D surfaces reflect only the Environment light; Material Options ▸ Appears in Reflections is stored but has no visible effect yet, #261), multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 | | Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; Fractal and the analysis / tool effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | -| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | +| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | | Masks & roto | 74% | 5.0 | (audit estimates; Roto Brush 2.0 / 3.0's trained model, MobileSAM in the swappable `segment` module, has since landed in M13.35; variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | | Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems; content-keyed RAM preview frames (an edit drops every comp's), audio scrubbing, more snapshot slots (G2 on 7 October: Audio, Lock and Shy switches keep the cached frames and playback with audio waits for frames instead of skipping them, #103; running out of video memory renders the frame on the CPU and keeps fewer GPU frames instead of crashing, #106; RAM preview keys with the render options, least-recently-shown eviction, Auto resolution while playing, Cache Before Playback that fits, an achieved-fps readout, Cache Frames When Idle and purges that clear what they name landed in M4.5–M4.8; 3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | diff --git a/docs/preferences.md b/docs/preferences.md index a51ac97d..df948f0d 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -56,6 +56,8 @@ Commands (CLI, MCP, control channel): - `previews.fastPreviews`: Fast Previews (Draft 3D, Faster Effects) - `appearance.theme`: Theme - `appearance.brightness`: Brightness +- `appearance.uiScale`: UI Scale, the size of the whole interface (75–200 %, on top of the + display's own scaling), applied as soon as it is chosen - `appearance.useLabelColorForHandles`: Use Label Color for Layer Handles and Paths - `grids.gridColor`: Color - `grids.gridSpacing`: Gridline Every From 65d2c34e5499e3575b49aa15879cc20c4eae45a6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 18:38:39 +0700 Subject: [PATCH 5/5] Menus: the Project panel's footage menu passes the keyboard navigator (#279, #290) The footage item's context menu from #383 builds its entries with menu_nodes, which #391 gave the menu bar's keyboard navigator and depth; like the other context menus it passes a fresh one. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3b7110e2..1128d6c0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1711,7 +1711,7 @@ pub(crate) fn submenu_at(app: &mut EffectcraftApp, ui: &mut egui::Ui, path: &[&s found = Some(n); } if let Some(n) = found { - menu_nodes(app, ui, std::slice::from_ref(n), clicked); + menu_nodes(app, ui, std::slice::from_ref(n), clicked, &mut crate::menu_keys::Nav::default(), 0); } if let Some((_, p)) = clicked.as_mut() && p.is_null()