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/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/lib.rs b/crates/ui-egui/src/lib.rs index 2d659543..aba24063 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; @@ -372,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/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..1128d6c0 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. @@ -1510,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; } @@ -1581,6 +1593,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 +1601,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 +1624,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 +1667,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 +1677,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(); } @@ -1681,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() @@ -1693,7 +1723,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 +1741,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 +1749,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/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/settings.rs b/crates/ui-egui/src/panels/settings.rs index 86cbd8fd..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) @@ -459,7 +460,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/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/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/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/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"); +} 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 9a4f2ad0..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 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