From e3778b1afd7db1fb650f7b082c6726e93036cde4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 07:15:22 +0700 Subject: [PATCH] =?UTF-8?q?M3.15:=20Ctrl+C=20/=20Ctrl+X=20/=20Ctrl+V=20rea?= =?UTF-8?q?ch=20Edit=20=E2=96=B8=20Copy,=20Cut=20and=20Paste=20in=20the=20?= =?UTF-8?q?real=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The windowing layer delivers Ctrl+C, Ctrl+X and Ctrl+V as clipboard events instead of key presses, and the shortcut dispatcher only read key presses: in the app, copying and pasting keyframes, layers and effects with the keyboard did nothing (tests passed because they inject key presses). Clipboard events now map back to their keys with the modifiers held (so Ctrl+Alt+C and the other variants work too), except while text is edited in the viewer, which handles them itself. Ctrl+V sends no event while the system clipboard is empty, so copying now also puts a line naming what was copied on it ("EffectCraft: 3 keyframes"). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 31 ++++++++++++++++++++++++++ crates/ui-egui/tests/ui_keyframes.rs | 33 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) 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]); +}