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) |