fix(masks): Delete / Backspace and Cmd+Z take back the last pen point while drawing (fixes #1345)

This commit is contained in:
Marco Arnulfo 2026-10-12 18:45:39 +02:00
parent 71438c627b
commit c580406234
3 changed files with 98 additions and 0 deletions

View File

@ -1453,6 +1453,7 @@ impl FilmcraftApp {
if self.session.trim_play.dynamic.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
let _ = self.session.execute("trim.cancelDynamic", json!({}));
}
panels::masks::pen_take_back(self, ctx, text_focused);
// Panel shortcuts of the focused panel override application shortcuts on the same chord.
// egui ignores extra Shift and Alt when matching, so the most specific chords go first
// across both scopes (the sort is stable, so the panel still wins a tie): otherwise the

View File

@ -565,6 +565,33 @@ pub fn draft_path(points: &[[f64; 4]]) -> MaskPath {
}
}
/// Delete / Backspace and Cmd+Z take back the last pen point while a mask is drawn (#1345). The
/// draft is outside the undo history: run before the shortcuts, so the keys reach neither Clear
/// (of the clip being masked) nor Undo (of the previous project edit). Taking back the last point
/// drops the draft.
pub(crate) fn pen_take_back(app: &mut FilmcraftApp, ctx: &egui::Context, text_focused: bool) {
let Some(d) = app.ui.mask_pen.as_mut() else { return };
let n = ctx.input_mut(|i| {
// Shift+Cmd+Z is Redo: consume_key ignores an extra Shift
let redo = i.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Z, pressed: true, modifiers: m, .. } if m.shift));
let undo = if redo { 0 } else { i.count_and_consume_key(egui::Modifiers::COMMAND, egui::Key::Z) };
let clear = if text_focused {
0
} else {
i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::Backspace) + i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::Delete)
};
undo + clear
});
if n == 0 {
return;
}
let left = d.points.len().saturating_sub(n);
d.points.truncate(left);
if left == 0 {
app.ui.mask_pen = None;
}
}
fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32, u32)) {
let Some(draft) = app.ui.mask_pen.clone() else { return };
let ph = app.session.playhead();

View File

@ -258,6 +258,76 @@ fn shift_constrains_pen_mask_segments_to_45_degrees() {
assert!(ox > 10.0 && oy.abs() < 1e-6, "handle constrained horizontal: {pen}");
}
/// Delete / Backspace and Cmd+Z take back the last pen point while a mask is drawn: the draft is
/// outside the undo history, so the keys must not reach Clear (on the clip being masked) or Undo
/// (of the previous project edit). The last point taken back closes the draft (#1345).
#[test]
fn delete_and_undo_take_back_the_last_pen_point() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace": "Effects"}));
let seq = d.exec("sequence.inspect", json!({}));
let clip = &seq["video"][0]["items"][0];
let (id, start, dur) = (clip["clip"].as_u64().unwrap(), clip["start"].as_i64().unwrap(), clip["duration"].as_i64().unwrap());
d.exec("playhead.set", json!({"time": start + dur / 2}));
d.exec("timeline.select", json!({"clips": [id]}));
// a project edit for a stray Undo to revert
d.exec("effects.apply", json!({"effect": "gaussian_blur"}));
d.frames(3);
let undo_len = |d: &mut Driver| d.exec("history.list", json!({}))["undo"].as_array().unwrap().len();
let clip_there = |d: &mut Driver| {
let seq = d.exec("sequence.inspect", json!({}));
seq["video"][0]["items"].as_array().unwrap().iter().any(|c| c["clip"].as_u64() == Some(id))
};
let undo0 = undo_len(&mut d);
d.ok("ui.click", json!({"id": "effectControls.opacity.mask.pen"}));
d.frames(2);
let pic = d.rect("program.picture");
let at = |fx: f64, fy: f64| json!({"x": pic[0] + pic[2] * fx, "y": pic[1] + pic[3] * fy});
for (fx, fy) in [(0.3, 0.3), (0.6, 0.3), (0.6, 0.6)] {
d.ok("ui.click", at(fx, fy));
d.frames(2);
}
// with the Timeline focused, Delete and Backspace are Clear on the selected clip
d.ok("ui.set", json!({"focused": "Timeline"}));
// Redo is not a take-back
d.ok("ui.key", json!({"key": "Shift+Cmd+Z"}));
d.frames(2);
d.ok("ui.key", json!({"key": "Delete"}));
d.frames(2);
assert!(clip_there(&mut d), "Delete cleared the clip being masked");
d.ok("ui.key", json!({"key": "Cmd+Z"}));
d.frames(2);
assert_eq!(undo_len(&mut d), undo0, "Cmd+Z undid a project edit while drawing");
// one point left: two more, then a click on the first closes the mask with three vertices
for (fx, fy) in [(0.35, 0.65), (0.25, 0.5)] {
d.ok("ui.click", at(fx, fy));
d.frames(2);
}
d.ok("ui.click", at(0.3, 0.3));
d.frames(3);
let m = d.masks();
let pen = m.iter().find(|x| x["effectId"] == "opacity").unwrap_or_else(|| panic!("opacity mask: {m:?}"));
assert_eq!(pen["path"]["vertices"].as_array().unwrap().len(), 3, "{pen}");
let (p0, p1) = (v0(pen, 0), v0(pen, 1));
assert!(p1.1 > p0.1 + 50.0, "second vertex is the one placed after the take-backs: {p0:?} {p1:?}");
// Backspace on the only point closes the draft; Cmd+Z then undoes as usual
d.ok("ui.click", json!({"id": "effectControls.opacity.mask.pen"}));
d.frames(2);
d.ok("ui.click", at(0.5, 0.5));
d.frames(2);
d.ok("ui.set", json!({"focused": "Timeline"}));
d.ok("ui.key", json!({"key": "Backspace"}));
d.frames(2);
assert!(clip_there(&mut d), "Backspace cleared the clip being masked");
assert!(d.ok("ui.elements", json!({"prefix": "program.maskPen"})).as_array().unwrap().is_empty(), "draft still open");
let undo1 = undo_len(&mut d);
d.ok("ui.key", json!({"key": "Cmd+Z"}));
d.frames(2);
assert_eq!(undo_len(&mut d), undo1 - 1);
assert!(d.masks().iter().all(|x| x["effectId"] != "opacity"), "Undo removed the pen mask");
}
#[test]
fn presets_bin_drag_and_save_dialog() {
let mut d = Driver::demo();