mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-30 04:57:19 -06:00
Merge pull request #399 from ItzBloxy/fix/259-trim-hit-testing
#259: the edge you press is the edge you trim
This commit is contained in:
@@ -21,3 +21,6 @@
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.ItzBloxy]
|
||||
display_name = "Bloxy"
|
||||
|
||||
@@ -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`") };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Drag>,
|
||||
/// What the primary button went down on in the Timeline (#259); see [`Pressed`].
|
||||
pub pressed: Option<Pressed>,
|
||||
/// Last layout (for hit-testing from the control channel).
|
||||
pub layout: Option<Layout>,
|
||||
/// 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<filmcraft_edit::Edge> },
|
||||
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,26 +1722,28 @@ 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,
|
||||
};
|
||||
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 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 +1752,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 +1774,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 +1788,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<u64> = seq
|
||||
.all_tracks()
|
||||
@@ -1823,14 +1800,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 +1817,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 +1845,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 +1855,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 +1912,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 +1973,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 } => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Edge> },
|
||||
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<f32> {
|
||||
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<Reach> = 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<TrackItem> {
|
||||
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<TrackItem>) -> (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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
//! 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<ControlRequest>,
|
||||
}
|
||||
|
||||
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 exec(&mut self, command: &str, params: Value) -> Value {
|
||||
self.ok("engine.execute", json!({"command": command, "params": params}))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// Render the window to `<target tmp>/ui-screenshots/<name>.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");
|
||||
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}");
|
||||
}
|
||||
|
||||
/// 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}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<f32> = 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}");
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user