diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3b2ff265..68d62b58 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -497,10 +497,32 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V } /// Run an engine command and perform the frontend events it emitted right away. +/// Commands that fill the internal clipboard. +const COPY_COMMANDS: &[&str] = + &["edit.copy", "edit.cut", "edit.copyWithPropertyLinks", "edit.copyWithRelativePropertyLinks", "edit.copyExpressionOnly", "keys.copy", "effect.copy"]; + +/// A line for the system clipboard naming what EffectCraft copied. The system clipboard must +/// hold something: with nothing on it, the windowing layer sends no paste event for Ctrl+V. +fn clipboard_note(s: &effectcraft_engine::Session) -> String { + let st = &s.state; + let n = |n: usize, one: &str, many: &str| format!("EffectCraft: {n} {}", if n == 1 { one } else { many }); + if st.clip_is_keys && !st.key_clipboard.is_empty() { + n(st.key_clipboard.iter().map(|c| c.keys.len()).sum(), "keyframe", "keyframes") + } else if !st.effect_clipboard.is_empty() { + n(st.effect_clipboard.len(), "effect", "effects") + } else if st.link_clipboard.is_some() { + "EffectCraft: property links".into() + } else { + n(st.clipboard.len(), "layer", "layers") + } +} + fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { let r = app.session.execute(id, params).map_err(|e| e.to_string()); if let Err(e) = &r { app.ui.status = e.clone(); + } else if COPY_COMMANDS.contains(&id) && app.session.state.text_edit.is_none() { + ctx.copy_text(clipboard_note(&app.session)); } let events = app.session.drain_events(); for ev in events { @@ -1217,7 +1239,13 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { if ctx.egui_wants_keyboard_input() { return; } + // Text editing in the viewer takes the clipboard events itself. + let clipboard = app.session.state.text_edit.is_none(); let events: Vec<(egui::Key, egui::Modifiers)> = ctx.input(|i| { + // The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of + // key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut, + // Paste and their variants (Ctrl+Alt+C…) run. + let held = if i.modifiers.command { i.modifiers } else { egui::Modifiers::COMMAND }; i.events .iter() .filter_map(|e| match e { @@ -1226,6 +1254,9 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { { Some((*key, *modifiers)) } + egui::Event::Copy if clipboard => Some((egui::Key::C, held)), + egui::Event::Cut if clipboard => Some((egui::Key::X, held)), + egui::Event::Paste(_) if clipboard => Some((egui::Key::V, held)), _ => None, }) .collect() diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 6fc844d2..4f73d2be 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -79,3 +79,36 @@ fn a_key_drag_follows_the_pointer_for_its_whole_length() { drag(&mut h, ks[1], near, egui::Modifiers { shift: true, ..Default::default() }); assert_eq!(key_times(&h, id)[1], 0.5, "snapped to the current time"); } + +#[test] +fn ctrl_c_and_ctrl_v_copy_and_paste_keys_at_the_current_time() { + let (mut h, id, uid) = harness(); + let ks = keys(&h, uid); + // Click the 1 s key to select it, Ctrl+C (a clipboard event, not a key press). + h.input_mut().events.push(Event::PointerMoved(ks[1])); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: ks[1], button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: ks[1], button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert_eq!(h.state().session.state.selected_keys.len(), 1); + h.input_mut().events.push(Event::Copy); + h.step(); + let copied: Vec = h + .output() + .platform_output + .commands + .iter() + .filter_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }) + .collect(); + assert_eq!(copied, ["EffectCraft: 1 keyframe"], "the system clipboard is filled, so Ctrl+V sends a paste event"); + assert!(h.state().session.state.clip_is_keys); + // Move to 3 s, Ctrl+V: the key lands there. + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0)); + h.input_mut().events.push(Event::Paste("EffectCraft: 1 keyframe".into())); + h.run_steps(2); + assert_eq!(key_times(&h, id), vec![0.0, 1.0, 3.0]); +}