Project: a Cut is read with the modifiers held where it is in the frame (#484)

Cause: clipboard events carry no modifiers, and the Cut and Copy/Paste
mapping read i.modifiers, egui's state after all of the frame's events.
A Shift+Delete with Shift released in the same frame became Ctrl+X (no
deletion), and a Ctrl+X followed in the same frame by Ctrl up and Shift
down became Shift+Delete: the selected items and the layers using them
deleted without the prompt.

Fix: walk the frame's events in order from the modifiers held when it
began (the state the last pass ended with, recorded each pass), so each
Cut, Copy and Paste uses the modifiers at its place in the stream. When
that state isn't known (a pass was skipped), a Cut is never Shift+Delete.
Only Shift alone counts, not Ctrl/Alt+Shift.

Tests: a_cut_is_read_with_the_modifiers_where_it_is_in_the_frame queues
modifier changes and a Cut in one frame (Shift then Cut and Shift up;
Ctrl, Cut, Ctrl up, Shift down; and the mixed orders); the Windows
Shift+Delete helper now sends Shift, Cut and Shift up as one frame. They
fail on the previous mapping and pass now.
This commit is contained in:
tylerjenningsw 2026-10-09 19:55:48 -04:00
parent 80a23372df
commit 4359899f7a
2 changed files with 84 additions and 25 deletions

View File

@ -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<egui::Modifiers> {
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<egui::Modifiers>| 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.

View File

@ -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<Event>, 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:?}");
}
}