mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 03:28:22 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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,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");
|
||||
}
|
||||
Reference in New Issue
Block a user