From 4eb34ae6c7c8a3274a1d7bd6040ffbac501858af Mon Sep 17 00:00:00 2001 From: Bloxy <93786729+ItzBloxy@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:27:48 +0200 Subject: [PATCH 1/4] #259: the edge you press is the edge you trim Pressing a clip edge and dragging inward selected and moved the clip: the press was hit-tested only once egui recognised a drag, 6 pt later, by when the pointer had left the 7 px edge zone. And at a cut the left clip's Out edge claimed both sides, so the right clip's In edge could not be grabbed. What a press grabs is now worked out on the frame the button goes down (timeline_hit::grab_at, shared by the cursor, the press and ui.timeline.hit) and acted on by both the click and the drag. Inside a clip beats outside it, so the side of a cut picks the clip; zero-width clips have no edges. Trims, rolls, rate stretches and remixes move by how far the pointer moved since the press, and a moved clip stays under the point it was grabbed by. ui.timeline.hit takes `modifiers` and reports `kind`, the trim a press would start. Unit tests for the zones and tools; headless UI tests that drag a pixel or a few per frame at 60 fps (before: the clip moved, the wrong clip was trimmed, a wobbly click selected the clip, a moved clip lagged). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/control.rs | 5 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/timeline.rs | 258 ++++++------- crates/ui-egui/src/panels/timeline_hit.rs | 434 ++++++++++++++++++++++ crates/ui-egui/tests/timeline_trim_ui.rs | 239 ++++++++++++ docs/control-protocol.md | 2 +- 6 files changed, 783 insertions(+), 156 deletions(-) create mode 100644 crates/ui-egui/src/panels/timeline_hit.rs create mode 100644 crates/ui-egui/tests/timeline_trim_ui.rs diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e491e36ae..98fa2455a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -13,7 +13,8 @@ //! - `ui.click {id | x,y, button?, count?, modifiers?}` / `ui.move {x,y}` / `ui.scroll {x,y,dx,dy}` //! - `ui.drag {from:{id|x,y}, to:{id|x,y}, steps?, modifiers?}`: synthetic press-move-release //! - `ui.key {key, command?, shift?, alt?, ctrl?}` / `ui.type {text}` -//! - `ui.timeline.hit {x, y}`: what the timeline shows at a point (track, clip, edge, time) +//! - `ui.timeline.hit {x, y, modifiers?}`: what the timeline shows at a point (track, clip, edge, +//! time) and `kind`, the trim a press of the current tool with `modifiers` starts there //! - `ui.timeline.locate {clip, edge?}`: screen point of a clip body/edge (for drags) //! - `ui.playback {action: play|stop|toggle, speed?}` //! - `ui.screenshot {path?, panel?}`: PNG of the window (or one panel) @@ -350,7 +351,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) Ok(p) => p, Err(e) => return err(e), }; - ok(crate::panels::timeline::hit_json(app, pos)) + ok(crate::panels::timeline::hit_json(app, pos, modifiers(p))) } "ui.timeline.locate" => { let Some(c) = p.get("clip").and_then(Value::as_u64) else { return err("need `clip`") }; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index f5510dd40..523210a85 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -43,6 +43,7 @@ pub mod timecode; pub mod timeline; pub mod timeline_automation; pub mod timeline_captions; +pub mod timeline_hit; pub mod tools; pub mod trim_monitor; pub mod voiceover; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 0172243c0..da8254666 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -22,6 +22,9 @@ use crate::icons::{self, Icon}; use crate::state::{TimelineView, Tool}; use crate::theme::Tokens; +use super::timeline_hit::{EdgeKind, Grab, edge_geometry, grab_at}; +pub use super::timeline_hit::{Hit, ROLL_PX, hit, selection_trim_kind}; + const TOP_H: f32 = 58.0; // timecode + toolbar (left) / ruler (right) const RULER_H: f32 = 44.0; const SCROLLBAR_H: f32 = 17.0; @@ -33,6 +36,8 @@ const SNAP_PX: f32 = 9.0; #[derive(Default)] pub struct TlState { pub drag: Option, + /// What the primary button went down on in the Timeline (#259); see [`Pressed`]. + pub pressed: Option, /// Last layout (for hit-testing from the control channel). pub layout: Option, /// Snap indicator x (screen) this frame. @@ -63,6 +68,18 @@ impl TlState { } } +/// What was under the pointer when the primary button went down in the Timeline, worked out on +/// that frame (#259). egui reports a click when the button comes up, by when it has forgotten the +/// press point, and a drag only once the pointer has moved past its 6 pt drag threshold, nearly +/// the 7 px edge zone. Both act on this instead, so a press acts on what it pressed. +#[derive(Clone, Copy, Debug)] +pub struct Pressed { + pub pos: Pos2, + /// The time under the pointer. + pub tick: Tick, + pub grab: Grab, +} + #[derive(Clone, Debug)] pub enum Drag { Scrub, @@ -78,11 +95,16 @@ pub enum Drag { edge: filmcraft_edit::Edge, mode: filmcraft_edit::TrimMode, delta: Tick, + /// The time under the pointer when the button went down: the edge moves by how far the + /// pointer has moved since (#259). + from: Tick, }, Roll { left: ClipId, right: ClipId, delta: Tick, + /// As in [`Drag::Trim`]. + from: Tick, }, Slip { clip: ClipId, @@ -96,11 +118,15 @@ pub enum Drag { clip: ClipId, edge: filmcraft_edit::Edge, delta: Tick, + /// As in [`Drag::Trim`]. + from: Tick, }, /// Remix tool: drag a music clip's Out edge; on release `clip.remix` re-plans it to the new duration. Remix { clip: ClipId, delta: Tick, + /// As in [`Drag::Trim`]. + from: Tick, }, Pan { last: Pos2, @@ -1371,37 +1397,6 @@ fn vertical_scrollbar(ui: &mut egui::Ui, bar: Rect, value: &mut f32, max: f32, i let _ = t; } -/// How close (px) to a cut the Selection tool rolls instead of rippling when Settings ▸ Trim ▸ -/// "Allow Selection tool to choose Roll and Ripple trims without modifier key" is on. -pub const ROLL_PX: f32 = 2.5; - -/// The trim the Selection tool starts at an edit point: Cmd+Shift = roll, Cmd = ripple. With the -/// Trim setting on (`no_modifier`), no modifier is needed: right on the cut (when a clip is on the -/// other side) rolls, elsewhere on the edge ripples. Otherwise a plain drag is a regular trim. -pub fn selection_trim_kind(no_modifier: bool, cmd: bool, shift: bool, dist_px: f32, has_neighbour: bool) -> &'static str { - if cmd && shift { - "roll" - } else if cmd { - "ripple" - } else if no_modifier { - if has_neighbour && dist_px <= ROLL_PX { "roll" } else { "ripple" } - } else { - "trim" - } -} - -/// Distance (px) from `x` to the cut at `clip`'s `edge`, and whether a clip touches that cut on -/// the other side. -fn edge_geometry(seq: &Sequence, layout: &Layout, track: filmcraft_project::TrackId, clip: ClipId, edge: filmcraft_edit::Edge, x: f32) -> (f32, bool) { - let Some(tr) = seq.track(track) else { return (f32::MAX, false) }; - let Some(it) = tr.item(clip) else { return (f32::MAX, false) }; - let (cut, neighbour) = match edge { - filmcraft_edit::Edge::Out => (it.end(), tr.items.iter().any(|x| x.start == it.end())), - filmcraft_edit::Edge::In => (it.start, tr.items.iter().any(|x| x.end() == it.start)), - }; - ((layout.x_of(cut) - x).abs(), neighbour) -} - /// Snap `t` to nearby candidates (edits, playhead, markers, in/out). Returns snapped tick. fn snap(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, t: Tick, exclude: &[ClipId]) -> Tick { snap_to(app, seq, layout, t, exclude, true) @@ -1450,59 +1445,21 @@ fn snap_to(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, t: Tick, exc } } -/// What is at a screen point. -#[derive(Clone, Debug)] -pub enum Hit { - Ruler, - Clip { track: TrackId, clip: ClipId, edge: Option }, - Transition { track: TrackId, id: filmcraft_project::TransitionId }, - Empty { track: TrackId }, - None, -} - -pub fn hit(seq: &Sequence, layout: &Layout, pos: Pos2) -> Hit { - if layout.ruler.contains(pos) { - return Hit::Ruler; - } - if !layout.content.contains(pos) { - return Hit::None; - } - let Some(row) = layout.row_at(pos.y) else { return Hit::None }; - let Some(tr) = seq.track(row.track) else { return Hit::None }; - for trn in &tr.transitions { - let x0 = layout.x_of(trn.start); - let x1 = layout.x_of(trn.end()); - if pos.x >= x0 && pos.x <= x1 && pos.y > row.rect.min.y + 17.0 { - return Hit::Transition { track: row.track, id: trn.id }; - } - } - let t = layout.tick_at(pos.x); - let edge_px = 7.0f32; - // prefer edges - for it in &tr.items { - let x0 = layout.x_of(it.start); - let x1 = layout.x_of(it.end()); - let w = x1 - x0; - let e = edge_px.min(w / 3.0); - if (pos.x - x0).abs() <= e && pos.x >= x0 - e { - return Hit::Clip { track: row.track, clip: it.id, edge: Some(filmcraft_edit::Edge::In) }; - } - if (pos.x - x1).abs() <= e && pos.x <= x1 + e { - return Hit::Clip { track: row.track, clip: it.id, edge: Some(filmcraft_edit::Edge::Out) }; - } - } - if let Some(it) = tr.item_at(t) { - return Hit::Clip { track: row.track, clip: it.id, edge: None }; - } - Hit::Empty { track: row.track } -} - -pub fn hit_json(app: &FilmcraftApp, pos: Pos2) -> Value { +/// What the Timeline shows at `pos` for the control channel's `ui.timeline.hit`, with `kind`: the +/// trim a press of the current tool with `mods` starts there (null when it would not trim). +pub fn hit_json(app: &FilmcraftApp, pos: Pos2, mods: egui::Modifiers) -> Value { let (Some(layout), Some(seq)) = (app.tl.layout.as_ref(), app.session.active_sequence()) else { return json!({"hit": "none"}) }; let t = layout.tick_at(pos.x); - match hit(seq, layout, pos) { + let grab = grab_at(seq, layout, pos, app.ui.tool, mods, app.session.prefs.trim.selection_tool_roll_ripple); + let kind = match grab { + Grab::Edge { kind, .. } => json!(kind.name()), + Grab::Other(_) => Value::Null, + }; + match grab.hit() { Hit::Ruler => json!({"hit": "ruler", "time": t.0}), - Hit::Clip { track, clip, edge } => json!({"hit": "clip", "track": track.0, "clip": clip.0, "edge": edge.map(|e| format!("{e:?}")), "time": t.0}), + Hit::Clip { track, clip, edge } => { + json!({"hit": "clip", "track": track.0, "clip": clip.0, "edge": edge.map(|e| format!("{e:?}")), "kind": kind, "time": t.0}) + } Hit::Transition { track, id } => json!({"hit": "transition", "track": track.0, "transition": id.0}), Hit::Empty { track } => json!({"hit": "empty", "track": track.0, "time": t.0}), Hit::None => json!({"hit": "none"}), @@ -1535,12 +1492,12 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut } } } - Drag::Remix { clip, delta } => { + Drag::Remix { clip, delta, .. } => { if let Some((_, it)) = seq.find_item(*clip) { out.insert(*clip, (it.start, it.duration + *delta, None)); } } - Drag::Trim { clip, edge, delta, .. } | Drag::Stretch { clip, edge, delta } => { + Drag::Trim { clip, edge, delta, .. } | Drag::Stretch { clip, edge, delta, .. } => { let ids = filmcraft_engine::commands::with_links(&app.session, &[*clip]); for c in ids { if let Some((_, it)) = seq.find_item(c) { @@ -1552,7 +1509,7 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut } } } - Drag::Roll { left, right, delta } => { + Drag::Roll { left, right, delta, .. } => { if let Some((_, l)) = seq.find_item(*left) { out.insert(*left, (l.start, l.duration + *delta, None)); } @@ -1712,6 +1669,18 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let mods = ctx.input(|i| i.modifiers); let tool = app.ui.tool; let rate = seq.settings.frame_rate; + let roll_ripple = app.session.prefs.trim.selection_tool_roll_ripple; + + // ---- 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; + } + 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.pressed = Some(Pressed { pos: p, tick: layout.tick_at(p.x), grab: grab_at(seq, layout, p, tool, mods, roll_ripple) }); + } // ---- wheel, as in Premiere Pro on macOS (checked in 26.5.2): // wheel the tracks under the pointer, up and down @@ -1753,18 +1722,16 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } - // ---- cursor feedback + // ---- cursor feedback: what a press here would grab (#259) if app.tl.drag.is_none() && let Some(p) = pos && area.contains(p) { - let h = hit(seq, layout, p); - let cur = match (tool, &h) { - (Tool::Selection, Hit::Clip { edge: Some(_), .. }) => CursorIcon::ResizeColumn, - (Tool::Ripple | Tool::Rolling | Tool::RateStretch, Hit::Clip { edge: Some(_), .. }) => CursorIcon::ResizeColumn, - (Tool::Remix, Hit::Clip { edge: Some(filmcraft_edit::Edge::Out), .. }) => CursorIcon::ResizeColumn, - (Tool::Razor, Hit::Clip { .. }) => CursorIcon::Crosshair, - (Tool::Slip | Tool::Slide, Hit::Clip { .. }) => CursorIcon::ResizeHorizontal, + let g = grab_at(seq, layout, p, tool, mods, roll_ripple); + let cur = match (tool, g) { + (_, Grab::Edge { .. }) => CursorIcon::ResizeColumn, + (Tool::Razor, Grab::Other(Hit::Clip { .. })) => CursorIcon::Crosshair, + (Tool::Slip | Tool::Slide, Grab::Other(Hit::Clip { .. })) => CursorIcon::ResizeHorizontal, (Tool::Hand, _) => CursorIcon::Grab, (Tool::Zoom, _) => CursorIcon::ZoomIn, _ => CursorIcon::Default, @@ -1772,7 +1739,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & ctx.set_cursor_icon(cur); // razor preview line if tool == Tool::Razor - && let Hit::Clip { track, .. } = h + && let Grab::Other(Hit::Clip { track, .. }) = g && let Some(row) = layout.rows.iter().find(|r| r.track == track) { let t = snap(app, seq, layout, rate.snap_nearest(layout.tick_at(p.x)), &[]); @@ -1781,13 +1748,19 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } - // ---- press + // ---- 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()) { - let Some(p) = resp.interact_pointer_pos() else { return }; - let h = hit(seq, layout, p); - let t = layout.tick_at(p.x); - let started = match (tool, h.clone()) { - (_, Hit::Ruler) => Some(Drag::Scrub), + 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 + None => { + let Some(p) = resp.interact_pointer_pos() else { return }; + Pressed { pos: p, tick: layout.tick_at(p.x), grab: grab_at(seq, layout, p, tool, mods, roll_ripple) } + } + }; + let (p, t) = (pressed.pos, pressed.tick); + let started = match (tool, pressed.grab) { + (_, Grab::Other(Hit::Ruler)) => Some(Drag::Scrub), (Tool::Hand, _) => Some(Drag::Pan { last: p }), (Tool::Zoom, _) => { if resp.clicked() { @@ -1797,7 +1770,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } None } - (Tool::Razor, Hit::Clip { clip, .. }) => { + (Tool::Razor, Grab::Other(Hit::Clip { clip, .. })) => { if resp.clicked() || resp.drag_started() { let tt = snap(app, seq, layout, rate.snap_nearest(t), &[]); let r = if mods.shift { @@ -1811,7 +1784,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } None } - (Tool::TrackSelectForward | Tool::TrackSelectBackward, Hit::Clip { track, .. } | Hit::Empty { track }) => { + (Tool::TrackSelectForward | Tool::TrackSelectBackward, Grab::Other(Hit::Clip { track, .. } | Hit::Empty { track })) => { let fwd = tool == Tool::TrackSelectForward; let ids: Vec = seq .all_tracks() @@ -1823,14 +1796,14 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let _ = app.session.execute("timeline.select", json!({"clips": ids})); None } - (Tool::Selection | Tool::Ripple | Tool::Rolling, Hit::Clip { clip, edge: Some(edge), track }) if resp.clicked() => { + (Tool::Selection | Tool::Ripple | Tool::Rolling, Grab::Edge { clip, edge, track, .. }) if resp.clicked() => { // click an edge = select it as an edit point (trim mode); Shift adds let kind = match tool { Tool::Rolling => "roll", Tool::Ripple => "ripple", _ => { let (dist, neighbour) = edge_geometry(seq, layout, track, clip, edge, p.x); - selection_trim_kind(app.session.prefs.trim.selection_tool_roll_ripple, mods.command, mods.shift, dist, neighbour) + selection_trim_kind(roll_ripple, mods.command, mods.shift, dist, neighbour) } }; let e = if edge == filmcraft_edit::Edge::In { "in" } else { "out" }; @@ -1840,36 +1813,16 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } None } - (Tool::Selection | Tool::Ripple | Tool::Rolling | Tool::RateStretch, Hit::Clip { clip, edge: Some(edge), track }) => { - let Some(tr) = seq.track(track) else { return }; - let sel_kind = if tool == Tool::Selection { - let (dist, neighbour) = edge_geometry(seq, layout, track, clip, edge, p.x); - selection_trim_kind(app.session.prefs.trim.selection_tool_roll_ripple, mods.command, mods.shift, dist, neighbour) - } else { - "" - }; - let mode = if tool == Tool::Ripple || sel_kind == "ripple" { filmcraft_edit::TrimMode::Ripple } else { filmcraft_edit::TrimMode::Regular }; - if tool == Tool::Rolling || sel_kind == "roll" { - // roll the cut between this and its neighbour - let Some(it) = tr.item(clip) else { return }; - let (l, r) = match edge { - filmcraft_edit::Edge::Out => (Some(clip), tr.items.iter().find(|x| x.start == it.end()).map(|x| x.id)), - filmcraft_edit::Edge::In => (tr.items.iter().find(|x| x.end() == it.start).map(|x| x.id), Some(clip)), - }; - match (l, r) { - (Some(left), Some(right)) => Some(Drag::Roll { left, right, delta: Tick::ZERO }), - _ => Some(Drag::Trim { clip, edge, mode, delta: Tick::ZERO }), - } - } else if tool == Tool::RateStretch { - Some(Drag::Stretch { clip, edge, delta: Tick::ZERO }) - } else { - Some(Drag::Trim { clip, edge, mode, delta: Tick::ZERO }) - } - } - (Tool::Remix, Hit::Clip { clip, edge: Some(filmcraft_edit::Edge::Out), .. }) => Some(Drag::Remix { clip, delta: Tick::ZERO }), - (Tool::Slip, Hit::Clip { clip, .. }) => Some(Drag::Slip { clip, delta: Tick::ZERO }), - (Tool::Slide, Hit::Clip { clip, .. }) => Some(Drag::Slide { clip, delta: Tick::ZERO }), - (_, Hit::Clip { clip, track, .. }) => { + (_, Grab::Edge { clip, edge, kind, .. }) => Some(match kind { + EdgeKind::Trim => Drag::Trim { clip, edge, mode: filmcraft_edit::TrimMode::Regular, delta: Tick::ZERO, from: t }, + EdgeKind::Ripple => Drag::Trim { clip, edge, mode: filmcraft_edit::TrimMode::Ripple, delta: Tick::ZERO, from: t }, + EdgeKind::Roll { left, right } => Drag::Roll { left, right, delta: Tick::ZERO, from: t }, + EdgeKind::Stretch => Drag::Stretch { clip, edge, delta: Tick::ZERO, from: t }, + EdgeKind::Remix => Drag::Remix { clip, delta: Tick::ZERO, from: t }, + }), + (Tool::Slip, Grab::Other(Hit::Clip { clip, .. })) => Some(Drag::Slip { clip, delta: Tick::ZERO }), + (Tool::Slide, Grab::Other(Hit::Clip { clip, .. })) => Some(Drag::Slide { clip, delta: Tick::ZERO }), + (_, Grab::Other(Hit::Clip { clip, track, .. })) => { // select (shift toggles; alt selects one side of a link) let sel = &app.session.state.selection; if mods.shift { @@ -1888,8 +1841,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & None } } - (_, Hit::Transition { .. }) => None, - (_, Hit::Empty { .. }) => { + (_, Grab::Other(Hit::Transition { .. })) => None, + (_, Grab::Other(Hit::Empty { .. })) => { if resp.drag_started() { Some(Drag::Marquee { start: p }) } else { @@ -1898,7 +1851,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & None } } - (_, Hit::None) => None, + (_, Grab::Other(Hit::None)) => None, }; if let Some(d) = started { if matches!(d, Drag::Scrub) { @@ -1955,24 +1908,23 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & }; Some(Drag::Move { clips, grab_tick, start_track, offset, track_delta }) } - Drag::Trim { clip, edge, mode, .. } => seq.find_item(clip).map(|(_, it)| { + Drag::Trim { clip, edge, mode, from, .. } => seq.find_item(clip).map(|(_, it)| { let base = if edge == filmcraft_edit::Edge::In { it.start } else { it.end() }; - let target = snap(app, seq, layout, rate.snap_nearest(t_here), &[clip]); - let delta = target - base; - Drag::Trim { clip, edge, mode, delta } + let target = snap(app, seq, layout, rate.snap_nearest(base + (t_here - from)), &[clip]); + Drag::Trim { clip, edge, mode, delta: target - base, from } }), - Drag::Remix { clip, .. } => seq.find_item(clip).map(|(_, it)| { - let target = rate.snap_nearest(t_here).max(it.start + rate.frame_duration()); - Drag::Remix { clip, delta: target - it.end() } + Drag::Remix { clip, from, .. } => seq.find_item(clip).map(|(_, it)| { + let target = rate.snap_nearest(it.end() + (t_here - from)).max(it.start + rate.frame_duration()); + Drag::Remix { clip, delta: target - it.end(), from } }), - Drag::Stretch { clip, edge, .. } => seq.find_item(clip).map(|(_, it)| { + Drag::Stretch { clip, edge, from, .. } => seq.find_item(clip).map(|(_, it)| { let base = if edge == filmcraft_edit::Edge::In { it.start } else { it.end() }; - let target = snap(app, seq, layout, rate.snap_nearest(t_here), &[clip]); - Drag::Stretch { clip, edge, delta: target - base } + let target = snap(app, seq, layout, rate.snap_nearest(base + (t_here - from)), &[clip]); + Drag::Stretch { clip, edge, delta: target - base, from } }), - Drag::Roll { left, right, .. } => seq.find_item(left).map(|(_, l)| { - let target = snap(app, seq, layout, rate.snap_nearest(t_here), &[left, right]); - Drag::Roll { left, right, delta: target - l.end() } + Drag::Roll { left, right, from, .. } => seq.find_item(left).map(|(_, l)| { + let target = snap(app, seq, layout, rate.snap_nearest(l.end() + (t_here - from)), &[left, right]); + Drag::Roll { left, right, delta: target - l.end(), from } }), Drag::Slip { clip, .. } => { let start = resp.interact_pointer_pos().map(|_| ()).and(ctx.input(|i| i.pointer.press_origin())); @@ -2017,16 +1969,16 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & .collect(); Some(app.session.execute("timeline.move", json!({"moves": moves, "insert": mods.command, "linked": false}))) } - Drag::Trim { clip, edge, mode, delta } if delta != Tick::ZERO => Some(app.session.execute( + 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 => { + 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::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::Marquee { start } => { diff --git a/crates/ui-egui/src/panels/timeline_hit.rs b/crates/ui-egui/src/panels/timeline_hit.rs new file mode 100644 index 000000000..f8bb64c21 --- /dev/null +++ b/crates/ui-egui/src/panels/timeline_hit.rs @@ -0,0 +1,434 @@ +//! What a press on the Timeline grabs (#259). +//! +//! Pure geometry and rules, shared by the cursor, the hover bracket, the press handler and the +//! control channel's `ui.timeline.hit`, so what the pointer shows is what a press does. + +use std::cmp::Ordering; + +use egui::{Modifiers, Pos2}; +use filmcraft_edit::Edge; +use filmcraft_project::{ClipId, Sequence, Track, TrackId, TransitionId}; + +use super::timeline::Layout; +use crate::state::Tool; + +/// How close (px) to a cut the Selection tool rolls instead of rippling when Settings ▸ Trim ▸ +/// "Allow Selection tool to choose Roll and Ripple trims without modifier key" is on. +pub const ROLL_PX: f32 = 2.5; + +/// Widest an edge zone gets (px), on each side of a clip edge. +pub const EDGE_PX: f32 = 7.0; + +/// The trim the Selection tool starts at an edit point: Cmd+Shift = roll, Cmd = ripple. With the +/// Trim setting on (`no_modifier`), no modifier is needed: right on the cut (when a clip is on the +/// other side) rolls, elsewhere on the edge ripples. Otherwise a plain drag is a regular trim. +pub fn selection_trim_kind(no_modifier: bool, cmd: bool, shift: bool, dist_px: f32, has_neighbour: bool) -> &'static str { + if cmd && shift { + "roll" + } else if cmd { + "ripple" + } else if no_modifier { + if has_neighbour && dist_px <= ROLL_PX { "roll" } else { "ripple" } + } else { + "trim" + } +} + +/// Distance (px) from `x` to the cut at `clip`'s `edge`, and whether a clip touches that cut on +/// the other side. +pub(crate) fn edge_geometry(seq: &Sequence, layout: &Layout, track: TrackId, clip: ClipId, edge: Edge, x: f32) -> (f32, bool) { + let Some(tr) = seq.track(track) else { return (f32::MAX, false) }; + let Some(it) = tr.item(clip) else { return (f32::MAX, false) }; + let (cut, neighbour) = match edge { + Edge::Out => (it.end(), tr.items.iter().any(|x| x.start == it.end())), + Edge::In => (it.start, tr.items.iter().any(|x| x.end() == it.start)), + }; + ((layout.x_of(cut) - x).abs(), neighbour) +} + +/// What is at a screen point. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Hit { + Ruler, + Clip { track: TrackId, clip: ClipId, edge: Option }, + Transition { track: TrackId, id: TransitionId }, + Empty { track: TrackId }, + None, +} + +pub fn hit(seq: &Sequence, layout: &Layout, pos: Pos2) -> Hit { + if layout.ruler.contains(pos) { + return Hit::Ruler; + } + if !layout.content.contains(pos) { + return Hit::None; + } + let Some(row) = layout.row_at(pos.y) else { return Hit::None }; + let Some(tr) = seq.track(row.track) else { return Hit::None }; + for trn in &tr.transitions { + let x0 = layout.x_of(trn.start); + let x1 = layout.x_of(trn.end()); + if pos.x >= x0 && pos.x <= x1 && pos.y > row.rect.min.y + 17.0 { + return Hit::Transition { track: row.track, id: trn.id }; + } + } + if let Some((clip, edge)) = edge_at(tr, layout, pos.x) { + return Hit::Clip { track: row.track, clip, edge: Some(edge) }; + } + match tr.item_at(layout.tick_at(pos.x)) { + Some(it) => Hit::Clip { track: row.track, clip: it.id, edge: None }, + None => Hit::Empty { track: row.track }, + } +} + +/// A clip edge within reach of the pointer. +#[derive(Clone, Copy)] +struct Reach { + clip: ClipId, + edge: Edge, + /// The pointer is inside the clip (or exactly on the edge). + inside: bool, + /// How far the pointer is from the edge (px). + dist: f32, +} + +impl Reach { + /// Inside a clip beats outside one (so at a cut the side of the cut picks the clip), then the + /// nearer edge wins; an exact tie (right on a cut, or mid-way across a narrow gap) goes to the + /// later clip's In edge. + fn beats(&self, other: &Reach) -> bool { + if self.inside != other.inside { + return self.inside; + } + match self.dist.partial_cmp(&other.dist) { + Some(Ordering::Less) => true, + Some(Ordering::Greater) => false, + _ => self.edge == Edge::In && other.edge != Edge::In, + } + } +} + +/// The edge zone (px, on each side of an edge) of a clip `w` px wide on screen: a third of the +/// clip, at most [`EDGE_PX`], so the middle third of a narrow clip still moves it. `None` when the +/// clip has no usable width (zoomed far out, or a broken zoom): it has no edges then. +fn zone(w: f32) -> Option { + if w.is_finite() && w > 0.0 { Some(EDGE_PX.min(w / 3.0)) } else { None } +} + +/// The clip edge on `tr` a pointer at `x` grabs (#259), whatever the order of `tr.items`. +fn edge_at(tr: &Track, layout: &Layout, x: f32) -> Option<(ClipId, Edge)> { + let mut best: Option = None; + for it in &tr.items { + let (x0, x1) = (layout.x_of(it.start), layout.x_of(it.end())); + let Some(e) = zone(x1 - x0) else { continue }; + // the pointer's distance into the clip from each of its edges + for (edge, d) in [(Edge::In, x - x0), (Edge::Out, x1 - x)] { + if !(-e..=e).contains(&d) { + continue; + } + let r = Reach { clip: it.id, edge, inside: d >= 0.0, dist: d.abs() }; + if best.is_none_or(|b| r.beats(&b)) { + best = Some(r); + } + } + } + best.map(|r| (r.clip, r.edge)) +} + +/// The trim a press on a clip edge starts. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum EdgeKind { + /// Regular trim: the edge moves, opening or covering a gap. + Trim, + /// Ripple trim: the edge moves and later clips follow. + Ripple, + /// Roll the cut between `left` and `right`. + Roll { left: ClipId, right: ClipId }, + /// Rate Stretch: the clip's speed changes to fill its new length. + Stretch, + /// Remix: a music clip is re-planned to its new length (Out edges only). + Remix, +} + +impl EdgeKind { + /// Its name on the control channel (`ui.timeline.hit` → `kind`). + pub fn name(self) -> &'static str { + match self { + EdgeKind::Trim => "trim", + EdgeKind::Ripple => "ripple", + EdgeKind::Roll { .. } => "roll", + EdgeKind::Stretch => "rateStretch", + EdgeKind::Remix => "remix", + } + } +} + +/// What a press of a tool at a point grabs: a clip edge and the trim it starts, or whatever else +/// is there. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Grab { + Edge { + track: TrackId, + clip: ClipId, + edge: Edge, + kind: EdgeKind, + }, + /// Not an edge this tool trims: a clip body, a transition, an empty track, the ruler or + /// nothing. A clip's `edge` may still be set, for tools that do something else there (Razor, + /// Slip…). + Other(Hit), +} + +impl Grab { + /// What is under the pointer, whatever the tool does with it. + pub fn hit(self) -> Hit { + match self { + Grab::Edge { track, clip, edge, .. } => Hit::Clip { track, clip, edge: Some(edge) }, + Grab::Other(h) => h, + } + } +} + +/// What a press of `tool` with `mods` at `pos` grabs (#259): the one place that decides which +/// tools trim at a clip edge and which trim they start. `roll_ripple` is Settings ▸ Trim ▸ "Allow +/// Selection tool to choose Roll and Ripple trims without modifier key". +pub fn grab_at(seq: &Sequence, layout: &Layout, pos: Pos2, tool: Tool, mods: Modifiers, roll_ripple: bool) -> Grab { + let h = hit(seq, layout, pos); + let Hit::Clip { track, clip, edge: Some(edge) } = h else { return Grab::Other(h) }; + let kind = match tool { + Tool::Selection => { + let (dist, neighbour) = edge_geometry(seq, layout, track, clip, edge, pos.x); + match selection_trim_kind(roll_ripple, mods.command, mods.shift, dist, neighbour) { + "roll" => roll(seq, track, clip, edge), + "ripple" => EdgeKind::Ripple, + _ => EdgeKind::Trim, + } + } + Tool::Ripple => EdgeKind::Ripple, + Tool::Rolling => roll(seq, track, clip, edge), + Tool::RateStretch => EdgeKind::Stretch, + Tool::Remix if edge == Edge::Out => EdgeKind::Remix, + _ => return Grab::Other(h), + }; + Grab::Edge { track, clip, edge, kind } +} + +/// A roll of the cut at `clip`'s `edge`, or a regular trim when no clip touches that cut on the +/// other side (there is nothing to roll against). +fn roll(seq: &Sequence, track: TrackId, clip: ClipId, edge: Edge) -> EdgeKind { + let Some(tr) = seq.track(track) else { return EdgeKind::Trim }; + let Some(it) = tr.item(clip) else { return EdgeKind::Trim }; + let pair = match edge { + Edge::Out => tr.items.iter().find(|x| x.start == it.end()).map(|r| (clip, r.id)), + Edge::In => tr.items.iter().find(|x| x.end() == it.start).map(|l| (l.id, clip)), + }; + match pair { + Some((left, right)) => EdgeKind::Roll { left, right }, + None => EdgeKind::Trim, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Rect, pos2}; + use filmcraft_project::{ItemId, Label, Project, SequenceSettings, TrackItem, TrackKind}; + use filmcraft_time::{FrameRate, Tick}; + + use crate::panels::timeline::Row; + + /// The row's top. Points are hit-tested 10 px below it, above where transitions are hit. + const TOP: f32 = 100.0; + + /// A clip on screen from `x0` to `x1` px (the layout is 100 px per second). + fn clip(id: u64, x0: f64, x1: f64) -> TrackItem { + TrackItem { + id: ClipId(id), + item: ItemId(1), + name: format!("c{id}"), + label: Label::Iris, + start: Tick::from_seconds_f64(x0 / 100.0), + duration: Tick::from_seconds_f64((x1 - x0) / 100.0), + source_in: Tick::ZERO, + speed: 1.0, + reverse: false, + enabled: true, + link: None, + group: None, + effects: Vec::new(), + markers: Vec::new(), + gain_db: 0.0, + frame_hold: None, + scale_to_frame: false, + essential: None, + multicam: None, + time_interpolation: Default::default(), + hold_filters: false, + field_options: None, + source_channels: Vec::new(), + graphic: None, + } + } + + /// A: 100–300, B: 300–500 (sharing a cut with A), C: 504–700 (after a 4 px gap), D: 800–809 + /// (9 px wide, so its zones are 3 px). + fn clips() -> Vec { + vec![clip(1, 100.0, 300.0), clip(2, 300.0, 500.0), clip(3, 504.0, 700.0), clip(4, 800.0, 809.0)] + } + + /// One video track holding `items`, laid out at 100 px/s from x = 0. + fn fixture(items: Vec) -> (Sequence, Layout, TrackId) { + let mut p = Project::new("t"); + let s = p.new_sequence("s", SequenceSettings { frame_rate: FrameRate::FPS_24, ..Default::default() }, 1, 0, None); + let mut seq = p.sequence(s).cloned().expect("sequence"); + let track = seq.video_tracks[0].id; + seq.video_tracks[0].items = items; + let row = Rect::from_min_max(pos2(0.0, TOP), pos2(2000.0, TOP + 60.0)); + let layout = Layout { + content: row, + ruler: Rect::from_min_max(pos2(0.0, 0.0), pos2(2000.0, 40.0)), + rows: vec![Row { track, kind: TrackKind::Video, index: 0, rect: row }], + pps: 100.0, + scroll: 0.0, + split_y: TOP + 60.0, + }; + (seq, layout, track) + } + + fn at(x: f32) -> Pos2 { + pos2(x, TOP + 10.0) + } + + fn edge_hit(track: TrackId, clip: u64, edge: Edge) -> Hit { + Hit::Clip { track, clip: ClipId(clip), edge: Some(edge) } + } + + #[test] + fn the_side_of_a_cut_picks_the_clip() { + let (seq, l, tr) = fixture(clips()); + assert_eq!(hit(&seq, &l, at(297.0)), edge_hit(tr, 1, Edge::Out)); + assert_eq!(hit(&seq, &l, at(303.0)), edge_hit(tr, 2, Edge::In)); + // right on the cut: the later clip + assert_eq!(hit(&seq, &l, at(300.0)), edge_hit(tr, 2, Edge::In)); + } + + #[test] + fn a_narrow_gap_splits_at_its_middle() { + let (seq, l, tr) = fixture(clips()); + assert_eq!(hit(&seq, &l, at(501.0)), edge_hit(tr, 2, Edge::Out)); + assert_eq!(hit(&seq, &l, at(503.0)), edge_hit(tr, 3, Edge::In)); + // exactly mid-way: the later clip + assert_eq!(hit(&seq, &l, at(502.0)), edge_hit(tr, 3, Edge::In)); + } + + #[test] + fn free_edges_reach_into_the_gap_beside_them() { + let (seq, l, tr) = fixture(clips()); + assert_eq!(hit(&seq, &l, at(95.0)), edge_hit(tr, 1, Edge::In)); + assert_eq!(hit(&seq, &l, at(705.0)), edge_hit(tr, 3, Edge::Out)); + assert_eq!(hit(&seq, &l, at(92.0)), Hit::Empty { track: tr }); + assert_eq!(hit(&seq, &l, at(200.0)), Hit::Clip { track: tr, clip: ClipId(1), edge: None }); + } + + #[test] + fn edges_do_not_depend_on_the_order_of_the_items() { + let mut reversed = clips(); + reversed.reverse(); + let (a, la, _) = fixture(clips()); + let (b, lb, _) = fixture(reversed); + // edge points only: finding the clip under a body point relies on the items being sorted + for x in [297.0, 300.0, 303.0, 501.0, 502.0, 503.0, 95.0, 705.0, 802.0, 807.0] { + assert_eq!(hit(&a, &la, at(x)), hit(&b, &lb, at(x)), "x = {x}"); + } + } + + #[test] + fn a_narrow_clip_keeps_its_middle_third() { + let (seq, l, tr) = fixture(clips()); + assert_eq!(hit(&seq, &l, at(802.0)), edge_hit(tr, 4, Edge::In)); + assert_eq!(hit(&seq, &l, at(807.0)), edge_hit(tr, 4, Edge::Out)); + assert_eq!(hit(&seq, &l, at(804.5)), Hit::Clip { track: tr, clip: ClipId(4), edge: None }); + // 4 px outside it is beyond its 3 px zone + assert_eq!(hit(&seq, &l, at(796.0)), Hit::Empty { track: tr }); + } + + #[test] + fn hostile_points_and_zooms_never_panic_or_grab_an_edge() { + let (seq, l, tr) = fixture(vec![clip(5, 1000.0, 1000.0)]); + // a zero-length clip has no edges, even right on it + assert_eq!(hit(&seq, &l, at(1000.0)), Hit::Empty { track: tr }); + for p in [pos2(f32::NAN, TOP + 10.0), pos2(f32::INFINITY, TOP + 10.0), pos2(f32::NEG_INFINITY, TOP + 10.0), pos2(300.0, f32::NAN), pos2(300.0, 5000.0)] + { + assert_eq!(hit(&seq, &l, p), Hit::None, "{p:?}"); + } + let (seq, l, _) = fixture(clips()); + assert_eq!(hit(&seq, &l, pos2(300.0, 20.0)), Hit::Ruler); + for pps in [0.0, f64::NAN, f64::INFINITY] { + let broken = Layout { pps, ..l.clone() }; + assert!(!matches!(hit(&seq, &broken, at(300.0)), Hit::Clip { edge: Some(_), .. }), "pps {pps}"); + } + } + + use egui::Modifiers; + + use crate::state::Tool; + + const ROLL_AB: EdgeKind = EdgeKind::Roll { left: ClipId(1), right: ClipId(2) }; + + fn grab(seq: &Sequence, l: &Layout, tool: Tool, x: f32, mods: Modifiers, roll_ripple: bool) -> Grab { + grab_at(seq, l, at(x), tool, mods, roll_ripple) + } + + fn edge_grab(track: TrackId, clip: u64, edge: Edge, kind: EdgeKind) -> Grab { + Grab::Edge { track, clip: ClipId(clip), edge, kind } + } + + #[test] + fn selection_tool_trims_by_modifier_and_setting() { + let (seq, l, tr) = fixture(clips()); + let (none, cmd, cmd_shift) = (Modifiers::NONE, Modifiers::COMMAND, Modifiers::COMMAND | Modifiers::SHIFT); + let sel = |x, mods, pref| grab(&seq, &l, Tool::Selection, x, mods, pref); + assert_eq!(sel(297.0, none, false), edge_grab(tr, 1, Edge::Out, EdgeKind::Trim)); + assert_eq!(sel(303.0, cmd, false), edge_grab(tr, 2, Edge::In, EdgeKind::Ripple)); + assert_eq!(sel(303.0, cmd_shift, false), edge_grab(tr, 2, Edge::In, ROLL_AB)); + // Settings ▸ Trim: right on the cut rolls, elsewhere on the edge ripples + assert_eq!(sel(298.0, none, true), edge_grab(tr, 1, Edge::Out, ROLL_AB)); + assert_eq!(sel(302.0, none, true), edge_grab(tr, 2, Edge::In, ROLL_AB)); + assert_eq!(sel(305.0, none, true), edge_grab(tr, 2, Edge::In, EdgeKind::Ripple)); + // a roll with no clip on the other side of the cut is a regular trim + assert_eq!(sel(102.0, cmd_shift, false), edge_grab(tr, 1, Edge::In, EdgeKind::Trim)); + // mid-clip: the clip itself + assert_eq!(sel(200.0, none, false), Grab::Other(Hit::Clip { track: tr, clip: ClipId(1), edge: None })); + } + + #[test] + fn which_tools_trim_at_an_edge() { + let (seq, l, tr) = fixture(clips()); + let g = |tool, x| grab(&seq, &l, tool, x, Modifiers::NONE, false); + assert_eq!(g(Tool::Ripple, 297.0), edge_grab(tr, 1, Edge::Out, EdgeKind::Ripple)); + assert_eq!(g(Tool::Rolling, 303.0), edge_grab(tr, 2, Edge::In, ROLL_AB)); + assert_eq!(g(Tool::Rolling, 102.0), edge_grab(tr, 1, Edge::In, EdgeKind::Trim)); + assert_eq!(g(Tool::RateStretch, 297.0), edge_grab(tr, 1, Edge::Out, EdgeKind::Stretch)); + assert_eq!(g(Tool::Remix, 297.0), edge_grab(tr, 1, Edge::Out, EdgeKind::Remix)); + // Remix has Out edges only + assert_eq!(g(Tool::Remix, 303.0), Grab::Other(edge_hit(tr, 2, Edge::In))); + for tool in [Tool::Razor, Tool::Slip, Tool::Slide, Tool::Hand, Tool::Zoom, Tool::TrackSelectForward] { + assert_eq!(g(tool, 297.0), Grab::Other(edge_hit(tr, 1, Edge::Out)), "{tool:?}"); + } + } + + #[test] + fn edge_kind_names_for_the_control_channel() { + assert_eq!( + [EdgeKind::Trim, EdgeKind::Ripple, ROLL_AB, EdgeKind::Stretch, EdgeKind::Remix].map(EdgeKind::name), + ["trim", "ripple", "roll", "rateStretch", "remix"] + ); + } + + #[test] + fn a_grab_reports_what_is_under_it() { + let (seq, l, tr) = fixture(clips()); + assert_eq!(grab(&seq, &l, Tool::Selection, 303.0, Modifiers::NONE, false).hit(), edge_hit(tr, 2, Edge::In)); + assert_eq!(grab(&seq, &l, Tool::Razor, 303.0, Modifiers::NONE, false).hit(), edge_hit(tr, 2, Edge::In)); + } +} diff --git a/crates/ui-egui/tests/timeline_trim_ui.rs b/crates/ui-egui/tests/timeline_trim_ui.rs new file mode 100644 index 000000000..6e8c0e9f0 --- /dev/null +++ b/crates/ui-egui/tests/timeline_trim_ui.rs @@ -0,0 +1,239 @@ +//! Headless UI tests of trimming in the Timeline (#259): a press acts on what was under the +//! pointer when the button went down, the side of a cut picks the clip, and the cursor, +//! `ui.timeline.hit` and the press agree. The real `FilmcraftApp` under `egui_kittest` with the +//! demo project, the pointer moving a few pixels per frame as a mouse does. + +use std::sync::mpsc::{Sender, channel}; + +use egui::{CursorIcon, 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 serde_json::{Value, json}; + +/// Two clips of the demo project that share a cut on V1. +const CITY: &str = "City_Night_Drive.mp4"; +const DUNES: &str = "Desert_Dunes.mp4"; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + /// The demo project, snapping off so drags land exactly where the pointer goes. Frames are + /// 1/60 s apart, as on a display: at the harness's default 1/4 s, a press held for four frames + /// is already a drag by time (egui's 0.8 s click limit), wherever the pointer is. + 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); + // `ui.elements` answers once the timeline has finished fitting the sequence + 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 app(&self) -> &FilmcraftApp { + self.harness.state() + } + + /// Press at `from`, move to `to` over `steps` frames (as a mouse does), release. + fn drag(&mut self, from: Pos2, to: Pos2, steps: usize) { + let push = |d: &mut Self, e: egui::Event| d.harness.input_mut().events.push(e); + push(self, egui::Event::PointerMoved(from)); + self.frames(1); + push(self, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + self.frames(1); + for i in 1..=steps { + push(self, egui::Event::PointerMoved(from + (to - from) * (i as f32 / steps as f32))); + self.frames(1); + } + push(self, egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + self.frames(2); + } + + /// Move the pointer to `at` with no button down, and let the Timeline react. + fn hover(&mut self, at: Pos2) { + self.harness.input_mut().events.push(egui::Event::PointerMoved(at)); + self.frames(2); + } + + /// The cursor the last frame asked for. + fn cursor(&self) -> CursorIcon { + self.harness.output().platform_output.cursor_icon + } + + /// The clip named `name` on V1. + 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 + } + + /// A clip's (start, end) in ticks. + 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) + } + + /// Screen x of a clip's In and Out edges, and a y 8 px below the top of its track: the band + /// where edges are grabbed, above the lower part of a track where a transition on a cut takes + /// the press (the demo has one on the City_Night_Drive / Desert_Dunes cut). + fn edges(&mut self, clip: u64) -> (f32, f32, f32) { + // `ui.timeline.locate` answers with points 2 px inside the edges + 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) + } + + /// Ticks per screen pixel, from a clip's length on screen. + fn ticks_per_px(&mut self, clip: u64) -> f64 { + let (s, e) = self.span(clip); + let (x0, x1, _) = self.edges(clip); + (e - s) as f64 / f64::from(x1 - x0) + } + + /// One frame of the sequence, in ticks. + fn frame(&self) -> i64 { + self.app().session.active_sequence().expect("sequence").settings.frame_rate.frame_duration().0 + } + + fn undo_steps(&self) -> usize { + self.app().session.history.undo.len() + } +} + +/// #259: grabbing a clip's Out edge 3 px inside and dragging inward trims it, by as far as the +/// pointer moved. The drag is recognised only once the pointer has moved past egui's 6 pt drag +/// threshold, about 10 px inside the clip and so outside the 7 px edge zone, where the clip used to +/// be selected and moved instead. +#[test] +fn dragging_an_out_edge_inward_trims_the_clip() { + let mut d = Driver::demo(); + let city = d.v1_clip(CITY); + let (x0, x1, y) = d.edges(city); + let (s0, e0) = d.span(city); + let (tpp, frame, undo0) = (d.ticks_per_px(city), d.frame(), d.undo_steps()); + let by = ((x1 - x0) / 4.0).round(); + d.drag(pos2(x1 - 3.0, y), pos2(x1 - 3.0 - by, y), by as usize); + let (s1, e1) = d.span(city); + assert_eq!(s1, s0, "the In edge stayed: trimmed, not moved"); + assert!(e1 < e0, "the Out edge moved in: {e0} → {e1}"); + let want = f64::from(by) * tpp; + assert!(((e0 - e1) as f64 - want).abs() <= frame as f64, "the edge moved {} ticks for {by} px ({want} ticks) ± a frame ({frame})", e0 - e1); + assert_eq!(d.undo_steps(), undo0 + 1, "one undo step"); +} + +/// Just right of a cut the clip on the right is grabbed: its In edge trims, and the clip on the +/// left stays as it was. The left clip's Out edge used to claim both sides of the cut. +#[test] +fn right_of_a_cut_trims_the_right_clip() { + let mut d = Driver::demo(); + let (city, dunes) = (d.v1_clip(CITY), d.v1_clip(DUNES)); + assert_eq!(d.span(city).1, d.span(dunes).0, "the demo's City_Night_Drive and Desert_Dunes share a cut"); + let (cut, _, y) = d.edges(dunes); + let (city0, (s0, e0)) = (d.span(city), d.span(dunes)); + d.drag(pos2(cut + 3.0, y), pos2(cut + 43.0, y), 40); + let (s1, e1) = d.span(dunes); + assert!(s1 > s0, "Desert_Dunes' In edge moved right: {s0} → {s1}"); + assert_eq!(e1, e0, "Desert_Dunes' Out edge stayed: trimmed, not moved"); + assert_eq!(d.span(city), city0, "City_Night_Drive is untouched"); +} + +/// A click that wobbles 5.5 px (still a click to egui, under its 6 pt limit) selects the edit point +/// that was pressed. It used to be hit-tested where the button came up, 8.5 px inside the clip and +/// outside the edge zone, and selected the clip. +#[test] +fn a_wobbly_click_on_an_edge_selects_the_edit_point() { + let mut d = Driver::demo(); + let city = d.v1_clip(CITY); + let st = &mut d.harness.state_mut().session.state; + st.selection.clear(); + st.edit_points.clear(); + let (_, x1, y) = d.edges(city); + d.drag(pos2(x1 - 3.0, y), pos2(x1 - 8.5, y), 3); + let st = &d.app().session.state; + assert!(st.edit_points.iter().any(|e| e.clip == ClipId(city) && e.out), "City_Night_Drive's Out edge is the edit point: {:?}", st.edit_points); + assert!(!st.selection.contains(&ClipId(city)), "the clip is not selected"); +} + +/// A moved clip stays under the point it was grabbed by. It used to lag behind the pointer by the +/// distance egui waits before it calls a press a drag. +#[test] +fn a_moved_clip_follows_the_pointer_from_where_it_was_grabbed() { + let mut d = Driver::demo(); + let city = d.v1_clip(CITY); + let (x0, x1, y) = d.edges(city); + let (s0, _) = d.span(city); + let (tpp, frame) = (d.ticks_per_px(city), d.frame()); + let mid = (x0 + x1) / 2.0; + d.drag(pos2(mid, y), pos2(mid + 120.0, y), 30); + let (s1, _) = d.span(city); + let want = 120.0 * tpp; + assert!(((s1 - s0) as f64 - want).abs() <= frame as f64, "moved {} ticks for 120 px ({want} ticks) ± a frame ({frame})", s1 - s0); +} + +/// The cursor, `ui.timeline.hit` and the press agree on what is under the pointer: just left of +/// a cut the left clip's Out edge, just right of it the right clip's In edge, mid-clip no edge. +/// (That a drag from those points trims those edges is tested above, from the same points.) +#[test] +fn cursor_hit_test_and_press_agree_at_a_cut() { + let mut d = Driver::demo(); + let (city, dunes) = (d.v1_clip(CITY), d.v1_clip(DUNES)); + let (x0, _, _) = d.edges(city); + let (cut, _, y) = d.edges(dunes); + let hit = |d: &mut Driver, x: f32, mods: Value| d.ok("ui.timeline.hit", json!({"x": x, "y": y, "modifiers": mods})); + for (x, clip, edge) in [(cut - 3.0, city, "Out"), (cut + 3.0, dunes, "In")] { + d.hover(pos2(x, y)); + assert_eq!(d.cursor(), CursorIcon::ResizeColumn, "trim cursor at x {x}"); + let h = hit(&mut d, x, json!({})); + assert_eq!((h["clip"].as_u64(), h["edge"].as_str(), h["kind"].as_str()), (Some(clip), Some(edge), Some("trim")), "at x {x}: {h}"); + let h = hit(&mut d, x, json!({"command": true})); + assert_eq!(h["kind"], "ripple", "Cmd at x {x}: {h}"); + } + let mid = (x0 + cut) / 2.0; + d.hover(pos2(mid, y)); + assert_eq!(d.cursor(), CursorIcon::Default, "no trim cursor mid-clip"); + let h = hit(&mut d, mid, json!({})); + assert_eq!((h["clip"].as_u64(), h["kind"].clone()), (Some(city), Value::Null), "mid-clip: {h}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6419e154a..e25b667d0 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -35,7 +35,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `ui.drag` | `{from, to, steps, modifiers}` | press–move–release | | `ui.scroll` | `{id|x,y, dx, dy, modifiers}` | wheel / trackpad | | `ui.key` / `ui.type` | `{key}` (`Cmd+K`, `Space`…) / `{text}` | keyboard | -| `ui.timeline.hit` / `ui.timeline.locate` | `{x,y}` / `{clip, edge?}` | timeline hit-testing | +| `ui.timeline.hit` / `ui.timeline.locate` | `{x,y,modifiers?}` / `{clip, edge?}` | timeline hit-testing. `hit` reports a clip's `edge` (`In`/`Out`) within 7 px of it (a third of a narrow clip); just left of a cut that is the left clip's Out, from the cut rightwards the right clip's In. `kind` is the trim a press of the current tool with `modifiers` starts there (`trim`, `ripple`, `roll`, `rateStretch`, `remix`), or null | | `ui.playback` | `{action: play|stop|toggle, speed?}` | | | `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) | | `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus | From 42cc47e7db0987ed180b3a93e3870bab7e95957a Mon Sep 17 00:00:00 2001 From: Bloxy <93786729+ItzBloxy@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:40:07 +0200 Subject: [PATCH 2/4] #259: the timeline shows the edge a press will grab Hovering a clip edge draws the edit-point bracket at half strength on the edge a press would grab: red for a ripple; yellow for a trim, roll, rate stretch or remix; on both sides of the cut for a roll. It follows the tool and the modifiers, from the same grab_at as the press. The bracket drawing is shared with the selected edit points (trim_monitor::paint_bracket). An ignored test renders the four cases at a demo cut to PNGs for review: cargo test -p filmcraft-ui-egui --test timeline_trim_ui -- --ignored hover_bracket_screenshots Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 4 +++ crates/ui-egui/src/panels/trim_monitor.rs | 43 +++++++++++++++++------ crates/ui-egui/tests/timeline_trim_ui.rs | 32 +++++++++++++++++ 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index da8254666..2b8dd294b 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1737,6 +1737,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & _ => CursorIcon::Default, }; ctx.set_cursor_icon(cur); + // the edge a press here would grab, and the trim it starts (#259) + if let Grab::Edge { track, clip, edge, kind } = g { + super::trim_monitor::paint_hover_bracket(ui.painter(), seq, layout, track, clip, edge, kind); + } // razor preview line if tool == Tool::Razor && let Grab::Other(Hit::Clip { track, .. }) = g diff --git a/crates/ui-egui/src/panels/trim_monitor.rs b/crates/ui-egui/src/panels/trim_monitor.rs index 4347bf6ff..cee0b505c 100644 --- a/crates/ui-egui/src/panels/trim_monitor.rs +++ b/crates/ui-egui/src/panels/trim_monitor.rs @@ -14,7 +14,8 @@ //! `trimMonitor.playAround`, `trimMonitor.exit`. use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; -use filmcraft_project::{ItemKind, Sequence, Track}; +use filmcraft_edit::Edge; +use filmcraft_project::{ClipId, ItemKind, Sequence, Track, TrackId}; use filmcraft_time::{Tick, TimeDisplay, format_time}; use serde_json::{Value, json}; @@ -22,6 +23,7 @@ use crate::FilmcraftApp; use crate::frames::{FrameKey, Target}; use crate::icons::{self, Icon}; use crate::panels::timeline::{Layout, Row}; +use crate::panels::timeline_hit::EdgeKind; use crate::theme::Tokens; /// Ripple edit points are red, roll / regular trims yellow (as in Premiere's timeline). @@ -79,19 +81,40 @@ pub fn paint_edit_points(app: &FilmcraftApp, p: &egui::Painter, seq: &Sequence, let col = if ep.kind == TrimKind::Ripple { RIPPLE_RED } else { ROLL_YELLOW }; for (clip, out) in marks { let Some(it) = tr.item(clip) else { continue }; - let x = layout.x_of(if out { it.end() } else { it.start }); - let (y0, y1) = (row.rect.min.y + 2.0, row.rect.max.y - 2.0); - let dir = if out { -1.0 } else { 1.0 }; - let xi = x + dir * 1.5; - let w = 6.0 * dir; - let s = Stroke::new(3.0, col); - p.line_segment([pos2(xi, y0), pos2(xi, y1)], s); - p.line_segment([pos2(xi, y0 + 1.0), pos2(xi + w, y0 + 1.0)], s); - p.line_segment([pos2(xi, y1 - 1.0), pos2(xi + w, y1 - 1.0)], s); + paint_bracket(p, layout.x_of(if out { it.end() } else { it.start }), row, out, col); } } } +/// A trim bracket at `x` on `row`, its arms pointing into the clip (left for an Out edge). +pub fn paint_bracket(p: &egui::Painter, x: f32, row: &Row, out: bool, col: Color32) { + let (y0, y1) = (row.rect.min.y + 2.0, row.rect.max.y - 2.0); + let dir = if out { -1.0 } else { 1.0 }; + let xi = x + dir * 1.5; + let w = 6.0 * dir; + let s = Stroke::new(3.0, col); + p.line_segment([pos2(xi, y0), pos2(xi, y1)], s); + p.line_segment([pos2(xi, y0 + 1.0), pos2(xi + w, y0 + 1.0)], s); + p.line_segment([pos2(xi, y1 - 1.0), pos2(xi + w, y1 - 1.0)], s); +} + +/// The bracket on the edge a press would grab (#259): an edit point's bracket at half strength, +/// red for a ripple and yellow otherwise; a roll marks both sides of the cut. +pub fn paint_hover_bracket(p: &egui::Painter, seq: &Sequence, layout: &Layout, track: TrackId, clip: ClipId, edge: Edge, kind: EdgeKind) { + let Some(row) = layout.rows.iter().find(|r| r.track == track) else { return }; + let Some(tr) = seq.track(track) else { return }; + let full = if kind == EdgeKind::Ripple { RIPPLE_RED } else { ROLL_YELLOW }; + let col = full.gamma_multiply(0.5); + let marks = match kind { + EdgeKind::Roll { left, right } => vec![(left, true), (right, false)], + _ => vec![(clip, edge == Edge::Out)], + }; + for (c, out) in marks { + let Some(it) = tr.item(c) else { continue }; + paint_bracket(p, layout.x_of(if out { it.end() } else { it.start }), row, out, col); + } +} + /// Fit a w×h picture into `area`. fn fit(area: Rect, w: f32, h: f32) -> Rect { let s = (area.width() / w.max(1.0)).min(area.height() / h.max(1.0)); diff --git a/crates/ui-egui/tests/timeline_trim_ui.rs b/crates/ui-egui/tests/timeline_trim_ui.rs index 6e8c0e9f0..2412961b4 100644 --- a/crates/ui-egui/tests/timeline_trim_ui.rs +++ b/crates/ui-egui/tests/timeline_trim_ui.rs @@ -98,6 +98,19 @@ impl Driver { self.harness.output().platform_output.cursor_icon } + /// Render the window to `/ui-screenshots/.png` (needs a GPU). + fn screenshot(&mut self, name: &str) -> std::path::PathBuf { + self.frames(2); + let img = self.harness.render().expect("render"); + let png = filmcraft_ui_egui::control::encode_png(img.as_raw(), img.width(), img.height()).expect("png"); + let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("ui-screenshots"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{name}.png")); + std::fs::write(&path, png).unwrap(); + eprintln!("screenshot: {}", path.display()); + path + } + /// The clip named `name` on V1. fn v1_clip(&self, name: &str) -> u64 { let seq = self.app().session.active_sequence().expect("sequence"); @@ -237,3 +250,22 @@ fn cursor_hit_test_and_press_agree_at_a_cut() { let h = hit(&mut d, mid, json!({})); assert_eq!((h["clip"].as_u64(), h["kind"].clone()), (Some(city), Value::Null), "mid-clip: {h}"); } + +/// Renders the hover bracket at the City_Night_Drive / Desert_Dunes cut to PNGs for visual +/// review (needs a GPU): `cargo test -p filmcraft-ui-egui --test timeline_trim_ui -- --ignored +/// hover_bracket_screenshots`. Yellow on City_Night_Drive's Out edge just left of the cut and on +/// Desert_Dunes' In edge just right of it, red for the Ripple tool, both sides for the Rolling tool. +#[test] +#[ignore] +fn hover_bracket_screenshots() { + let mut d = Driver::demo(); + let dunes = d.v1_clip(DUNES); + let (cut, _, y) = d.edges(dunes); + for (tool, name, x) in + [("selection", "trim-out", cut - 3.0), ("selection", "trim-in", cut + 3.0), ("ripple", "ripple-in", cut + 3.0), ("rolling", "roll", cut + 3.0)] + { + d.ok("ui.set", json!({"tool": tool})); + d.hover(pos2(x, y)); + d.screenshot(&format!("hover-{name}")); + } +} From af2b4601c88e67904ecea7df40b227e919c681b1 Mon Sep 17 00:00:00 2001 From: Bloxy <93786729+ItzBloxy@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:46:35 +0200 Subject: [PATCH 3/4] #259: caption edges trim from where they were pressed Caption blocks chose move / trim in / trim out where egui recognised the drag, about 9 px in from a press 2 px inside the edge and past the 5 px edge zone, so they moved instead of trimming. They now decide at the press origin and measure the drag from it. UI test that failed before. Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/panels/timeline_captions.rs | 4 ++- crates/ui-egui/tests/timeline_trim_ui.rs | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline_captions.rs b/crates/ui-egui/src/panels/timeline_captions.rs index b154f5540..bbb73c21f 100644 --- a/crates/ui-egui/src/panels/timeline_captions.rs +++ b/crates/ui-egui/src/panels/timeline_captions.rs @@ -213,8 +213,10 @@ fn interact_block( if tr.locked { return; } + // decide on what was under the pointer when the button went down, not where egui recognised + // the drag (6 pt later, past the 5 px edge zones) (#259) if resp.drag_started() - && let Some(p) = resp.interact_pointer_pos() + && let Some(p) = ctx.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) { let g = match gesture_at(p.x) { Gesture::Move => 0u8, diff --git a/crates/ui-egui/tests/timeline_trim_ui.rs b/crates/ui-egui/tests/timeline_trim_ui.rs index 2412961b4..8ea07cd2b 100644 --- a/crates/ui-egui/tests/timeline_trim_ui.rs +++ b/crates/ui-egui/tests/timeline_trim_ui.rs @@ -68,6 +68,10 @@ impl Driver { 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() } @@ -269,3 +273,28 @@ fn hover_bracket_screenshots() { d.screenshot(&format!("hover-{name}")); } } + +/// A caption's Out edge, grabbed 2 px inside and dragged inward, trims the caption. The gesture +/// used to be decided where the drag was recognised, about 9 px inside (2 px plus egui's 6 pt drag +/// threshold), past the 5 px edge zone, so the caption moved. +#[test] +fn dragging_a_caption_edge_inward_trims_it() { + let mut d = Driver::demo(); + let id = d.exec("captions.add", json!({"seconds": 2.0, "durationSeconds": 2.0}))["caption"].as_u64().expect("caption id"); + d.frames(4); + let name = format!("timeline.caption.{id}"); + let els = d.ok("ui.elements", json!({"prefix": name})); + let el = els.as_array().expect("elements").iter().find(|e| e["id"] == name.as_str()).unwrap_or_else(|| panic!("no {name}: {els}")).clone(); + let r: Vec = el["rect"].as_array().expect("rect").iter().map(|v| v.as_f64().expect("number") as f32).collect(); + let (x1, y) = (r[0] + r[2], r[1] + r[3] / 2.0); + let span = |d: &Driver| { + let seq = d.app().session.active_sequence().expect("sequence"); + let (_, c) = seq.find_caption(ClipId(id)).expect("caption"); + (c.start.0, c.end().0) + }; + let (s0, e0) = span(&d); + d.drag(pos2(x1 - 2.0, y), pos2(x1 - 32.0, y), 30); + let (s1, e1) = span(&d); + assert_eq!(s1, s0, "the caption's start stayed: trimmed, not moved"); + assert!(e1 < e0, "the caption's end moved in: {e0} → {e1}"); +} From 7401f5038b7268dc444d898734763e23e049896e Mon Sep 17 00:00:00 2001 From: Bloxy <93786729+ItzBloxy@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:17:08 +0200 Subject: [PATCH 4/4] contributors: credit @ItzBloxy My own entry for the About window: display name "Bloxy". Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 89c6db208..44fa7aee5 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -21,3 +21,6 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false + +[people.ItzBloxy] +display_name = "Bloxy"