diff --git a/apps/effectcraft/src/native_menu.rs b/apps/effectcraft/src/native_menu.rs index 9db2ffe76..c3b142c5f 100644 --- a/apps/effectcraft/src/native_menu.rs +++ b/apps/effectcraft/src/native_menu.rs @@ -2,10 +2,10 @@ //! [`effectcraft_ui_egui::native_menu`]'s spec of the engine's After Effects menu tree. //! //! Each frame [`NativeBar::update`] runs menu activations (the same command ids as the in-window -//! bar), and when [`native_menu::state_key`] changed it rebuilds the spec and pushes labels, -//! enabled and check state into the existing items; a structural change (Open Recent, a new -//! shortcut preset) rebuilds the menu. Settings ▸ Appearance ▸ Use In-Window Menu Bar on macOS -//! swaps back to the in-window bar. +//! bar), and when [`native_menu::state_key`] or whether a text field has the keyboard changed it +//! rebuilds the spec and pushes labels, enabled and check state into the existing items; a +//! structural change (Open Recent, a new shortcut preset) rebuilds the menu. Settings ▸ +//! Appearance ▸ Use In-Window Menu Bar on macOS swaps back to the in-window bar. use std::collections::HashMap; use std::sync::mpsc::{Receiver, channel}; @@ -24,7 +24,7 @@ pub struct NativeBar { items: HashMap, spec: NativeMenu, structure: u64, - state: Option, + state: Option<(u64, bool)>, events: Receiver, /// The in-window-bar setting last applied (None: not yet). in_window: Option, @@ -67,12 +67,13 @@ impl NativeBar { if in_window { return; } - let key = native_menu::state_key(app); + // While a text field has the keyboard, the Edit items it takes stay enabled. + let key = (native_menu::state_key(app), native_menu::text_field_window(ctx).is_some()); if self.state == Some(key) && self.menu.is_some() { return; } self.state = Some(key); - let spec = native_menu::build(app); + let spec = native_menu::build_now(app, ctx); let structure = spec.structure(); if self.menu.is_none() || structure != self.structure { self.install(&spec); diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index d7d4cb965..9288ec4e1 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -126,6 +126,27 @@ impl NativeMenu { } } +/// The native menu spec as the menu bar shows it now: [`build`], with the Edit items a text field +/// takes ([`edits_text`]) enabled while one has the keyboard ([`text_field_window`]). Paste +/// otherwise follows the project clipboard and would be greyed out with nothing copied there, +/// while the field can paste. +pub fn build_now(app: &EffectcraftApp, ctx: &egui::Context) -> NativeMenu { + fn enable(nodes: &mut [NativeNode]) { + for node in nodes { + match node { + NativeNode::Item(item) if edits_text(&item.command) => item.enabled = true, + NativeNode::Submenu { children, .. } => enable(children), + _ => {} + } + } + } + let mut menu = build(app); + if text_field_window(ctx).is_some() { + enable(&mut menu.menus); + } + menu +} + /// Build the native menu spec for the app's current state. pub fn build(app: &EffectcraftApp) -> NativeMenu { fn rec(app: &EffectcraftApp, nodes: &[MenuNode], path: &str, out: &mut Vec) { @@ -332,9 +353,23 @@ pub fn state_key(app: &EffectcraftApp) -> u64 { h.finish() } -/// Commands that act on a focused text field instead (Edit menu while typing): the egui event to -/// send in their place. -fn text_field_event(command: &str, clipboard: Option) -> Option> { +/// The window whose text field has the keyboard: a floating panel's own OS window, or the main +/// window, where any widget that holds the keyboard counts (the Viewer's text editor is not an +/// egui text field). +pub fn text_field_window(ctx: &egui::Context) -> Option { + craft_ui::docking::typing_window(ctx).or_else(|| ctx.egui_wants_keyboard_input().then_some(egui::ViewportId::ROOT)) +} + +/// Whether a focused text field takes the menu command `command` ([`activate`]): the native menu +/// keeps such items enabled while one has the keyboard, whatever the project allows. +pub fn edits_text(command: &str) -> bool { + text_field_event(command).is_some() +} + +/// Commands that act on a focused text field instead (Edit menu while typing): the egui events +/// to send in their place. Paste is a Cmd+V press, which the field's window turns into a request +/// for the clipboard text. +fn text_field_event(command: &str) -> Option> { let key = |k: egui::Key, shift: bool| egui::Event::Key { key: k, physical_key: None, @@ -345,7 +380,7 @@ fn text_field_event(command: &str, clipboard: Option) -> Option vec![egui::Event::Copy], "edit.cut" => vec![egui::Event::Cut], - "edit.paste" => vec![egui::Event::Paste(clipboard?)], + "edit.paste" => vec![key(egui::Key::V, false)], "edit.selectAll" => vec![key(egui::Key::A, false)], "edit.undo" => vec![key(egui::Key::Z, false)], "edit.redo" => vec![key(egui::Key::Z, true)], @@ -353,8 +388,43 @@ fn text_field_event(command: &str, clipboard: Option) -> Option) { + if window == egui::ViewportId::ROOT { + for event in events { + match event { + egui::Event::Key { key: egui::Key::V, .. } => { + if let Some(text) = app.hooks.clipboard_text.as_ref().and_then(|f| f()) { + app.synthetic.push(egui::Event::Paste(text)); + } + } + event => app.synthetic.push(event), + } + } + ctx.request_repaint(); + return; + } + let events = events + .into_iter() + .flat_map(|event| { + let release = match &event { + egui::Event::Key { key, physical_key, modifiers, .. } => { + Some(egui::Event::Key { key: *key, physical_key: *physical_key, pressed: false, repeat: false, modifiers: *modifiers }) + } + _ => None, + }; + std::iter::once(event).chain(release) + }) + .collect(); + craft_ui::docking::send_keys(ctx, window, events); +} + /// Run a native menu item (by id) the way the in-window menu bar does. While a text field has -/// the keyboard, Edit ▸ Cut/Copy/Paste/Select All/Undo/Redo edit the text instead. +/// the keyboard ([`text_field_window`]), Edit ▸ Cut/Copy/Paste/Select All/Undo/Redo edit the +/// text instead. pub fn activate(app: &mut EffectcraftApp, ctx: &egui::Context, menu: &NativeMenu, id: &str) -> Result { let item = menu.find(id).ok_or_else(|| format!("no native menu item `{id}`"))?; let (command, params) = (item.command.clone(), item.params.clone()); @@ -369,13 +439,12 @@ pub fn activate(app: &mut EffectcraftApp, ctx: &egui::Context, menu: &NativeMenu ctx.request_repaint(); return Ok(Value::Null); } - if ctx.egui_wants_keyboard_input() { - let clip = if command == "edit.paste" { app.hooks.clipboard_text.as_ref().and_then(|f| f()) } else { None }; - if let Some(events) = text_field_event(&command, clip) { - app.synthetic.extend(events); - ctx.request_repaint(); - return Ok(Value::Null); - } + // The menu bar takes the Edit menu's key equivalents before any window sees them: while a + // text field has the keyboard, in the main window or a floating panel's own window, the + // field gets the key and the project command does not run. + if let (Some(window), Some(events)) = (text_field_window(ctx), text_field_event(&command)) { + send_to_text_field(app, ctx, window, events); + return Ok(Value::Null); } let params = if params.is_null() { json!({}) } else { params }; let (command, params) = crate::menus::menu_choice(app, command, params); @@ -663,8 +732,7 @@ mod tests { activate(&mut app, &ctx, &spec, &id).unwrap(); assert_eq!(app.session.active_comp().unwrap().layers.len(), n + 1); assert!(activate(&mut app, &ctx, &spec, "m.999").is_err()); - assert!(text_field_event("edit.copy", None).is_some()); - assert!(text_field_event("edit.paste", None).is_none()); - assert!(text_field_event("layer.newNull", None).is_none()); + assert!(edits_text("edit.copy") && edits_text("edit.paste")); + assert!(!edits_text("layer.newNull")); } } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index b5492c1b5..6b4c08912 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, one binary (each module is a former `tests/.rs`). +mod native_menu_text_keys; mod render_queue_snapshot; mod shared_docking_ui; mod ui_3d; diff --git a/crates/ui-egui/tests/it/native_menu_text_keys.rs b/crates/ui-egui/tests/it/native_menu_text_keys.rs new file mode 100644 index 000000000..d740c6b46 --- /dev/null +++ b/crates/ui-egui/tests/it/native_menu_text_keys.rs @@ -0,0 +1,191 @@ +//! The macOS menu bar takes the Edit menu's key equivalents before any window sees them: while a +//! text field has the keyboard, in a docked panel or a floating panel's own OS window, they edit +//! the field and leave the project alone, and the items stay enabled. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::{menus, native_menu}; +use egui::{Event, Modifiers, PointerButton, Pos2, ViewportCommand, ViewportId, ViewportInfo, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +fn app() -> EffectcraftApp { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Solid", "color": "#406080"})).unwrap(); + let mut app = EffectcraftApp::new(s); + app.ui.start_screen = false; + app.hooks.clipboard_text = Some(Box::new(|| Some("pasted".into()))); + app +} + +/// What the Edit commands would change in the project. +fn project(app: &EffectcraftApp) -> (u64, usize, usize, usize) { + let s = &app.session; + (s.revision, s.state.clipboard.len(), s.history.undo.len(), s.history.redo.len()) +} + +/// Choose the native menu item that runs `command`, then deliver what it queued for the main +/// window (kittest does not call `raw_input_hook`). +fn menu(h: &mut Harness<'_, EffectcraftApp>, command: &str) { + let spec = native_menu::build(h.state()); + let id = spec.items().into_iter().find(|i| i.command == command).unwrap().id.clone(); + let ctx = h.ctx.clone(); + native_menu::activate(h.state_mut(), &ctx, &spec, &id).unwrap(); + for _ in 0..3 { + let mut raw = egui::RawInput::default(); + eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut raw); + h.input_mut().events.extend(raw.events); + h.step(); + h.state_mut().mark_synthetic_applied(); + } + h.run_steps(2); +} + +/// Whether the menu bar shows `command`'s item enabled now, and whether the project alone would. +fn enabled(h: &Harness<'_, EffectcraftApp>, command: &str) -> (bool, bool) { + let find = |menu: native_menu::NativeMenu| menu.items().into_iter().find(|i| i.command == command).unwrap().enabled; + (find(native_menu::build_now(h.state(), &h.ctx)), find(native_menu::build(h.state()))) +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, window: Option<&Host>) { + let send = |h: &mut Harness<'_, EffectcraftApp>, event: Event| match window { + Some(host) => to_window(host, event), + None => h.input_mut().events.push(event), + }; + send(h, Event::PointerMoved(at)); + h.step(); + for pressed in [true, false] { + send(h, Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } +} + +/// Stand-in OS windows for floating panels: each runs its own pass with its own input, and +/// asking one for the clipboard text gives it "pasted". +#[derive(Default)] +struct Windows { + window: Option, + events: HashMap>, +} + +type Host = Rc>; + +fn to_window(host: &Host, event: Event) { + let mut host = host.borrow_mut(); + let Some(window) = host.window else { panic!("no floating panel window") }; + host.events.entry(window).or_default().push(event); +} + +fn native_windows(h: &mut Harness<'_, EffectcraftApp>) -> Host { + let host = Host::default(); + let shared = host.clone(); + egui::Context::set_immediate_viewport_renderer(move |ctx, mut viewport| { + let id = viewport.ids.this; + let size = viewport.builder.inner_size.unwrap_or(vec2(400.0, 300.0)); + let time = ctx.input(|i| i.time); + let events = { + let mut host = shared.borrow_mut(); + host.window = Some(id); + host.events.remove(&id).unwrap_or_default() + }; + let viewports = [(ViewportId::ROOT, false), (id, true)] + .into_iter() + .map(|(id, focused)| (id, ViewportInfo { focused: Some(focused), native_pixels_per_point: Some(1.0), ..Default::default() })) + .collect(); + let input = egui::RawInput { + viewport_id: id, + viewports, + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, size)), + events, + focused: true, + time: Some(time), + ..Default::default() + }; + let mut full = ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)); + full.textures_delta.clear(); + if full.viewport_output.get(&id).is_some_and(|out| out.commands.contains(&ViewportCommand::RequestPaste)) { + shared.borrow_mut().events.entry(id).or_default().push(Event::Paste("pasted".into())); + } + }); + h.ctx.set_embed_viewports(false); + // A main window that reports its place: not Wayland, where floating panels stay in it. + let screen = h.ctx.content_rect(); + h.input_mut().viewports.entry(ViewportId::ROOT).or_default().outer_rect = Some(screen); + h.run_steps(3); + let window = host.borrow().window.expect("the floating panel opens in an OS window of its own"); + // That window has the keyboard. + let input = h.input_mut(); + input.focused = false; + for (id, focused) in [(ViewportId::ROOT, false), (window, true)] { + input.viewports.entry(id).or_default().focused = Some(focused); + } + h.run_steps(2); + host +} + +#[test] +fn edit_menu_keys_edit_a_text_field_in_a_panel_window() { + let mut app = app(); + menus::invoke(&mut app, &egui::Context::default(), "window.panelWindow", json!({"panel": "Project", "window": true})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + let host = native_windows(&mut h); + let at = center(&h, "project.search"); + click(&mut h, at, Some(&host)); + to_window(&host, Event::Text("abc".into())); + h.run_steps(3); + let window = host.borrow().window.unwrap(); + assert_eq!(h.state().ui.project_search, "abc"); + assert!(!h.ctx.egui_wants_keyboard_input(), "the main window has no focused widget"); + assert_eq!(native_menu::text_field_window(&h.ctx), Some(window)); + assert_eq!(enabled(&h, "edit.paste"), (true, false), "Paste stays enabled with nothing copied in the project"); + let before = project(h.state()); + menu(&mut h, "edit.selectAll"); + menu(&mut h, "edit.cut"); + assert_eq!(h.state().ui.project_search, "", "Select All and Cut act on the field"); + menu(&mut h, "edit.paste"); + assert_eq!(h.state().ui.project_search, "pasted"); + menu(&mut h, "edit.undo"); + assert_eq!(h.state().ui.project_search, "", "Undo undoes the paste"); + menu(&mut h, "edit.copy"); + assert_eq!(project(h.state()), before, "the project is untouched"); + assert_eq!(h.state().ui.project_search, ""); +} + +#[test] +fn edit_menu_keys_edit_a_docked_text_field() { + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + h.run_steps(3); + let at = center(&h, "project.search"); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.input_mut().events.push(Event::Text("abc".into())); + h.run_steps(3); + assert_eq!(h.state().ui.project_search, "abc"); + assert_eq!(native_menu::text_field_window(&h.ctx), Some(ViewportId::ROOT)); + assert_eq!(enabled(&h, "edit.paste"), (true, false), "Paste stays enabled with nothing copied in the project"); + let before = project(h.state()); + menu(&mut h, "edit.selectAll"); + menu(&mut h, "edit.cut"); + assert_eq!(h.state().ui.project_search, "", "Select All and Cut act on the field"); + menu(&mut h, "edit.paste"); + assert_eq!(h.state().ui.project_search, "pasted"); + menu(&mut h, "edit.undo"); + assert_eq!(h.state().ui.project_search, "", "Undo undoes the paste"); + menu(&mut h, "edit.copy"); + assert_eq!(project(h.state()), before, "the project is untouched"); +}