diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2a9b85a8..8b25a1e8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1465,8 +1465,27 @@ fn space_tap_id() -> egui::Id { egui::Id::new("spacebar-tap") } +/// The modifiers held when this frame's input began: the state the last pass ended with, if it +/// ran this (egui keeps only the state after the frame's events). Records this pass's state. +fn modifiers_at_start(ctx: &egui::Context) -> Option { + let id = egui::Id::new("shortcut-modifiers"); + let pass = ctx.cumulative_pass_nr(); + let now = ctx.input(|i| i.modifiers); + ctx.data_mut(|d| { + let last = d.get_temp::<(u64, egui::Modifiers)>(id); + d.insert_temp(id, (pass, now)); + last.and_then(|(p, m)| (p.checked_add(1) == Some(pass)).then_some(m)) + }) +} + +/// Only Shift held (Shift+Delete, not Ctrl/Alt+Shift+Delete). +fn only_shift(m: egui::Modifiers) -> bool { + m.shift && !m.alt && !m.ctrl && !m.command && !m.mac_cmd +} + /// Dispatch keyboard shortcuts (skipped while typing in a text field). pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { + let start = modifiers_at_start(ctx); // Dialog cancellation owns Escape even when a text field has keyboard focus. if app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { if crate::panels::shortcut_editor::recording(app) || egui::Popup::is_any_open(ctx) { @@ -1501,17 +1520,23 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { } // Text editing in the viewer takes the clipboard events itself. let clipboard = app.session.state.text_edit.is_none(); + let project = app.ui.focused == PanelKind::Project; let events: Vec<(egui::Key, egui::Modifiers, bool)> = 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 }; - // On Windows, Shift+Delete is the old Cut key and arrives as Cut too. The Project panel - // has no clipboard: there it is Shift+Delete (Delete Project Items Without Confirmation). - let shift_delete = !i.modifiers.command && i.modifiers.shift && app.ui.focused == PanelKind::Project; - i.events - .iter() - .filter_map(|e| match e { + // Paste and their variants (Ctrl+Alt+C…) run. Clipboard events carry no modifiers and + // `i.modifiers` is the state after all of the frame's events, so each is read with the + // modifiers where it is in the stream: walk the events from the frame's starting state. + // `None` when that isn't known. + let changed = i.events.iter().any(|e| matches!(e, egui::Event::ModifiersChanged(_) | egui::Event::WindowFocused(false))); + let mut at = if changed { start } else { Some(i.modifiers) }; + let held = |at: Option| at.filter(|m| m.command).unwrap_or(egui::Modifiers::COMMAND); + let mut out = Vec::new(); + for e in &i.events { + match e { + egui::Event::ModifiersChanged(m) => at = Some(*m), + // egui forgets the modifiers held when the window loses focus. + egui::Event::WindowFocused(false) => at = Some(egui::Modifiers::NONE), egui::Event::Key { key, pressed: true, modifiers, repeat, .. } if !*repeat || matches!( @@ -1519,16 +1544,22 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { egui::Key::PageUp | egui::Key::PageDown | egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp | egui::Key::ArrowDown ) => { - Some((*key, *modifiers, true)) + out.push((*key, *modifiers, true)); } - egui::Event::Key { key: egui::Key::Space, pressed: false, .. } => Some((egui::Key::Space, egui::Modifiers::NONE, false)), - egui::Event::Copy if clipboard => Some((egui::Key::C, held, true)), - egui::Event::Cut if clipboard && shift_delete => Some((egui::Key::Delete, i.modifiers, true)), - egui::Event::Cut if clipboard => Some((egui::Key::X, held, true)), - egui::Event::Paste(_) if clipboard => Some((egui::Key::V, held, true)), - _ => None, - }) - .collect() + egui::Event::Key { key: egui::Key::Space, pressed: false, .. } => out.push((egui::Key::Space, egui::Modifiers::NONE, false)), + egui::Event::Copy if clipboard => out.push((egui::Key::C, held(at), true)), + // On Windows, Shift+Delete is the old Cut key and arrives as Cut too. The Project + // panel has no clipboard: there a Cut with only Shift held is Shift+Delete (Delete + // Project Items Without Confirmation). Never when the modifiers aren't known. + egui::Event::Cut if clipboard && project && at.is_some_and(only_shift) => { + out.push((egui::Key::Delete, egui::Modifiers::SHIFT, true)); + } + egui::Event::Cut if clipboard => out.push((egui::Key::X, held(at), true)), + egui::Event::Paste(_) if clipboard => out.push((egui::Key::V, held(at), true)), + _ => {} + } + } + out }); // Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it // comes as text. diff --git a/crates/ui-egui/tests/ui_project_delete.rs b/crates/ui-egui/tests/ui_project_delete.rs index 052d2ca7..f217c29d 100644 --- a/crates/ui-egui/tests/ui_project_delete.rs +++ b/crates/ui-egui/tests/ui_project_delete.rs @@ -63,17 +63,18 @@ enum ShiftDelete { fn shift_delete(h: &mut Harness<'_, EffectcraftApp>, how: ShiftDelete) { match how { ShiftDelete::Key => key(h, Key::Delete, Modifiers::SHIFT), - ShiftDelete::WindowsCut => { - h.input_mut().events.push(Event::ModifiersChanged(Modifiers::SHIFT)); - h.step(); - h.input_mut().events.push(Event::Cut); - h.step(); - h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); - h.run_steps(2); - } + // Shift down, Cut and Shift up all in one frame. + ShiftDelete::WindowsCut => frame(h, &[Event::ModifiersChanged(Modifiers::SHIFT), Event::Cut, Event::ModifiersChanged(Modifiers::NONE)]), } } +/// Queue `events` together and run them as one frame. +fn frame(h: &mut Harness<'_, EffectcraftApp>, events: &[Event]) { + h.input_mut().events.extend(events.iter().cloned()); + h.step(); + h.run_steps(2); +} + const SHIFT_DELETE: [ShiftDelete; 2] = [ShiftDelete::Key, ShiftDelete::WindowsCut]; /// Items that compositions use ask first, as in After Effects: answer Delete when asked. @@ -336,3 +337,30 @@ fn shift_delete_in_other_panels_keeps_project_items() { assert!(items.iter().all(|i| h.state().session.project.item(*i).is_some()), "{how:?} in the Composition panel"); } } + +/// A Cut is read with the modifiers held where it is among the frame's events, not those held +/// after the frame: Shift released in the same frame is still Shift+Delete, and a Ctrl+X followed +/// in the same frame by Ctrl up and Shift down is not. +#[test] +fn a_cut_is_read_with_the_modifiers_where_it_is_in_the_frame() { + use Event::{Cut, ModifiersChanged as M}; + let (shift, ctrl, none) = (Modifiers::SHIFT, Modifiers::COMMAND, Modifiers::NONE); + // (held from the frame before, this frame's events, whether the solid in use goes) + let cases: [(Modifiers, Vec, bool); 5] = [ + (shift, vec![Cut, M(none)], true), + (ctrl, vec![Cut, M(none), M(shift)], false), + (none, vec![M(shift), Cut, M(none)], true), + (none, vec![M(ctrl), Cut, M(none), M(shift)], false), + (shift, vec![M(none), Cut, M(shift)], false), + ]; + for (n, (before, events, deletes)) in cases.into_iter().enumerate() { + let (mut h, items, _) = harness(); + let solid = items[1]; + click(&mut h, &format!("project.item.{}.name", solid.0), Modifiers::NONE); + frame(&mut h, &[M(before)]); + frame(&mut h, &events); + frame(&mut h, &[M(none)]); + assert_eq!(h.state().dialog, None, "case {n}: no prompt"); + assert_eq!(h.state().session.project.item(solid).is_none(), deletes, "case {n}: {events:?} after {before:?}"); + } +}