simple_editor\ui/
preview.rs

1//! Preview panel: the rendered frame (letterboxed, black bars), a selection outline for the selected
2//! visual clip (drag to move = edits clip.x / clip.y at the playhead time via `Animated::set_at`, calling
3//! `undo` once at drag start), and the transport bar, centred under the video:
4//! go-start, prev cut, step back, play/pause, stop, step forward, next cut, go-end (all painted
5//! glyphs, NLE order) plus timecode / duration, In/Out buttons and the
6//! in/out times, a preview-quality selector (100 / 75 / 50 / 25 %) and a Movie mode toggle.
7//! In `fullscreen` mode only the video is drawn (no transport, no overlay): Esc / F11 leave it (app).
8//!
9//! When `PreviewCtx.tool` is anything but `Tool::Select`, a click-drag over the video draws instead of
10//! moving the clip: a shape tool reports `new_shape`, the Draw tool records a timed `stroke`, and a mask
11//! tool edits the selected clip's mask (reported through `mask_edit`); a Polygon/Path mask gets its
12//! vertices from the drag rect (an empty point list would hide the clip entirely).
13//! The Polygon *shape* tool is SVG-style instead: each click appends a vertex (the path is drawn live),
14//! a click back on a placed vertex (which is where a double-click's second press lands) or Enter closes
15//! it into a shape, and re-selecting that shape with the Select tool puts a drag handle on every point.
16
17use crate::engine::compose::placement;
18use crate::hotkeys::Action;
19use crate::media::Frame;
20use crate::model::{ClipKind, Id, Mask, MaskShape, Project, ShapeKind, Stroke as ModelStroke};
21use crate::theme::Palette;
22use crate::ui::timecode;
23use crate::ui::tools::{glyph_text_button, Dir, Glyph, Tool};
24use eframe::egui::{self, pos2, vec2, Color32, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, TextureOptions, Vec2};
25use std::sync::Arc;
26
27/// Preview render scales offered by the quality selector.
28pub const QUALITIES: [u32; 4] = [100, 75, 50, 25];
29
30/// A click-drag with a non-Select tool.
31struct ToolDrag {
32    /// Press position in screen points.
33    from: Pos2,
34    /// `ui.input(|i| i.time)` at the press (drawing strokes are timed).
35    t0: f64,
36    /// Draw tool: (x, y, t) in project px relative to the canvas centre.
37    points: Vec<(f32, f32, f32)>,
38}
39
40pub struct PreviewState {
41    pub texture: Option<egui::TextureHandle>,
42    /// Active overlay drag: clip (x, y) at drag start and the accumulated pointer delta (points).
43    drag: Option<(f64, f64, Vec2)>,
44    /// Active tool drag (shape / draw / mask).
45    tool_drag: Option<ToolDrag>,
46    /// Polygon tool: vertices placed so far, project px relative to the canvas centre.
47    poly: Vec<(f32, f32)>,
48    /// Select tool: index of the polygon vertex being dragged.
49    point_drag: Option<usize>,
50    /// Last pointer movement (fullscreen hides the cursor after 2 s of stillness).
51    moved_at: Option<std::time::Instant>,
52    /// Content rect of the transport row last frame — it is centred against the panel using its own
53    /// measured width, so the first frame is left-aligned and every later one is centred.
54    transport: Rect,
55}
56
57impl Default for PreviewState {
58    fn default() -> Self {
59        Self {
60            texture: None,
61            drag: None,
62            tool_drag: None,
63            poly: Vec::new(),
64            point_drag: None,
65            moved_at: None,
66            transport: Rect::ZERO,
67        }
68    }
69}
70
71pub struct PreviewCtx<'a> {
72    pub project: &'a mut Project,
73    pub selection: &'a [Id],
74    pub playhead: f64,
75    pub playing: bool,
76    /// Video only: no transport bar, no selection overlay / drag.
77    pub fullscreen: bool,
78    pub palette: &'a Palette,
79    pub undo: &'a mut dyn FnMut(&Project),
80    /// A newly rendered frame to upload this update (None = keep the current texture).
81    pub frame: Option<Arc<Frame>>,
82    /// A texture the GPU renderer already holds, with its pixel size — painted directly, with no
83    /// readback and no upload. Takes precedence over `frame`.
84    pub gpu_texture: Option<(egui::TextureId, [u32; 2])>,
85    /// Active editing tool (`Tool::Select` = drag moves the selected clip).
86    pub tool: Tool,
87    /// Current preview render scale in percent (settings.preview_quality).
88    pub quality: u32,
89    /// Current movie mode (settings.movie_mode).
90    pub movie_mode: bool,
91    /// Pre-render progress 0..1 while frames are being cached (None = not pre-rendering).
92    pub prerender: Option<f32>,
93    /// Playback is held while the player refills its read-ahead: draw a spinner over the video.
94    pub buffering: bool,
95    /// Progress 0..1 of the proxy build in flight (None = no proxy being built).
96    pub proxy: Option<f32>,
97    /// Tracker box of the Tracking pane (centre + half sizes, project px relative to the canvas
98    /// centre) while that pane is on screen — drawn here and dragged to place the template.
99    pub tracker: Option<(f32, f32, f32, f32)>,
100}
101
102#[derive(Default)]
103pub struct PreviewResponse {
104    pub seek: Option<f64>,
105    /// Transport buttons map straight to actions (PlayPause, Stop, StepBack, …, MarkIn, ClearInOut).
106    pub actions: Vec<Action>,
107    pub edited: bool,
108    /// Pixel size available for the video image (for Player::set_canvas).
109    pub canvas: (u32, u32),
110    /// The user picked another preview quality (percent) — the app stores it in Settings.
111    pub set_quality: Option<u32>,
112    /// The user toggled Movie mode.
113    pub set_movie_mode: Option<bool>,
114    /// A shape dragged out with a shape tool: (kind, centre x, centre y, half width, half height) in
115    /// project pixels relative to the canvas centre (same frame as `Clip.x/y` and `ShapeStyle.w/h`).
116    pub new_shape: Option<(ShapeKind, f32, f32, f32, f32)>,
117    /// Vertices of a closed Polygon path, relative to that shape's centre (empty = a regular n-gon).
118    pub new_points: Vec<(f32, f32)>,
119    /// A stroke recorded with the Draw tool (points relative to the canvas centre, timed from the press).
120    pub stroke: Option<ModelStroke>,
121    /// The selected clip's mask was edited with a mask tool.
122    pub mask_edit: bool,
123    /// The tracker box was dragged to this centre (project px relative to the canvas centre).
124    pub set_tracker: Option<(f32, f32)>,
125}
126
127pub fn show(ui: &mut egui::Ui, state: &mut PreviewState, mut c: PreviewCtx<'_>) -> PreviewResponse {
128    let mut r = PreviewResponse::default();
129    if c.fullscreen {
130        video(ui, state, &mut c, &mut r);
131        // transport overlay: summoned by mouse movement, gone ~2 s after the mouse stops.
132        // `moved_at` is pointer-only (see video()), so spacebar play/pause never reveals the bar.
133        if state.moved_at.is_some_and(|t| t.elapsed().as_secs_f32() < 2.0) {
134            egui::Area::new(ui.id().with("fs_transport"))
135                .anchor(egui::Align2::CENTER_BOTTOM, vec2(0.0, -24.0))
136                .order(egui::Order::Foreground)
137                .show(ui.ctx(), |ui| {
138                    egui::Frame::popup(ui.style()).fill(c.palette.panel.gamma_multiply(0.92)).show(ui, |ui| {
139                        transport(ui, state, &c, &mut r);
140                        // hovering the bar keeps it alive past the 2 s fade
141                        if ui.ui_contains_pointer() {
142                            state.moved_at = Some(std::time::Instant::now());
143                        }
144                    });
145                });
146        }
147        return r;
148    }
149    ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
150        transport(ui, state, &c, &mut r);
151        video(ui, state, &mut c, &mut r);
152    });
153    r
154}
155
156fn transport(ui: &mut egui::Ui, state: &mut PreviewState, c: &PreviewCtx<'_>, r: &mut PreviewResponse) {
157    let fps = c.project.fps;
158    // centred: pad by half the leftover of last frame's measured width (0 on the very first frame)
159    let pad = ((ui.available_width() - state.transport.width()) * 0.5).max(0.0);
160    let row = ui
161        .horizontal(|ui| {
162            ui.spacing_mut().item_spacing.x = 2.0;
163            ui.add_space(pad);
164            // `label` is the tooltip for an icon button and the caption for a text one
165            let mut b = |ui: &mut egui::Ui, icon: Option<Glyph>, label: &str, a: Action| {
166                let hit = match icon {
167                    Some(g) => glyph_text_button(ui, g, "").on_hover_text(label),
168                    None => ui.button(label),
169                };
170                if hit.clicked() {
171                    r.actions.push(a);
172                }
173            };
174            b(ui, Some(Glyph::Jump(Dir::Left)), "Go to start", Action::GoStart);
175            b(ui, Some(Glyph::Skip(Dir::Left)), "Previous cut", Action::PrevCut);
176            b(ui, Some(Glyph::Tri(Dir::Left)), "Step back one frame", Action::StepBack);
177            let (pp, pp_tip) = if c.playing { (Glyph::Pause, "Pause") } else { (Glyph::Play, "Play") };
178            b(ui, Some(pp), pp_tip, Action::PlayPause);
179            b(ui, Some(Glyph::Stop), "Stop", Action::Stop);
180            b(ui, Some(Glyph::Tri(Dir::Right)), "Step forward one frame", Action::StepForward);
181            b(ui, Some(Glyph::Skip(Dir::Right)), "Next cut", Action::NextCut);
182            b(ui, Some(Glyph::Jump(Dir::Right)), "Go to end", Action::GoEnd);
183            ui.add_space(8.0);
184            ui.monospace(format!("{} / {}", timecode(c.playhead, fps), timecode(c.project.duration(), fps)));
185            ui.add_space(8.0);
186            b(ui, None, "In", Action::MarkIn);
187            b(ui, None, "Out", Action::MarkOut);
188            b(ui, None, "Clear", Action::ClearInOut);
189            if c.project.in_point.is_some() || c.project.out_point.is_some() {
190                ui.add_space(4.0);
191                let tc = |t: Option<f64>| t.map(|t| timecode(t, fps)).unwrap_or_else(|| "-".into());
192                ui.monospace(format!("{} → {}", tc(c.project.in_point), tc(c.project.out_point)));
193            }
194            ui.add_space(8.0);
195            let cur = QUALITIES.iter().copied().find(|&q| q == c.quality).unwrap_or(100);
196            egui::ComboBox::from_id_salt("preview_quality")
197                .selected_text(format!("{cur} %"))
198                .width(64.0)
199                .show_ui(ui, |ui| {
200                    for q in QUALITIES {
201                        if ui.selectable_label(cur == q, format!("{q} %")).clicked() && q != cur {
202                            r.set_quality = Some(q);
203                        }
204                    }
205                })
206                .response
207                .on_hover_text("Preview render scale");
208            // film strip: movie mode plays pre-rendered frames
209            if crate::ui::tools::icon_button(
210                ui,
211                c.palette,
212                ui.id().with("movie"),
213                Glyph::FilmStrip,
214                "Movie mode: play pre-rendered full-quality frames",
215                c.movie_mode,
216            )
217            .clicked()
218            {
219                r.set_movie_mode = Some(!c.movie_mode);
220            }
221            b(ui, Some(Glyph::Fullscreen), "Fullscreen", Action::Fullscreen);
222        })
223        .response;
224    // measured content width (without the centring pad) drives the next frame's padding
225    state.transport = Rect::from_min_max(pos2(row.rect.left() + pad, row.rect.top()), row.rect.max);
226}
227
228/// Keep `v`'s direction while forcing at least `min` of length, so a zero-length drag still makes a
229/// visible shape without flipping which way it points.
230fn signed_min(v: f32, min: f32) -> f32 {
231    if v < 0.0 {
232        v.min(-min)
233    } else {
234        v.max(min)
235    }
236}
237
238/// Shift locks the shape's aspect (square / circle / 45°-stepped line); Alt anchors it on the press point
239/// instead of corner-to-corner, so it grows symmetrically from where the drag started. Returns the two
240/// corners `draw_shape_preview` (and the final shape) should use in place of the raw press/cursor, so the
241/// live preview and what actually gets created always agree.
242fn constrain_drag(kind: ShapeKind, from: Pos2, to: Pos2, modifiers: egui::Modifiers) -> (Pos2, Pos2) {
243    let mut delta = to - from;
244    if modifiers.shift {
245        if matches!(kind, ShapeKind::Line | ShapeKind::Arrow) {
246            let len = delta.length();
247            let step = std::f32::consts::FRAC_PI_4;
248            let angle = (delta.y.atan2(delta.x) / step).round() * step;
249            delta = vec2(angle.cos(), angle.sin()) * len;
250        } else {
251            let m = delta.x.abs().max(delta.y.abs());
252            delta = vec2(m.copysign(delta.x), m.copysign(delta.y));
253        }
254    }
255    if modifiers.alt {
256        (from - delta, from + delta)
257    } else {
258        (from, from + delta)
259    }
260}
261
262/// Outline of the shape a drag is creating: `from` is where the press landed, `to` is the cursor.
263/// Bounded shapes fill the rectangle between them; a line or arrow runs from one to the other, so it
264/// must not be handed a normalised rect (its corners would point the wrong way down two of the four
265/// diagonals).
266fn draw_shape_preview(p: &egui::Painter, kind: ShapeKind, from: Pos2, to: Pos2, palette: &Palette, stroke: Stroke) {
267    let fill = palette.selection.gamma_multiply(0.25);
268    let r = Rect::from_two_pos(from, to);
269    let c = r.center();
270    let (hw, hh) = (r.width() / 2.0, r.height() / 2.0);
271    let poly = |n: u32, rot: f32| -> Vec<Pos2> {
272        (0..n)
273            .map(|i| {
274                let a = rot + std::f32::consts::TAU * i as f32 / n as f32;
275                pos2(c.x + a.cos() * hw, c.y + a.sin() * hh)
276            })
277            .collect()
278    };
279    let top = -std::f32::consts::FRAC_PI_2;
280    match kind {
281        ShapeKind::Rect => {
282            p.rect(r, 0.0, fill, stroke, StrokeKind::Inside);
283        }
284        ShapeKind::Ellipse => {
285            let pts = poly(48, 0.0);
286            p.add(Shape::convex_polygon(pts.clone(), fill, Stroke::NONE));
287            p.add(Shape::closed_line(pts, stroke));
288        }
289        ShapeKind::Triangle | ShapeKind::Polygon | ShapeKind::Star => {
290            let pts = if kind == ShapeKind::Star {
291                let (o, i) = (poly(5, top), poly(5, top + std::f32::consts::TAU / 10.0));
292                (0..5).flat_map(|k| [o[k], pos2(c.x + (i[k].x - c.x) * 0.45, c.y + (i[k].y - c.y) * 0.45)]).collect()
293            } else {
294                poly(if kind == ShapeKind::Triangle { 3 } else { 5 }, top)
295            };
296            p.add(Shape::convex_polygon(pts.clone(), fill, Stroke::NONE));
297            p.add(Shape::closed_line(pts, stroke));
298        }
299        ShapeKind::Line | ShapeKind::Arrow => {
300            let (a, b) = (from, to);
301            p.line_segment([a, b], stroke);
302            if kind == ShapeKind::Arrow {
303                let v = b - a;
304                let len = v.length().max(1.0);
305                let (ux, uy) = (v.x / len, v.y / len);
306                let head = (len * 0.25).clamp(6.0, 40.0);
307                let base = pos2(b.x - ux * head, b.y - uy * head);
308                let (px, py) = (-uy * head * 0.5, ux * head * 0.5);
309                p.add(Shape::convex_polygon(
310                    vec![b, pos2(base.x + px, base.y + py), pos2(base.x - px, base.y - py)],
311                    stroke.color,
312                    Stroke::NONE,
313                ));
314            }
315        }
316        ShapeKind::Draw => {
317            p.rect_stroke(r, 0.0, stroke, StrokeKind::Inside);
318        }
319    }
320}
321
322/// Largest `aspect` rect centred in `area`, edges snapped to whole physical pixels.
323pub(crate) fn letterbox(area: Rect, aspect: f32, ppp: f32) -> Rect {
324    let (aw, ah) = (area.width(), area.height());
325    let (w, h) = if aw / ah > aspect { (ah * aspect, ah) } else { (aw, aw / aspect) };
326    let snap = |v: f32| (v * ppp).round() / ppp;
327    let min = pos2(snap(area.min.x + (aw - w) / 2.0), snap(area.min.y + (ah - h) / 2.0));
328    Rect::from_min_size(min, vec2(snap(w), snap(h)))
329}
330
331/// Drag with a shape / draw / mask tool. Returns true when the tool consumed the gesture (so the clip
332/// must not be moved). Never panics without a selection: a shape or a stroke is reported regardless and
333/// the app decides what to create.
334fn tool_drag(
335    ui: &egui::Ui,
336    state: &mut PreviewState,
337    c: &mut PreviewCtx<'_>,
338    r: &mut PreviewResponse,
339    resp: &egui::Response,
340    lb: Rect,
341) -> bool {
342    let tool = c.tool;
343    if tool == Tool::Select {
344        state.tool_drag = None;
345        return false;
346    }
347    // screen point -> project px relative to the canvas centre
348    let k = c.project.width as f32 / lb.width().max(1.0);
349    let (hw, hh) = (c.project.width as f32 / 2.0, c.project.height as f32 / 2.0);
350    let to_proj = move |p: Pos2| ((p.x - lb.min.x) * k - hw, (p.y - lb.min.y) * k - hh);
351    let to_screen = move |&(x, y): &(f32, f32)| pos2(lb.min.x + (x + hw) / k, lb.min.y + (y + hh) / k);
352
353    // the Polygon tool places real vertices: click to append, click a placed one / Enter to close
354    if tool == Tool::Shape(ShapeKind::Polygon) {
355        let at = resp.interact_pointer_pos();
356        // clicking a vertex that is already placed closes the path instead of stacking a duplicate on
357        // top of it — that is where a double-click's second press lands, and egui cannot be asked
358        // (quick clicks at different points read as double/triple clicks while you place vertices)
359        let on_vertex = at.is_some_and(|p| state.poly.iter().any(|q| (to_screen(q) - p).length() <= 6.0));
360        if (resp.clicked() || resp.drag_stopped()) && !on_vertex {
361            if let Some(p) = at {
362                state.poly.push(to_proj(p));
363            }
364        }
365        let p = ui.painter_at(lb);
366        let stroke = Stroke::new(1.5, c.palette.selection);
367        let mut pts: Vec<Pos2> = state.poly.iter().map(to_screen).collect();
368        if !pts.is_empty() {
369            for q in &pts {
370                p.circle_filled(*q, 3.5, c.palette.selection);
371            }
372            // rubber band from the last vertex to the cursor, and a hint of the closing edge
373            if let Some(at) = resp.hover_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())) {
374                p.line_segment([pts[0], at], Stroke::new(1.0, c.palette.selection.gamma_multiply(0.5)));
375                pts.push(at);
376            }
377            p.add(Shape::line(pts, stroke));
378        }
379        if (on_vertex && (resp.clicked() || resp.double_clicked())) || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
380            if state.poly.len() >= 3 {
381                let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
382                for &(x, y) in &state.poly {
383                    (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
384                }
385                let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
386                r.new_points = state.poly.iter().map(|&(x, y)| (x - cx, y - cy)).collect();
387                r.new_shape =
388                    Some((ShapeKind::Polygon, cx, cy, ((x1 - x0) / 2.0).max(2.0), ((y1 - y0) / 2.0).max(2.0)));
389            }
390            state.poly.clear();
391        }
392        return true;
393    }
394
395    // a mask shapes pixels: an audio clip has none, so the mask tool finds no target on one and the
396    // gesture is a no-op (no mask, and no undo entry for an edit that never happened)
397    let mask_target = c.selection.iter().copied().find(|&id| c.project.clip(id).is_some_and(|cl| cl.is_visual()));
398
399    if resp.drag_started() {
400        if let Some(p) = resp.interact_pointer_pos() {
401            let t0 = ui.input(|i| i.time);
402            state.tool_drag = Some(ToolDrag { from: p, t0, points: Vec::new() });
403            if matches!(tool, Tool::Mask(_)) && mask_target.is_some() {
404                (c.undo)(c.project);
405            }
406        }
407    }
408    let Some(d) = &mut state.tool_drag else { return !matches!(tool, Tool::Zoom | Tool::Text) };
409    let now = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).unwrap_or(d.from);
410
411    if resp.dragged() {
412        match tool {
413            Tool::Draw => {
414                let (x, y) = to_proj(now);
415                let t = (ui.input(|i| i.time) - d.t0).max(0.0) as f32;
416                // skip points closer than a project pixel: a still mouse would otherwise flood the stroke
417                if d.points.last().is_none_or(|&(px, py, _)| (px - x).abs() + (py - y).abs() > 1.0) {
418                    d.points.push((x, y, t));
419                }
420            }
421            Tool::Mask(shape) => {
422                let (x0, y0) = to_proj(d.from);
423                let (x1, y1) = to_proj(now);
424                if let Some(clip) = mask_target.and_then(|id| c.project.clip_mut(id)) {
425                    let m = clip.mask.get_or_insert_with(|| Mask::new(shape));
426                    m.shape = shape;
427                    m.cx.value = ((x0 + x1) / 2.0) as f64;
428                    m.cy.value = ((y0 + y1) / 2.0) as f64;
429                    m.rx.value = ((x1 - x0).abs() / 2.0).max(1.0) as f64;
430                    m.ry.value = ((y1 - y0).abs() / 2.0).max(1.0) as f64;
431                    if matches!(shape, MaskShape::Polygon | MaskShape::Path) {
432                        // those shapes are rasterised from `points`; fewer than 3 vertices reads as
433                        // "outside everywhere" and the clip would vanish, so the drag rect seeds them
434                        m.points.clear();
435                        crate::ui::seed_mask_points(m);
436                    }
437                    r.mask_edit = true;
438                    r.edited = true;
439                }
440            }
441            _ => {}
442        }
443        // rubber band / ink preview
444        let p = ui.painter_at(lb);
445        let stroke = Stroke::new(1.0, c.palette.selection);
446        match tool {
447            Tool::Draw => {
448                let pts: Vec<Pos2> =
449                    d.points.iter().map(|&(x, y, _)| pos2(lb.min.x + (x + hw) / k, lb.min.y + (y + hh) / k)).collect();
450                if pts.len() > 1 {
451                    p.add(Shape::line(pts, Stroke::new(2.0, c.palette.selection)));
452                }
453            }
454            // draw the shape itself, not a box around it, so what you drag is what you get
455            // `from`/`now` raw, not a normalised rect: a line runs press -> cursor, and only a bounded
456            // shape wants its corners sorted (see draw_shape_preview); Shift/Alt bend those corners first
457            // (constrain_drag) so the preview matches what drag_stopped below will actually create.
458            Tool::Shape(kind) => {
459                let modifiers = ui.input(|i| i.modifiers);
460                let (from, to) = constrain_drag(kind, d.from, now, modifiers);
461                draw_shape_preview(&p, kind, from, to, c.palette, stroke);
462            }
463            _ => {
464                p.rect_stroke(Rect::from_two_pos(d.from, now), 0.0, stroke, StrokeKind::Inside);
465            }
466        }
467    }
468
469    if resp.drag_stopped() {
470        let (x0, y0) = to_proj(d.from);
471        let (x1, y1) = to_proj(now);
472        match tool {
473            Tool::Shape(kind) => {
474                // Shift/Alt bend the two corners first (constrain_drag), same as the live preview above.
475                // Line/Arrow run from (-w, -h) to (+w, +h) about their centre (engine::shapes), so the
476                // half-extents stay SIGNED and the drag's direction survives; a dragged-up-right line
477                // used to come out pointing down-right because both were made absolute here.
478                let modifiers = ui.input(|i| i.modifiers);
479                let (p0, p1) = constrain_drag(kind, pos2(x0, y0), pos2(x1, y1), modifiers);
480                let (dx, dy) = ((p1.x - p0.x) / 2.0, (p1.y - p0.y) / 2.0);
481                let (w, h) = match kind {
482                    ShapeKind::Line | ShapeKind::Arrow => (signed_min(dx, 2.0), signed_min(dy, 2.0)),
483                    _ => (dx.abs().max(2.0), dy.abs().max(2.0)),
484                };
485                r.new_shape = Some((kind, (p0.x + p1.x) / 2.0, (p0.y + p1.y) / 2.0, w, h));
486            }
487            Tool::Draw if d.points.len() > 1 => {
488                r.stroke = Some(ModelStroke { color: [255, 255, 255, 255], width: 6.0, points: d.points.clone() });
489            }
490            _ => {}
491        }
492        state.tool_drag = None;
493    }
494    true
495}
496
497fn video(ui: &mut egui::Ui, state: &mut PreviewState, c: &mut PreviewCtx<'_>, r: &mut PreviewResponse) {
498    let (rect, resp) = ui.allocate_exact_size(ui.available_size_before_wrap(), Sense::click_and_drag());
499    let painter = ui.painter_at(rect);
500    painter.rect_filled(rect, 0.0, Color32::BLACK);
501    let ppp = ui.pixels_per_point();
502    let aspect = c.project.width.max(1) as f32 / c.project.height.max(1) as f32;
503    let lb = letterbox(rect, aspect, ppp);
504    let (cw, ch) = ((lb.width() * ppp).round().max(16.0) as u32, (lb.height() * ppp).round().max(16.0) as u32);
505    r.canvas = (cw, ch);
506
507    if let Some(f) = &c.frame {
508        let (w, h) = (f.width as usize, f.height as usize);
509        if w > 0 && h > 0 && f.rgba.len() == w * h * 4 {
510            let img = egui::ColorImage::from_rgba_premultiplied([w, h], &f.rgba);
511            match &mut state.texture {
512                // same size: sub-image upload (tex_sub_image_2d) instead of re-specifying the texture
513                Some(t) if t.size() == [w, h] => t.set_partial([0, 0], img, TextureOptions::LINEAR),
514                Some(t) => t.set(img, TextureOptions::LINEAR),
515                None => state.texture = Some(ui.ctx().load_texture("preview", img, TextureOptions::LINEAR)),
516            }
517        }
518    }
519    // the GPU renderer's own texture wins: nothing is read back or re-uploaded
520    if let Some((id, _)) = c.gpu_texture {
521        painter.image(id, lb, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
522    } else if let Some(t) = &state.texture {
523        painter.image(t.id(), lb, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
524    }
525    // buffering: the clock is held while the read-ahead refills — say so over the video
526    if c.buffering {
527        ui.put(Rect::from_center_size(lb.center(), vec2(32.0, 32.0)), egui::Spinner::new().size(32.0));
528        ui.ctx().request_repaint_after(std::time::Duration::from_millis(50));
529    }
530    if c.fullscreen {
531        // double-click toggles fullscreen, like every player
532        if resp.double_clicked() {
533            r.actions.push(Action::Fullscreen);
534        }
535        // hide the cursor after 2 s without movement
536        let moved = ui.input(|i| i.pointer.delta() != Vec2::ZERO || i.pointer.any_down());
537        if moved || state.moved_at.is_none() {
538            state.moved_at = Some(std::time::Instant::now());
539        }
540        if state.moved_at.map(|t| t.elapsed().as_secs_f32() > 2.0).unwrap_or(false) {
541            ui.ctx().set_cursor_icon(egui::CursorIcon::None);
542        } else {
543            ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
544        }
545        state.drag = None;
546        return;
547    }
548    state.moved_at = None;
549    prerender_badge(ui, &painter, lb, c);
550
551    // a tool other than Select owns the gesture (draw / mask / shape) — never move the clip then, and
552    // never let the polygon tool's closing double-click also toggle fullscreen
553    if tool_drag(ui, state, c, r, &resp, lb) {
554        state.drag = None;
555        return;
556    }
557    if resp.double_clicked() {
558        r.actions.push(Action::Fullscreen);
559    }
560
561    // the Tracking pane's box: drawn over the video and dragged to place the template. A press that
562    // started inside it owns the gesture, so the clip underneath is not moved as well.
563    // ponytail: the drag centres the box on the pointer instead of keeping the grab offset — keeping it
564    // needs the offset stored in PreviewState, and "put the tracker here" is what the gesture means.
565    if let Some((tcx, tcy, thw, thh)) = c.tracker {
566        let k = lb.width() / c.project.width.max(1) as f32; // points per project px
567        let ctr = lb.center();
568        let bx = Rect::from_center_size(ctr + vec2(tcx * k, tcy * k), vec2(thw * 2.0 * k, thh * 2.0 * k));
569        let st = Stroke::new(1.5, c.palette.accent);
570        painter.rect_stroke(bx, 0.0, st, StrokeKind::Middle);
571        painter.line_segment([bx.center() - vec2(5.0, 0.0), bx.center() + vec2(5.0, 0.0)], st);
572        painter.line_segment([bx.center() - vec2(0.0, 5.0), bx.center() + vec2(0.0, 5.0)], st);
573        if ui.input(|i| i.pointer.press_origin()).is_some_and(|p| bx.contains(p)) && resp.dragged() {
574            if let Some(p) = resp.interact_pointer_pos() {
575                r.set_tracker = Some(((p.x - ctr.x) / k, (p.y - ctr.y) / k));
576            }
577            state.drag = None;
578            return;
579        }
580    }
581
582    // selection overlay + drag-to-move
583    let Some(clip) = c
584        .selection
585        .iter()
586        .filter_map(|&id| c.project.clip(id))
587        .find(|cl| cl.is_visual() && cl.enabled && cl.contains(c.playhead))
588    else {
589        state.drag = None;
590        return;
591    };
592    let id = clip.id;
593    let lt = clip.local(c.playhead);
594    let to_screen =
595        |x: f32, y: f32| pos2(lb.min.x + x / cw as f32 * lb.width(), lb.min.y + y / ch as f32 * lb.height());
596    let stroke = Stroke::new(1.5, c.palette.selection);
597    if clip.kind == ClipKind::Text {
598        let p = placement(c.project, clip, c.playhead, (1, 1), cw, ch, false);
599        let o = to_screen(p.cx, p.cy);
600        painter.line_segment([o - vec2(6.0, 0.0), o + vec2(6.0, 0.0)], stroke);
601        painter.line_segment([o - vec2(0.0, 6.0), o + vec2(0.0, 6.0)], stroke);
602    } else if let Some(a) = c.project.asset(clip.asset) {
603        let p = placement(c.project, clip, c.playhead, (a.width, a.height), cw, ch, true);
604        let (s, co) = p.rot.to_radians().sin_cos();
605        let (hw, hh) = (p.w / 2.0, p.h / 2.0);
606        let pts = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)]
607            .iter()
608            .map(|&(x, y)| to_screen(p.cx + x * co - y * s, p.cy + x * s + y * co))
609            .collect();
610        painter.add(Shape::closed_line(pts, stroke));
611    }
612
613    // explicit polygon vertices: outline + one grab handle each, in the shape's own rotated/scaled frame
614    let poly: Vec<(f32, f32)> =
615        clip.shape.as_ref().and_then(|s| s.poly_points()).map(|p| p.to_vec()).unwrap_or_default();
616    let frame = (!poly.is_empty()).then(|| {
617        let p = placement(c.project, clip, c.playhead, (1, 1), cw, ch, false);
618        let (sn, cs) = p.rot.to_radians().sin_cos();
619        // project px -> screen points, through the clip's scale
620        let k = clip.scale.at(lt) as f32 * lb.width() / c.project.width.max(1) as f32;
621        (to_screen(p.cx, p.cy), sn, cs, if k.is_finite() && k.abs() > 1e-4 { k } else { 1e-4 })
622    });
623    // the grab is decided by where the press began, not by where the pointer has dragged to
624    let press = ui.input(|i| i.pointer.press_origin());
625    let mut hit = None;
626    if let Some((o, sn, cs, k)) = frame {
627        let scr: Vec<Pos2> =
628            poly.iter().map(|&(x, y)| pos2(o.x + (x * cs - y * sn) * k, o.y + (x * sn + y * cs) * k)).collect();
629        painter.add(Shape::closed_line(scr.clone(), stroke));
630        for (i, q) in scr.iter().enumerate() {
631            painter.circle_filled(*q, 3.5, c.palette.selection);
632            if press.is_some_and(|pp| (pp - *q).length() <= 7.0) {
633                hit = Some(i);
634            }
635        }
636    }
637
638    if resp.drag_started() {
639        (c.undo)(c.project);
640        state.point_drag = hit;
641        state.drag = hit.is_none().then(|| (clip.x.at(lt), clip.y.at(lt), Vec2::ZERO));
642    }
643    if resp.dragged() {
644        if let (Some(i), Some((o, sn, cs, k)), Some(pp)) = (state.point_drag, frame, resp.interact_pointer_pos()) {
645            // pointer -> the shape's own frame (undo the rotation and scale the handles were drawn with)
646            let (dx, dy) = ((pp.x - o.x) / k, (pp.y - o.y) / k);
647            if let Some(p) = c.project.clip_mut(id).and_then(|cl| cl.shape.as_mut()).and_then(|s| s.points.get_mut(i)) {
648                *p = (dx * cs + dy * sn, -dx * sn + dy * cs);
649                r.edited = true;
650            }
651        } else if let Some((x0, y0, acc)) = &mut state.drag {
652            *acc += resp.drag_delta();
653            let k = c.project.width as f32 / lb.width(); // project px per point
654            let (nx, ny) = (*x0 + (acc.x * k) as f64, *y0 + (acc.y * k) as f64);
655            if let Some(cl) = c.project.clip_mut(id) {
656                cl.x.set_at(lt, nx);
657                cl.y.set_at(lt, ny);
658                r.edited = true;
659            }
660        }
661    }
662    if resp.drag_stopped() {
663        state.drag = None;
664        state.point_drag = None;
665    }
666}
667
668/// Small "Pre-rendering NN %" / "Building proxy NN %" badge in the top-left of the video.
669fn prerender_badge(ui: &egui::Ui, painter: &egui::Painter, lb: Rect, c: &PreviewCtx<'_>) {
670    let (p, label) = match (c.prerender, c.proxy) {
671        (Some(p), _) => (p, "Pre-rendering"),
672        (None, Some(p)) => (p, "Building proxy"),
673        (None, None) => return,
674    };
675    let text = format!("{label} {:.0} %", (p.clamp(0.0, 1.0) * 100.0));
676    let font = egui::TextStyle::Small.resolve(ui.style());
677    let galley = painter.layout_no_wrap(text, font, c.palette.text);
678    let pad = vec2(6.0, 3.0);
679    let at = lb.min + vec2(6.0, 6.0);
680    let bg = Rect::from_min_size(at, galley.size() + pad * 2.0);
681    painter.rect_filled(bg, 3.0, c.palette.header.gamma_multiply(0.9));
682    painter.rect_stroke(bg, 3.0, Stroke::new(1.0, c.palette.accent), StrokeKind::Inside);
683    painter.galley(at + pad, galley, c.palette.text);
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use crate::model::{Clip, MaskShape};
690    use eframe::egui::{Event, Modifiers, PointerButton, RawInput};
691
692    #[test]
693    fn letterbox_fits_and_centres() {
694        let area = Rect::from_min_size(pos2(10.0, 20.0), vec2(400.0, 300.0));
695        // wide video in a 4:3 area → full width, bars top/bottom
696        let lb = letterbox(area, 16.0 / 9.0, 1.0);
697        assert_eq!(lb.width(), 400.0);
698        assert_eq!(lb.height(), 225.0);
699        assert!((lb.center() - area.center()).length() <= 0.5);
700        // tall video → full height, bars left/right
701        let lb = letterbox(area, 0.5, 1.0);
702        assert_eq!(lb.height(), 300.0);
703        assert_eq!(lb.width(), 150.0);
704        assert!((lb.center() - area.center()).length() <= 0.5);
705    }
706
707    #[test]
708    fn letterbox_snaps_to_pixels() {
709        let area = Rect::from_min_size(pos2(0.3, 0.0), vec2(333.3, 200.0));
710        let lb = letterbox(area, 16.0 / 9.0, 2.0);
711        for v in [lb.min.x, lb.min.y, lb.width(), lb.height()] {
712            assert!((v * 2.0 - (v * 2.0).round()).abs() < 1e-3, "{v} not pixel aligned");
713        }
714        assert!(lb.width() <= area.width() + 0.5 && lb.height() <= area.height() + 0.5);
715    }
716
717    struct H {
718        ctx: egui::Context,
719        state: PreviewState,
720        project: Project,
721        selection: Vec<Id>,
722        tool: Tool,
723        undos: usize,
724        time: f64,
725        panel: Rect,
726    }
727
728    impl H {
729        fn new() -> Self {
730            let mut project = Project::new();
731            project.tracks[0].clips.push(Clip::new(7, ClipKind::Video, "v", 0.0, 4.0));
732            Self {
733                ctx: egui::Context::default(),
734                state: PreviewState::default(),
735                project,
736                selection: vec![7],
737                tool: Tool::Select,
738                undos: 0,
739                time: 0.0,
740                panel: Rect::from_min_size(Pos2::ZERO, vec2(700.0, 460.0)),
741            }
742        }
743        fn frame(&mut self, events: Vec<Event>) -> PreviewResponse {
744            self.frame_mod(events, Modifiers::NONE)
745        }
746        fn frame_mod(&mut self, events: Vec<Event>, modifiers: Modifiers) -> PreviewResponse {
747            self.time += 0.05;
748            let input = RawInput {
749                screen_rect: Some(self.panel),
750                time: Some(self.time),
751                events,
752                modifiers,
753                ..Default::default()
754            };
755            let pal = Palette::new(true, Color32::WHITE);
756            let H { ctx, state, project, selection, tool, undos, .. } = self;
757            let mut out = PreviewResponse::default();
758            let _ = ctx.run(input, |ctx| {
759                egui::CentralPanel::default().show(ctx, |ui| {
760                    let mut undo = |_: &Project| *undos += 1;
761                    out = show(
762                        ui,
763                        state,
764                        PreviewCtx {
765                            project,
766                            selection,
767                            playhead: 1.0,
768                            playing: false,
769                            fullscreen: false,
770                            palette: &pal,
771                            undo: &mut undo,
772                            frame: None,
773                            gpu_texture: None,
774                            tool: *tool,
775                            quality: 100,
776                            movie_mode: false,
777                            prerender: Some(0.4),
778                            buffering: false,
779                            proxy: None,
780                            tracker: None,
781                        },
782                    );
783                });
784            });
785            out
786        }
787        fn click(&mut self, at: Pos2) -> PreviewResponse {
788            self.frame(vec![Event::PointerMoved(at)]);
789            let btn = |pressed| Event::PointerButton {
790                pos: at,
791                button: PointerButton::Primary,
792                pressed,
793                modifiers: Modifiers::NONE,
794            };
795            self.frame(vec![btn(true)]);
796            self.frame(vec![btn(false)])
797        }
798        fn drag(&mut self, from: Pos2, to: Pos2) -> PreviewResponse {
799            self.drag_mod(from, to, Modifiers::NONE)
800        }
801        /// Same gesture, with `modifiers` held down for the whole drag (Shift / Alt shape constraints).
802        fn drag_mod(&mut self, from: Pos2, to: Pos2, modifiers: Modifiers) -> PreviewResponse {
803            self.frame_mod(vec![Event::PointerMoved(from)], modifiers);
804            self.frame_mod(
805                vec![Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }],
806                modifiers,
807            );
808            let steps = 4;
809            let (mut mask_edit, mut edited) = (false, false);
810            for i in 1..=steps {
811                let p = from + (to - from) * (i as f32 / steps as f32);
812                let f = self.frame_mod(vec![Event::PointerMoved(p)], modifiers);
813                mask_edit |= f.mask_edit;
814                edited |= f.edited;
815            }
816            let mut out = self.frame_mod(
817                vec![Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers }],
818                modifiers,
819            );
820            // the flags are per-frame; the test wants the whole gesture
821            out.mask_edit |= mask_edit;
822            out.edited |= edited;
823            out
824        }
825    }
826
827    #[test]
828    fn transport_is_centred_under_the_video() {
829        let mut h = H::new();
830        h.frame(vec![]); // measures
831        h.frame(vec![]); // centres with the measurement
832        let row = h.state.transport;
833        assert!(row.width() > 100.0, "transport measured: {row:?}");
834        let dx = (row.center().x - h.panel.center().x).abs();
835        assert!(dx < 6.0, "transport centre {} vs panel centre {}", row.center().x, h.panel.center().x);
836    }
837
838    #[test]
839    fn shape_tool_drag_reports_a_shape_instead_of_moving_the_clip() {
840        let mut h = H::new();
841        h.tool = Tool::Shape(ShapeKind::Rect);
842        h.frame(vec![]);
843        let out = h.drag(pos2(250.0, 150.0), pos2(400.0, 250.0));
844        let (kind, cx, cy, w, hh) = out.new_shape.expect("a shape was dragged out");
845        assert_eq!(kind, ShapeKind::Rect);
846        assert!(w > 1.0 && hh > 1.0, "non-empty shape: {w} x {hh}");
847        // the drag must not have moved the clip
848        let c = h.project.clip(7).unwrap();
849        assert_eq!((c.x.value, c.y.value), (0.0, 0.0), "shape tool never moves the clip");
850        assert_eq!(h.undos, 0, "no clip undo for a shape gesture");
851        let _ = (cx, cy);
852    }
853
854    /// A line follows the drag in every direction: the half-extents stay signed, so dragging up-right
855    /// makes a line that points up-right instead of snapping to its bounding box's other diagonal.
856    /// One drag must produce exactly ONE shape. If the gesture fired on more than one frame the timeline
857    /// would gain several stacked clips and several undo entries, so a single Ctrl+Z would look dead.
858    #[test]
859    fn a_shape_drag_emits_exactly_one_shape() {
860        let mut h = H::new();
861        h.tool = Tool::Shape(ShapeKind::Line);
862        h.frame(vec![]);
863        let (from, to) = (pos2(250.0, 150.0), pos2(400.0, 80.0));
864        // drive the gesture by hand so every frame's response can be counted
865        h.frame(vec![Event::PointerMoved(from)]);
866        let mut n = 0;
867        let mut count = |r: PreviewResponse| {
868            if r.new_shape.is_some() {
869                n += 1;
870            }
871        };
872        count(h.frame(vec![Event::PointerButton {
873            pos: from,
874            button: PointerButton::Primary,
875            pressed: true,
876            modifiers: Modifiers::NONE,
877        }]));
878        for i in 1..=4 {
879            let p = from + (to - from) * (i as f32 / 4.0);
880            count(h.frame(vec![Event::PointerMoved(p)]));
881        }
882        count(h.frame(vec![Event::PointerButton {
883            pos: to,
884            button: PointerButton::Primary,
885            pressed: false,
886            modifiers: Modifiers::NONE,
887        }]));
888        // a few idle frames after the release: a gesture that never cleared would keep firing
889        for _ in 0..3 {
890            count(h.frame(vec![]));
891        }
892        assert_eq!(n, 1, "one drag, one shape (each extra one is an extra undo step)");
893    }
894
895    #[test]
896    fn line_tool_keeps_the_press_as_its_origin() {
897        // (dx, dy) of the drag -> expected sign of (w, h)
898        for (to, want) in [
899            (pos2(400.0, 250.0), (1.0, 1.0)),  // right + down
900            (pos2(400.0, 50.0), (1.0, -1.0)),  // right + up
901            (pos2(100.0, 250.0), (-1.0, 1.0)), // left  + down
902            (pos2(100.0, 50.0), (-1.0, -1.0)), // left  + up
903        ] {
904            let mut h = H::new();
905            h.tool = Tool::Shape(ShapeKind::Line);
906            h.frame(vec![]);
907            let from = pos2(250.0, 150.0);
908            let out = h.drag(from, to);
909            let (kind, cx, cy, w, hh) = out.new_shape.expect("a line was dragged out");
910            assert_eq!(kind, ShapeKind::Line);
911            assert_eq!(w.signum(), want.0, "w sign for a drag to {to:?}: got {w}");
912            assert_eq!(hh.signum(), want.1, "h sign for a drag to {to:?}: got {hh}");
913            // centre + the signed half-extents reproduce both ends, which is what engine::shapes draws
914            let (x0, y0) = (cx - w, cy - hh);
915            let (x1, y1) = (cx + w, cy + hh);
916            assert!(x1 - x0 != 0.0 && y1 - y0 != 0.0);
917            assert_eq!(((x1 - x0).signum(), (y1 - y0).signum()), want, "the far end must follow the cursor");
918        }
919    }
920
921    #[test]
922    fn shift_locks_a_rect_drag_to_a_square() {
923        let mut h = H::new();
924        h.tool = Tool::Shape(ShapeKind::Rect);
925        h.frame(vec![]);
926        let out = h.drag_mod(pos2(250.0, 150.0), pos2(400.0, 220.0), Modifiers::SHIFT);
927        let (_, _, _, w, hh) = out.new_shape.expect("a shape was dragged out");
928        assert!((w - hh).abs() < 0.01, "Shift must square the drag: {w} x {hh}");
929    }
930
931    #[test]
932    fn shift_locks_a_line_to_45_degrees() {
933        let mut h = H::new();
934        h.tool = Tool::Shape(ShapeKind::Line);
935        h.frame(vec![]);
936        // a drag near 41 degrees should snap to a perfect 45-degree diagonal
937        let out = h.drag_mod(pos2(250.0, 150.0), pos2(400.0, 280.0), Modifiers::SHIFT);
938        let (_, _, _, w, hh) = out.new_shape.expect("a line was dragged out");
939        assert!((w.abs() - hh.abs()).abs() < 0.5, "45 deg: |w| should equal |hh|, got {w} x {hh}");
940    }
941
942    #[test]
943    fn alt_grows_a_shape_from_the_press_point() {
944        let mut h = H::new();
945        h.tool = Tool::Shape(ShapeKind::Rect);
946        h.frame(vec![]);
947        let press = pos2(250.0, 150.0);
948        let plain = h.drag(press, pos2(350.0, 220.0)).new_shape.unwrap();
949        let mut h2 = H::new();
950        h2.tool = Tool::Shape(ShapeKind::Rect);
951        h2.frame(vec![]);
952        let alt = h2.drag_mod(press, pos2(350.0, 220.0), Modifiers::ALT).new_shape.unwrap();
953        // same half-extents doubled, and the centre sits on the press point instead of the midpoint
954        assert!((alt.3 - plain.3 * 2.0).abs() < 0.5, "half-width doubles under Alt: {} vs {}", alt.3, plain.3);
955        assert!((alt.4 - plain.4 * 2.0).abs() < 0.5, "half-height doubles under Alt: {} vs {}", alt.4, plain.4);
956    }
957
958    #[test]
959    fn polygon_tool_places_and_closes_real_points() {
960        let mut h = H::new();
961        h.tool = Tool::Shape(ShapeKind::Polygon);
962        h.frame(vec![]);
963        for p in [pos2(300.0, 120.0), pos2(380.0, 240.0), pos2(220.0, 240.0)] {
964            let out = h.click(p);
965            assert!(out.new_shape.is_none(), "the path is still open");
966        }
967        assert_eq!(h.state.poly.len(), 3, "one vertex per click");
968        let out = h.frame(vec![Event::Key {
969            key: egui::Key::Enter,
970            physical_key: None,
971            pressed: true,
972            repeat: false,
973            modifiers: Modifiers::NONE,
974        }]);
975        let (kind, cx, cy, w, hh) = out.new_shape.expect("Enter closes the path");
976        assert_eq!(kind, ShapeKind::Polygon);
977        assert_eq!(out.new_points.len(), 3);
978        // the points are centred on the reported centre and the half-size is exactly their bounds
979        let mx = out.new_points.iter().fold(0.0f32, |a, p| a.max(p.0.abs()));
980        let my = out.new_points.iter().fold(0.0f32, |a, p| a.max(p.1.abs()));
981        assert!(w > 1.0 && hh > 1.0 && cx.is_finite() && cy.is_finite(), "{:?}", (cx, cy, w, hh));
982        assert!((mx - w).abs() < 0.5 && (my - hh).abs() < 0.5, "{:?} vs {:?}", (mx, my), (w, hh));
983        assert!(h.state.poly.is_empty(), "the in-progress path is consumed");
984        // clicking the last vertex again (a double-click's second press) closes a path too
985        for p in [pos2(200.0, 100.0), pos2(260.0, 100.0), pos2(260.0, 160.0)] {
986            h.click(p);
987        }
988        let out = h.click(pos2(260.0, 160.0));
989        assert_eq!(out.new_points.len(), 3, "a click back on a vertex closes: {:?}", out.new_points);
990    }
991
992    #[test]
993    fn select_tool_drags_a_polygon_vertex() {
994        let mut h = H::new();
995        let id = h.project.add_shape_clip(ShapeKind::Polygon, 0.0, 4.0);
996        let s = h.project.clip_mut(id).unwrap().shape.as_mut().unwrap();
997        s.points = vec![(0.0, 0.0), (200.0, 100.0), (-200.0, 100.0)];
998        h.selection = vec![id];
999        h.frame(vec![]);
1000        // the transport row is measured now, so the video rect (and its centre) is known
1001        h.frame(vec![]);
1002        // the first vertex sits on the shape centre, i.e. the centre of the video
1003        let at = pos2(h.panel.center().x, h.state.transport.top() / 2.0);
1004        let out = h.drag(at, at + vec2(40.0, 20.0));
1005        assert!(out.edited, "dragging a handle edits");
1006        let cl = h.project.clip(id).unwrap();
1007        let p = cl.shape.as_ref().unwrap().points[0];
1008        assert!(p.0 > 10.0 && p.1 > 5.0, "the vertex followed the pointer: {p:?}");
1009        assert_eq!(cl.shape.as_ref().unwrap().points[1], (200.0, 100.0), "the other vertices stay put");
1010        assert_eq!((cl.x.value, cl.y.value), (0.0, 0.0), "grabbing a handle never moves the clip");
1011        assert_eq!(h.undos, 1, "one undo for the gesture");
1012    }
1013
1014    #[test]
1015    fn draw_tool_records_a_timed_stroke() {
1016        let mut h = H::new();
1017        h.tool = Tool::Draw;
1018        h.frame(vec![]);
1019        let out = h.drag(pos2(200.0, 120.0), pos2(430.0, 260.0));
1020        let s = out.stroke.expect("a stroke was recorded");
1021        assert!(s.points.len() > 1, "{} points", s.points.len());
1022        assert!(s.points[0].2 <= s.points[s.points.len() - 1].2, "times increase");
1023    }
1024
1025    #[test]
1026    fn mask_tool_drag_edits_the_selected_clips_mask() {
1027        let mut h = H::new();
1028        h.tool = Tool::Mask(MaskShape::Ellipse);
1029        h.frame(vec![]);
1030        let out = h.drag(pos2(240.0, 140.0), pos2(420.0, 260.0));
1031        assert!(out.mask_edit, "mask edits are reported");
1032        let m = h.project.clip(7).unwrap().mask.clone().expect("mask created");
1033        assert_eq!(m.shape, MaskShape::Ellipse);
1034        assert!(m.rx.value > 1.0 && m.ry.value > 1.0, "{:?}", (m.rx.value, m.ry.value));
1035        assert_eq!(h.undos, 1, "one undo for the mask gesture");
1036    }
1037
1038    /// A mask shapes pixels: dragging one over a selected audio clip does nothing at all.
1039    #[test]
1040    fn mask_tool_drag_ignores_an_audio_clip() {
1041        let mut h = H::new();
1042        h.project.clip_mut(7).unwrap().kind = ClipKind::Audio;
1043        h.tool = Tool::Mask(MaskShape::Ellipse);
1044        h.frame(vec![]);
1045        let out = h.drag(pos2(240.0, 140.0), pos2(420.0, 260.0));
1046        assert!(!out.mask_edit, "nothing was masked");
1047        assert!(h.project.clip(7).unwrap().mask.is_none(), "no mask on an audio clip");
1048        assert_eq!(h.undos, 0, "and no undo entry for the edit that never happened");
1049    }
1050
1051    #[test]
1052    fn polygon_mask_drag_leaves_usable_vertices() {
1053        let mut h = H::new();
1054        h.tool = Tool::Mask(MaskShape::Polygon);
1055        h.frame(vec![]);
1056        h.drag(pos2(240.0, 140.0), pos2(420.0, 260.0));
1057        let m = h.project.clip(7).unwrap().mask.clone().expect("mask created");
1058        assert_eq!(m.shape, MaskShape::Polygon);
1059        // fewer than 3 points rasterises as "outside everywhere" and the clip disappears
1060        assert!(m.points.len() >= 3, "{:?}", m.points);
1061        let xs: Vec<f32> = m.points.iter().map(|p| p.0).collect();
1062        assert!(xs.iter().cloned().fold(f32::MIN, f32::max) > xs.iter().cloned().fold(f32::MAX, f32::min));
1063    }
1064
1065    #[test]
1066    fn select_tool_still_moves_the_clip() {
1067        let mut h = H::new();
1068        h.project.clip_mut(7).unwrap().asset = 0;
1069        h.frame(vec![]);
1070        let out = h.drag(pos2(300.0, 150.0), pos2(360.0, 190.0));
1071        // no asset → no outline, but the drag path still runs; either way nothing panics
1072        let _ = out;
1073    }
1074}