Merge pull request #1052 from Auipp/fix/timeline-escape-drag

timeline: cancel drag and restore playhead on Escape (#580)
This commit is contained in:
Brandon Thomas 2026-10-11 22:53:29 -04:00
commit ca36cf9a6a
5 changed files with 338 additions and 62 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.
@ -89,7 +91,9 @@ pub struct Pressed {
#[derive(Clone, Debug)]
pub enum Drag {
Scrub,
Scrub {
initial: Tick,
},
Move {
clips: Vec<ClipId>,
grab_tick: Tick,
@ -155,6 +159,8 @@ pub enum Drag {
},
Pan {
last: Pos2,
start_scroll: f64,
start_v: f32,
},
/// Box selection, shown live: `before` is the selection when the drag began, which Shift or
/// Ctrl (`add`) keep; the release commits one `timeline.select`.
@ -168,6 +174,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,
},
}
@ -1599,9 +1606,19 @@ 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))
&& 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;
@ -2091,11 +2108,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;
let grab = grab_at(seq, layout, p, tool, mods, roll_ripple);
// the playhead jumps to a press on the ruler at once, not only once the pointer has moved (#1090)
if matches!(grab, Grab::Other(Hit::Ruler)) {
@ -2179,7 +2198,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
@ -2196,8 +2215,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 };
@ -2341,7 +2360,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 { .. }) {
scrub_playhead(app, seq, layout, p, mods.shift);
}
if resp.drag_started() {
@ -2350,37 +2369,59 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
}
}
// ---- Escape abandons the drag: the release commits nothing and the clips stay put (#580)
if app.tl.drag.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
// the live box (#1028) already changed the selection: put back the one the drag began with
if let Some(Drag::Marquee { before, .. }) = app.tl.drag.take() {
app.session.state.selection = before;
// Escape cancels an ongoing drag or press (#580): the release commits nothing
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.changed() {
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;
}
// the live box (#1028) already changed the selection: put back the one the drag began with
Drag::Marquee { before, .. } => {
app.session.state.selection = before;
}
_ => {}
}
}
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);
@ -2478,51 +2519,55 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
}
// ---- release: commit exactly one command
if resp.drag_stopped()
&& let Some(d) = app.tl.drag.take()
{
let r = match d {
Drag::Move { clips, offset, track_delta, new_track, copy, .. } if offset != Tick::ZERO || track_delta != 0 || new_track.is_some() => {
// #1104: dropped past the last track, the clips go onto a new one
let to = |tid| new_track.map_or_else(|| json!(shift_track(seq, tid, track_delta).unwrap_or(tid).0), |_| json!("new"));
let moves: Vec<Value> = clips
.iter()
.filter_map(|c| seq.find_item(*c).map(|(tid, it)| json!({"clip": c.0, "track": to(tid), "time": (it.start + offset).max(Tick::ZERO).0})))
.collect();
// Option held while dropping copies (Premiere: Option-drag duplicates)
Some(app.session.execute("timeline.move", json!({"moves": moves, "insert": mods.command, "linked": false, "copy": copy || mods.alt})))
}
Drag::Trim { clip, edge, mode, delta, .. } if delta != Tick::ZERO => Some(app.session.execute(
"timeline.trim",
json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "mode": if mode == filmcraft_edit::TrimMode::Ripple {"ripple"} else {"regular"}, "delta": delta.0}),
)),
Drag::Stretch { clip, edge, delta, .. } if delta != Tick::ZERO => Some(app.session.execute("timeline.rateStretch", json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "delta": delta.0}))),
Drag::Remix { clip, delta, .. } if delta != Tick::ZERO => {
let d = seq.find_item(clip).map(|(_, it)| it.duration + delta).unwrap_or(Tick::ZERO);
Some(app.session.execute("clip.remix", json!({"clip": clip.0, "duration": d.0})))
}
Drag::Roll { left, right, delta, .. } if delta != Tick::ZERO => Some(app.session.execute("timeline.roll", json!({"left": left.0, "right": right.0, "delta": delta.0}))),
Drag::Slip { clip, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.slip", json!({"clip": clip.0, "delta": delta.0}))),
Drag::Slide { clip, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.slide", json!({"clip": clip.0, "delta": delta.0}))),
Drag::Transition { id, start, duration, .. } => {
let moved = seq.all_tracks().find_map(|tr| tr.transitions.iter().find(|x| x.id == id)).is_some_and(|x| (x.start, x.duration) != (start, duration));
moved.then(|| app.session.execute("sequence.setTransition", json!({"transition": id.0, "start": start.0, "duration": duration.0})))
}
Drag::Marquee { start, before, add } => {
app.session.state.selection = before;
// egui ends a drag on Escape: that cancels the box, as in the Project panel
match resp.interact_pointer_pos() {
Some(end) if !ctx.input(|i| i.key_pressed(egui::Key::Escape)) => {
let ids: Vec<u64> = marquee_clips(seq, layout, Rect::from_two_pos(start, end)).iter().map(|c| c.0).collect();
Some(app.session.execute("timeline.select", json!({"clips": ids, "add": add})))
}
_ => None,
if resp.drag_stopped() {
// a drag Escape cancelled (#580) commits nothing
let cancelled = std::mem::take(&mut app.tl.cancelled);
if !cancelled && let Some(d) = app.tl.drag.take() {
let r = match d {
Drag::Move { clips, offset, track_delta, new_track, copy, .. } if offset != Tick::ZERO || track_delta != 0 || new_track.is_some() => {
// #1104: dropped past the last track, the clips go onto a new one
let to = |tid| new_track.map_or_else(|| json!(shift_track(seq, tid, track_delta).unwrap_or(tid).0), |_| json!("new"));
let moves: Vec<Value> = clips
.iter()
.filter_map(|c| seq.find_item(*c).map(|(tid, it)| json!({"clip": c.0, "track": to(tid), "time": (it.start + offset).max(Tick::ZERO).0})))
.collect();
// Option held while dropping copies (Premiere: Option-drag duplicates)
Some(app.session.execute("timeline.move", json!({"moves": moves, "insert": mods.command, "linked": false, "copy": copy || mods.alt})))
}
Drag::Trim { clip, edge, mode, delta, .. } if delta != Tick::ZERO => Some(app.session.execute(
"timeline.trim",
json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "mode": if mode == filmcraft_edit::TrimMode::Ripple {"ripple"} else {"regular"}, "delta": delta.0}),
)),
Drag::Stretch { clip, edge, delta, .. } if delta != Tick::ZERO => Some(app.session.execute("timeline.rateStretch", json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "delta": delta.0}))),
Drag::Remix { clip, delta, .. } if delta != Tick::ZERO => {
let d = seq.find_item(clip).map(|(_, it)| it.duration + delta).unwrap_or(Tick::ZERO);
Some(app.session.execute("clip.remix", json!({"clip": clip.0, "duration": d.0})))
}
Drag::Roll { left, right, delta, .. } if delta != Tick::ZERO => Some(app.session.execute("timeline.roll", json!({"left": left.0, "right": right.0, "delta": delta.0}))),
Drag::Slip { clip, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.slip", json!({"clip": clip.0, "delta": delta.0}))),
Drag::Slide { clip, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.slide", json!({"clip": clip.0, "delta": delta.0}))),
Drag::Transition { id, start, duration, .. } => {
let moved = seq.all_tracks().find_map(|tr| tr.transitions.iter().find(|x| x.id == id)).is_some_and(|x| (x.start, x.duration) != (start, duration));
moved.then(|| app.session.execute("sequence.setTransition", json!({"transition": id.0, "start": start.0, "duration": duration.0})))
}
Drag::Marquee { start, before, add } => {
app.session.state.selection = before;
// egui ends a drag on Escape: that cancels the box, as in the Project panel
match resp.interact_pointer_pos() {
Some(end) if !ctx.input(|i| i.key_pressed(egui::Key::Escape)) => {
let ids: Vec<u64> = marquee_clips(seq, layout, Rect::from_two_pos(start, end)).iter().map(|c| c.0).collect();
Some(app.session.execute("timeline.select", json!({"clips": ids, "add": add})))
}
_ => None,
}
}
_ => None,
};
if let Some(Err(e)) = r {
app.ui.status = e.to_string();
}
_ => None,
};
if let Some(Err(e)) = r {
app.ui.status = e.to_string();
} else {
app.tl.drag = None;
}
}

View File

@ -111,6 +111,13 @@ pub struct LineDrag {
last: Option<f64>,
}
impl LineDrag {
/// Whether the drag has changed the volume yet (its first change began an undo step).
pub fn changed(&self) -> bool {
self.last.is_some()
}
}
fn find<'a>(seq: &'a Sequence, layout: &'a Layout, clip: ClipId) -> Option<(&'a Row, &'a TrackItem)> {
let (tid, it) = seq.find_item(clip)?;
Some((layout.rows.iter().find(|r| r.track == tid)?, it))
@ -206,6 +213,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
&& 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,215 @@
//! Headless UI tests for Escape key drag cancellation in the Timeline (#580).
use std::sync::mpsc::{Sender, channel};
use egui::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

@ -107,7 +107,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 |