Edit menu keys edit a focused text field, docked and in panel windows

The macOS menu bar takes Cmd+C, Cmd+X, Cmd+V, Cmd+A, Cmd+Z and Shift+Cmd+Z
before any window sees them. The menu only checked the main window's
keyboard focus, so with a text field focused in a floating panel's own OS
window those keys ran Copy, Paste or Undo on the project. The menu now asks
craft-ui which window is typing and sends the field its keys there (the
window fetches the clipboard text for Paste), and keeps the Edit items a
field takes enabled while one has the keyboard: Paste followed the project
clipboard and was greyed out with nothing copied there.
This commit is contained in:
aiongg
2026-10-26 02:29:04 +00:00
parent 6d063fc9de
commit 917c44cd26
4 changed files with 283 additions and 22 deletions
+8 -7
View File
@@ -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<String, CheckMenuItem>,
spec: NativeMenu,
structure: u64,
state: Option<u64>,
state: Option<(u64, bool)>,
events: Receiver<MenuEvent>,
/// The in-window-bar setting last applied (None: not yet).
in_window: Option<bool>,
@@ -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);
+83 -15
View File
@@ -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<NativeNode>) {
@@ -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<String>) -> Option<Vec<egui::Event>> {
/// 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<egui::ViewportId> {
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<Vec<egui::Event>> {
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<String>) -> Option<Vec<egui
Some(match command {
"edit.copy" => 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<String>) -> Option<Vec<egui
})
}
/// Hand a text field's Edit command to the window `window` whose field has the keyboard. The
/// main window gets the clipboard text itself and the events through the synthetic queue; a
/// floating panel's OS window gets each key pressed and released, and asks for the clipboard
/// text on Cmd+V (`craft_ui::docking::send_keys`).
fn send_to_text_field(app: &mut EffectcraftApp, ctx: &egui::Context, window: egui::ViewportId, events: Vec<egui::Event>) {
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<Value, String> {
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"));
}
}
+1
View File
@@ -1,5 +1,6 @@
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
mod native_menu_text_keys;
mod render_queue_snapshot;
mod shared_docking_ui;
mod ui_3d;
@@ -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<ViewportId>,
events: HashMap<ViewportId, Vec<Event>>,
}
type Host = Rc<RefCell<Windows>>;
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");
}