Merge pull request #173 from carlitosteso3/fix/shortcut-modifiers-164

Shortcut recorder accepts modifier chords; Add Edit cuts only selected clips (#164, items 1 and 5)
This commit is contained in:
Brandon Thomas
2026-10-07 17:46:20 -04:00
4 changed files with 75 additions and 4 deletions
+24
View File
@@ -851,3 +851,27 @@ fn a_cyclic_subclip_chain_has_no_media_duration() {
*parent = sub;
assert_eq!(crate::media_duration(&s.project, &s.media, sub), None);
}
/// #164: Add Edit with a clip selected cut every targeted track. Like Premiere it now cuts only
/// the selected clips under the playhead; with nothing selected there, the targeted tracks.
#[test]
fn add_edit_cuts_only_the_selected_clips() {
let mut s = demo();
let count = |s: &Session| {
let q = s.active_sequence().unwrap();
(q.video_tracks.iter().map(|t| t.items.len()).sum::<usize>(), q.audio_tracks.iter().map(|t| t.items.len()).sum::<usize>())
};
// a V1 clip and a time inside it
let first = v1(&s)[1].clone();
let t = first.start + filmcraft_time::Tick(first.duration.0 / 2);
let (v0, a0) = count(&s);
s.state.selection = vec![first.id];
s.execute("sequence.addEdit", json!({"time": t.0})).unwrap();
let (v1n, a1n) = count(&s);
assert_eq!((v1n, a1n), (v0 + 1, a0), "only the selected V1 clip is cut");
// nothing selected under the playhead: every targeted track is cut
s.execute("edit.undo", json!({})).unwrap();
s.state.selection.clear();
let r = s.execute("sequence.addEdit", json!({"time": t.0})).unwrap();
assert!(r["cuts"].as_u64().unwrap() > 1, "{r}");
}
+11 -2
View File
@@ -1333,8 +1333,17 @@ fn build() -> Vec<CommandSpec> {
}),
cmd!("sequence.addEdit", "Add Edit", ["Sequence"], Some("Cmd+K"), r#"{"time":ticks?}"#, has_seq, |s, p| {
let t = time_p(s, p, "").unwrap_or(s.playhead());
let tg = s.targeting().targeted;
let n = s.edit_sequence("Add Edit", |q, ctx, _| Ok(edit::razor(q, &tg, t, ctx)))?;
// Like Premiere: selected clips under the playhead are cut, and only they (#164);
// with none, the targeted tracks are.
let sel = s.state.selection.clone();
let selected_here =
s.active_sequence().is_some_and(|q| sel.iter().any(|c| q.find_item(*c).is_some_and(|(_, it)| it.start < t && t < it.start + it.duration)));
let n = if selected_here {
s.edit_sequence("Add Edit", |q, ctx, _| Ok(edit::razor_items(q, &sel, t, ctx)))?
} else {
let tg = s.targeting().targeted;
s.edit_sequence("Add Edit", |q, ctx, _| Ok(edit::razor(q, &tg, t, ctx)))?
};
Ok(json!({"cuts": n.len()}))
}),
cmd!("sequence.addEditAllTracks", "Add Edit to All Tracks", ["Sequence"], Some("Cmd+Shift+K"), r#"{"time":ticks?}"#, has_seq, |s, p| {
+10 -2
View File
@@ -105,13 +105,21 @@ fn assign(app: &mut FilmcraftApp, command: &str, keys: &str, add: bool) {
}
}
/// Take the next key press while recording (Esc cancels).
/// A modifier key on its own. egui reports pressing Shift, Ctrl, Alt or the Windows / Cmd key as a
/// key event of its own, before the key it modifies arrives (#164).
fn is_modifier_key(key: egui::Key) -> bool {
use egui::Key::*;
matches!(key, ShiftLeft | ShiftRight | ControlLeft | ControlRight | AltLeft | AltRight | SuperLeft | SuperRight)
}
/// Take the next key press while recording (Esc cancels). Modifier keys on their own are skipped:
/// the chord is recorded when the key they modify is pressed, with them as its modifiers.
fn record(app: &mut FilmcraftApp, ctx: &egui::Context) {
let Some((cmd, add)) = app.shortcut_editor.recording.clone() else { return };
let got = ctx.input_mut(|i| {
let mut got = None;
i.events.retain(|e| match e {
egui::Event::Key { key, pressed: true, modifiers, .. } if got.is_none() => {
egui::Event::Key { key, pressed: true, modifiers, .. } if got.is_none() && !is_modifier_key(*key) => {
got = Some((*key, *modifiers));
false
}
+30
View File
@@ -512,3 +512,33 @@ fn waveform_peaks_do_not_survive_opening_another_project() {
let now = d.harness.state().tl.peaks.lock().unwrap().get(&item).cloned();
assert!(now.is_none_or(|p| !std::sync::Arc::ptr_eq(&p, &old)), "peaks from the previous project were reused");
}
/// #164: on a real keyboard, pressing Alt+Shift+J sends each modifier key on its own first (egui
/// reports modifier keys as key events). The shortcut recorder took the Alt press and refused it
/// ("AltLeft can't be used as a shortcut"), so no chord with Shift, Ctrl or Alt could be recorded.
#[test]
fn recording_a_shortcut_skips_the_modifier_key_presses() {
let mut d = Driver::demo();
d.ok("ui.menu.invoke", json!({"id": "app.keyboardShortcuts"}));
d.frames(2);
d.ok("ui.click", json!({"id": "shortcuts.search"}));
d.ok("ui.type", json!({"text": "add edit"}));
d.frames(2);
d.ok("ui.click", json!({"id": "shortcuts.add.sequence.addEdit"}));
d.frames(1);
// what a real keyboard sends for Alt+Shift+J: Alt, then Shift, then J
let alt = egui::Modifiers { alt: true, ..Default::default() };
let alt_shift = egui::Modifiers { alt: true, shift: true, ..Default::default() };
let key = |key, modifiers| egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers };
let events = &mut d.harness.input_mut().events;
events.push(key(egui::Key::AltLeft, alt));
events.push(key(egui::Key::ShiftLeft, alt_shift));
events.push(key(egui::Key::J, alt_shift));
d.frames(2);
d.ok("ui.click", json!({"id": "shortcuts.ok"}));
d.frames(2);
// added next to Cmd+K (`shortcuts.get` lists every chord of the command)
let got = d.ok("engine.execute", json!({"command": "shortcuts.get", "params": {"command": "sequence.addEdit"}})).to_string();
assert!(got.contains("Alt+Shift+J"), "{got}");
assert!(got.contains("Cmd+K"), "{got}");
}