timeline: cancel drag and restore playhead on Escape (#580)

Pressing Escape during an active drag in the Timeline cancels the operation without committing changes on mouse release. For ruler scrub, restores the playhead to its starting time.
This commit is contained in:
Auipp 2026-10-11 15:02:09 +03:00
parent 4e2d0bc2d3
commit f315c46457
5 changed files with 341 additions and 58 deletions

View File

@ -43,6 +43,8 @@ pub struct TlState {
pub drag: Option<Drag>,
/// What the primary button went down on in the Timeline (#259); see [`Pressed`].
pub pressed: Option<Pressed>,
/// A drag or press aborted with Escape; ignores release until the button comes up (#580).
pub cancelled: bool,
/// Last layout (for hit-testing from the control channel).
pub layout: Option<Layout>,
/// Snap indicator x (screen) this frame.
@ -87,7 +89,9 @@ pub struct Pressed {
#[derive(Clone, Debug)]
pub enum Drag {
Scrub,
Scrub {
initial: Tick,
},
Move {
clips: Vec<ClipId>,
grab_tick: Tick,
@ -150,6 +154,8 @@ pub enum Drag {
},
Pan {
last: Pos2,
start_scroll: f64,
start_v: f32,
},
Marquee {
start: Pos2,
@ -159,6 +165,7 @@ pub enum Drag {
/// Where the pointer was pressed, and the view's left edge (seconds) at that moment.
grab: f32,
start: f64,
start_pps: f64,
mode: u8,
},
}
@ -1498,9 +1505,18 @@ fn zoom_scrollbar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, seq_seco
} else {
0
};
app.tl.drag = Some(Drag::ZoomBar { grab: pos.x, start: app.ui.timeline.target_scroll, mode });
app.tl.drag = Some(Drag::ZoomBar { grab: pos.x, start: app.ui.timeline.target_scroll, start_pps: app.ui.timeline.target_pps, mode });
}
if let Some(Drag::ZoomBar { grab, start, mode }) = app.tl.drag.clone()
if matches!(app.tl.drag, Some(Drag::ZoomBar { .. })) && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
if let Some(Drag::ZoomBar { start, start_pps, .. }) = app.tl.drag.take() {
let v = &mut app.ui.timeline;
v.target_scroll = start;
v.scroll = start;
v.target_pps = start_pps;
v.pps = start_pps;
}
}
if let Some(Drag::ZoomBar { grab, start, mode, .. }) = app.tl.drag.clone()
&& resp.dragged()
{
let v = &mut app.ui.timeline;
@ -1955,11 +1971,13 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
// ---- what the button went down on (#259): kept from the press frame until it comes up
if ctx.input(|i| !i.pointer.any_down() && !i.pointer.any_released()) {
app.tl.pressed = None;
app.tl.cancelled = false;
}
if resp.is_pointer_button_down_on()
&& ctx.input(|i| i.pointer.primary_pressed())
&& let Some(p) = ctx.input(|i| i.pointer.press_origin())
{
app.tl.cancelled = false;
app.tl.pressed = Some(Pressed { pos: p, tick: layout.tick_at(p.x), grab: grab_at(seq, layout, p, tool, mods, roll_ripple) });
}
@ -2038,7 +2056,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
}
// ---- press: act on what was under the pointer when the button went down (#259)
if resp.drag_started() || (resp.clicked() && app.tl.drag.is_none()) {
if !app.tl.cancelled && (resp.drag_started() || (resp.clicked() && app.tl.drag.is_none())) {
let pressed = match app.tl.pressed.take() {
Some(pr) => pr,
// a press and release in the same frame is never stored: it happened where egui says
@ -2055,8 +2073,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
g => g,
};
let started = match (tool, grab) {
(_, Grab::Other(Hit::Ruler)) => Some(Drag::Scrub),
(Tool::Hand, _) => Some(Drag::Pan { last: p }),
(_, Grab::Other(Hit::Ruler)) => Some(Drag::Scrub { initial: app.session.playhead() }),
(Tool::Hand, _) => Some(Drag::Pan { last: p, start_scroll: app.ui.timeline.target_scroll, start_v: app.ui.timeline.v_scroll }),
(Tool::Zoom, _) => {
if resp.clicked() {
let f = if mods.alt { 1.0 / 2.0 } else { 2.0 };
@ -2182,7 +2200,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
(_, Grab::Other(Hit::Empty { .. } | Hit::None)) => None,
};
if let Some(d) = started {
if matches!(d, Drag::Scrub) {
if matches!(d, Drag::Scrub { .. }) {
app.stop();
let mut tt = rate.snap_nearest(layout.tick_at(p.x).max(Tick::ZERO));
if mods.shift || app.session.prefs.timeline.snap_playhead {
@ -2200,29 +2218,55 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
}
}
// Escape cancels an ongoing drag or press (#580)
if (app.tl.drag.is_some() || app.tl.pressed.is_some()) && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
if let Some(d) = app.tl.drag.take() {
match d {
Drag::Scrub { initial } => {
app.session.set_playhead(initial);
}
Drag::Volume(v) => {
if v.last.is_some() {
let _ = app.session.execute("edit.undo", json!({}));
}
}
Drag::Pan { start_scroll, start_v, .. } => {
let v = &mut app.ui.timeline;
v.target_scroll = start_scroll;
v.scroll = start_scroll;
v.v_scroll = start_v;
}
_ => {}
}
}
app.tl.pressed = None;
app.tl.cancelled = true;
}
// ---- drag
if resp.dragged()
if !app.tl.cancelled
&& resp.dragged()
&& let Some(p) = resp.interact_pointer_pos()
&& let Some(d) = app.tl.drag.clone()
{
let t_here = layout.tick_at(p.x);
let new = match d {
Drag::Scrub => {
Drag::Scrub { initial } => {
let mut tt = rate.snap_nearest(t_here.max(Tick::ZERO));
// Shift snaps; Settings ▸ Timeline ▸ "Snap playhead in Timeline when Snap is enabled"
if mods.shift || app.session.prefs.timeline.snap_playhead {
tt = snap_playhead(app, seq, layout, tt);
}
app.session.set_playhead(tt);
Some(Drag::Scrub)
Some(Drag::Scrub { initial })
}
Drag::Pan { last } => {
Drag::Pan { last, start_scroll, start_v } => {
let dx = p.x - last.x;
let v = &mut app.ui.timeline;
v.target_scroll = (v.target_scroll - dx as f64 / v.pps).max(0.0);
v.scroll = v.target_scroll;
app.ui.timeline.v_scroll += p.y - last.y;
Some(Drag::Pan { last: p })
Some(Drag::Pan { last: p, start_scroll, start_v })
}
Drag::Move { clips, grab_tick, start_track, .. } => {
let raw = rate.snap_nearest(t_here - grab_tick);
@ -2301,7 +2345,9 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
}
// ---- release: commit exactly one command
if resp.drag_stopped()
if resp.drag_stopped() {
let cancelled = std::mem::take(&mut app.tl.cancelled);
if !cancelled
&& let Some(d) = app.tl.drag.take()
{
let r = match d {
@ -2350,6 +2396,9 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
if let Some(Err(e)) = r {
app.ui.status = e.to_string();
}
} else {
app.tl.drag = None;
}
}
// ---- double-click a transition: Set Transition Duration (Premiere)

View File

@ -206,6 +206,15 @@ fn key_drag_id() -> egui::Id {
/// it to delete it. Runs after the timeline's own interaction, so they are above the clip.
pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &Layout, aclip: Rect) {
let held: Option<KeyDrag> = ui.data(|d| d.get_temp(key_drag_id()));
if held.is_some() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
if let Some(h) = held {
if h.begun {
let _ = app.session.execute("edit.undo", json!({}));
}
}
ui.data_mut(|d| d.remove::<KeyDrag>(key_drag_id()));
return;
}
if held.is_none() && (app.tl.drag.is_some() || !matches!(app.ui.tool, Tool::Selection | Tool::Pen)) {
return;
}

View File

@ -0,0 +1,225 @@
//! Headless UI tests for Escape key drag cancellation in the Timeline (#580).
use std::sync::mpsc::{Sender, channel};
use egui::{Pos2, pos2};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_project::ClipId;
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use filmcraft_ui_egui::panels::timeline::Drag;
use serde_json::{Value, json};
const DUNES: &str = "Desert_Dunes.mp4";
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
}
impl Driver {
fn demo() -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
session.execute("sequence.snap", json!({"on": false})).expect("snapping off");
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let harness = Harness::builder()
.with_size(egui::vec2(1600.0, 980.0))
.with_step_dt(1.0 / 60.0)
.with_max_steps(10_000)
.build_eframe(move |_cc| app);
let mut d = Driver { harness, tx };
d.frames(4);
d.ok("ui.elements", json!({"prefix": "timeline.clip."}));
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn call(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(v) = reply.try_recv() {
return v;
}
}
panic!("no reply to {method} {params}");
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let v = self.call(method, params.clone());
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
v["result"].clone()
}
fn exec(&mut self, command: &str, params: Value) -> Value {
self.ok("engine.execute", json!({"command": command, "params": params}))
}
fn app(&self) -> &FilmcraftApp {
self.harness.state()
}
fn send(&mut self, e: egui::Event) {
self.harness.input_mut().events.push(e);
self.frames(1);
}
fn escape(&mut self) {
self.send(egui::Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: Default::default(),
});
}
fn rect(&mut self, id: &str) -> [f64; 4] {
let v = self.ok("ui.elements", json!({"prefix": id}));
let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}")).clone();
let r: Vec<f64> = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
[r[0], r[1], r[2], r[3]]
}
fn v1_clip(&self, name: &str) -> u64 {
let seq = self.app().session.active_sequence().expect("sequence");
seq.video_tracks[0].items.iter().find(|i| i.name == name).unwrap_or_else(|| panic!("no {name} on V1")).id.0
}
fn span(&self, clip: u64) -> (i64, i64) {
let seq = self.app().session.active_sequence().expect("sequence");
let (_, it) = seq.find_item(ClipId(clip)).expect("clip");
(it.start.0, it.end().0)
}
fn edges(&mut self, clip: u64) -> (f32, f32, f32) {
let i = self.ok("ui.timeline.locate", json!({"clip": clip, "edge": "in"}));
let o = self.ok("ui.timeline.locate", json!({"clip": clip, "edge": "out"}));
let f = |v: &Value, k: &str| v[k].as_f64().expect("number") as f32;
let id = format!("timeline.clip.{clip}");
let els = self.ok("ui.elements", json!({"prefix": id}));
let el = els.as_array().expect("elements").iter().find(|e| e["id"] == id.as_str()).unwrap_or_else(|| panic!("no {id}: {els}")).clone();
let top = el["rect"][1].as_f64().expect("rect") as f32;
(f(&i, "x") - 2.0, f(&o, "x") + 2.0, top + 8.0)
}
}
#[test]
fn escape_cancels_clip_move_drag() {
let mut d = Driver::demo();
let q = d.app().session.active_sequence().unwrap().clone();
let v2 = q.video_tracks[1].items[0].clone();
let r = d.rect(&format!("timeline.clip.{}", v2.id.0));
let from = pos2(r[0] as f32 + 10.0, (r[1] + r[3] / 2.0) as f32);
let to = pos2(from.x + 120.0, from.y);
d.send(egui::Event::PointerMoved(from));
d.send(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
for i in 1..=10 {
d.send(egui::Event::PointerMoved(from.lerp(to, i as f32 / 10.0)));
}
assert!(matches!(d.app().tl.drag, Some(Drag::Move { .. })), "drag is active");
d.escape();
assert!(d.app().tl.drag.is_none(), "Escape cleared the drag state");
d.send(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
d.frames(2);
let q_after = d.app().session.active_sequence().unwrap().clone();
let item_after = q_after.find_item(v2.id).unwrap().1;
assert_eq!(item_after.start, v2.start, "clip position is unchanged");
assert_eq!(item_after.duration, v2.duration, "clip duration is unchanged");
}
#[test]
fn escape_cancels_clip_trim_drag() {
let mut d = Driver::demo();
let clip = d.v1_clip(DUNES);
let (s0, e0) = d.span(clip);
let (_, x_out, y) = d.edges(clip);
let from = pos2(x_out, y);
let to = pos2(from.x - 40.0, from.y);
d.send(egui::Event::PointerMoved(from));
d.send(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
for i in 1..=10 {
d.send(egui::Event::PointerMoved(from.lerp(to, i as f32 / 10.0)));
}
assert!(matches!(d.app().tl.drag, Some(Drag::Trim { .. })), "trim drag is active");
d.escape();
assert!(d.app().tl.drag.is_none(), "Escape cleared the trim drag");
d.send(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
d.frames(2);
assert_eq!(d.span(clip), (s0, e0), "clip bounds are unchanged after cancel");
}
#[test]
fn escape_cancels_ruler_scrub_and_restores_playhead() {
let mut d = Driver::demo();
let initial_playhead = d.app().session.playhead();
let layout = d.app().tl.layout.as_ref().expect("layout").clone();
let from = pos2(layout.x_of(initial_playhead), layout.ruler.center().y);
let to = pos2(from.x + 100.0, from.y);
d.send(egui::Event::PointerMoved(from));
d.send(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
for i in 1..=10 {
d.send(egui::Event::PointerMoved(from.lerp(to, i as f32 / 10.0)));
}
assert!(matches!(d.app().tl.drag, Some(Drag::Scrub { .. })), "ruler scrub is active");
assert_ne!(d.app().session.playhead(), initial_playhead, "playhead scrubbed to a new position");
d.escape();
assert!(d.app().tl.drag.is_none(), "Escape cleared the scrub state");
assert_eq!(d.app().session.playhead(), initial_playhead, "Escape restored the playhead to initial time");
d.send(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
d.frames(2);
assert_eq!(d.app().session.playhead(), initial_playhead, "playhead remains at initial time after release");
}
#[test]
fn escape_cancels_marquee_selection() {
let mut d = Driver::demo();
let q = d.app().session.active_sequence().unwrap().clone();
let v2 = q.video_tracks[1].items[0].clone();
d.exec("timeline.select", json!({"clips": [v2.id.0]}));
assert_eq!(d.app().session.state.selection, vec![v2.id]);
let layout = d.app().tl.layout.as_ref().expect("layout").clone();
let from = pos2(layout.content.min.x + 50.0, layout.content.max.y - 10.0);
let to = pos2(from.x + 200.0, from.y - 50.0);
d.send(egui::Event::PointerMoved(from));
d.send(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
for i in 1..=10 {
d.send(egui::Event::PointerMoved(from.lerp(to, i as f32 / 10.0)));
}
assert!(matches!(d.app().tl.drag, Some(Drag::Marquee { .. })), "marquee is active");
d.escape();
assert!(d.app().tl.drag.is_none(), "Escape cleared the marquee");
d.send(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
d.frames(2);
assert_eq!(d.app().session.state.selection, vec![v2.id], "original selection is preserved");
}

View File

@ -105,7 +105,7 @@ agent-hours to close the area to Premiere, sequential.
| Area | Weight | Breadth | Ready | Hours | Evidence | Detail |
|---|---|---|---|---|---|---|
| Editing, timeline, trimming, multicam | 20% | ~90% | ~70% | 80–140 | Edit algebra, trim modes, Trim Monitor, multicam with audio sync, Premiere default keyboard (143 commands), Source playback and range dragging (10-08). Open: ripple-delete of a gap by column (#668), Backspace on gaps (#648), clip stretching (#653), deselect on empty space (#683), new tracks by dragging media (#483), Escape cancelling drags (#580), time remapping (#510), effects on a whole track (#623) | [ui-parity.md](ui-parity.md) |
| Editing, timeline, trimming, multicam | 20% | ~90% | ~70% | 80–140 | Edit algebra, trim modes, Trim Monitor, multicam with audio sync, Premiere default keyboard (143 commands), Source playback and range dragging (10-08). Open: ripple-delete of a gap by column (#668), Backspace on gaps (#648), clip stretching (#653), deselect on empty space (#683), new tracks by dragging media (#483), Escape cancelling drags (#580 fixed), time remapping (#510), effects on a whole track (#623) | [ui-parity.md](ui-parity.md) |
| Effects and transitions | 12% | 100% (measured) | ~60% | 80–140 | Every effect exists; Warp Stabilizer is 2-D, Auto Reframe approximate, Morph Cut morphs with optical flow but without face tracking (#805); Optical Flow time interpolation is motion compensated on the CPU (#780). 34 effect ids run on the GPU against ~82 of Premiere's ~92 marked Accelerated. No Object Mask (26.0, AI). | [hardware-parity.md](hardware-parity.md) |
| Media import and codecs | 12% | ~75% | ~60% | 150–250 | Own bit-exact decoders for H.264, HEVC, VP9, AV1, ProRes, DNx, APV, MPEG-2, AAC, AC-3, Opus. But H.264 only 8-bit 4:2:0 and HEVC no 4:2:2 in software (most 10-bit cameras, #626); no camera RAW (R3D, ARRIRAW, BRAW, Sony RAW, Canon RAW, ProRes RAW, #345, #383); no E-AC-3 (#647), AVI (#598), WMV, DV, XAVC / P2 metadata; Sony start timecode (#460); WebM black (#432) | [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md) |
| Audio | 10% | ~88% | ~65% | 50–90 | Mixer graph, automation, all 53 effects, Essential Sound, 5.1, voice-over, Remix, multi-stream container audio (#361). Device and playback reports: crackle / cut-outs (#336, #410, #602, #605, #617); Enhance Speech is DSP, not a model; ALSA fallback fixed (#254) | |

View File

@ -18,7 +18,7 @@ open interaction reports (about 40 of the 188 open issues).
|---|---|---|---|---|---|
| Menus | 366 in-scope menu items | ~338 present (label diff, 92%) | 92% | spelling, Render and Replace, 5 workspaces | 10–20 h |
| Keyboard | Premiere default keyboard + editor | Premiere-compatible preset (143 keyboard-only commands; 39 cloud / absent-panel keys skipped with reasons), FilmCraft / FCP / Avid presets, editor | 90% | Ctrl chords (#256 fixed), modifier hints on Windows (#270 fixed) | 5–10 h |
| Timeline editing tools | Selection, Track Select, Ripple / Rolling / Rate Stretch, Razor, Slip / Slide, Pen, Hand, Zoom, Type | all present, trim modes, Trim Monitor, dynamic trimming | 80% | deselect in empty space (#683), gap delete (#648, #668), clip stretch (#653), new tracks by dropping (#483), Escape cancels drag (#580), delete in timeline (#680, #404) | 30–50 h |
| Timeline editing tools | Selection, Track Select, Ripple / Rolling / Rate Stretch, Razor, Slip / Slide, Pen, Hand, Zoom, Type | all present, trim modes, Trim Monitor, dynamic trimming | 80% | deselect in empty space (#683), gap delete (#648, #668), clip stretch (#653), new tracks by dropping (#483), Escape cancels drag (#580 fixed), delete in timeline (#680, #404) | 30–50 h |
| Program monitor on-screen controls | Motion / Transform handles, mask handles, safe margins, guides | guides, rulers, safe margins, snapping, mask drawing; **no transform handles** (#639); Button Editor (+) does nothing (#431) | 55% | #639, #431 | 15–25 h |
| Effect Controls | property rows, keyframe area with zoom, divider, graphs, eyedroppers | rows, keyframes, value / velocity graphs, eyedropper (#518); missing keyframe zoom (#641, #645), draggable divider (#643), row lines (#640), Position curves (#448) | 60% | #412, #640–#645, #448 | 15–25 h |
| Panels and docking | 17 workspaces, dock / undock / float, drop zones | 11 workspaces, docking; rearranging windows reported missing (#493); multi-monitor floating windows (#466) | 60% | #493, #466 | 20–35 h |