simple_editor\ui/
timeline.rs

1//! Timeline widget (custom painted). Header column (track name, M/S buttons, height drag handle at the
2//! bottom edge), ruler (ticks, in/out markers, click/drag scrubs), track lanes (video tracks top→bottom =
3//! Vn..V1, then A1..An), clips (name, waveform for audio via WaveformCache, keyframe diamonds, disabled
4//! = dimmed, selected = accent outline), playhead (drag by ruler or line).
5//!
6//! Interaction: click select (Ctrl toggles), drag clip body = move (linked clips follow; Project::move_clips
7//! all-or-nothing; up/down across tracks of the same kind), drag clip edges = trim (Clip::trim_start /
8//! trim_end with Project::max_clip_duration), snapping (playhead, clip edges, in/out, 0; ~8 px) when
9//! `snap`, right-click context menu (Split, Delete, Ripple Delete, Link/Unlink, Enable/Disable, Add
10//! Video/Audio Track, Remove Track (on headers), Mute/Solo). Ctrl+Scroll = zoom around the cursor,
11//! Shift+Scroll = horizontal pan, Alt+Scroll = height of the track under the cursor, plain scroll =
12//! vertical pan. Accepts egui dnd payloads `DragPayload` (Asset → Project::insert_asset_clips at the drop
13//! time/track; Path → returned in `dropped_files`; Effect / Transition → the clip under the pointer is
14//! outlined while dragging and reported in `dropped_other`, so a card lands straight on that clip).
15//! Every mutation calls `c.undo(&project)` with the project as
16//! it was before the gesture (once per gesture, only if it changed something) and sets `edited`. While `playing`,
17//! auto-scroll keeps the playhead visible unless the user panned away (resumes once the playhead re-enters the
18//! view or playback pauses).
19//!
20//! Round 2: horizontal/vertical scrollbars (thumb drag + click-to-page), edge auto-scroll during drags,
21//! linked selection (click = link group, Alt+click = single, linked clips get a thin outline), audio
22//! volume line (dB mapped, draggable) + fade handles, video track V/S headers, full-width track resize
23//! handles, draggable keyframe diamonds with an easing context menu, transition bands (edge drag = duration,
24//! right-click = remove), colour labels / sequence tint, speed badges, thumbnail filmstrips (ThumbCache),
25//! auto-cut keep-range shading and Sequence open-on-double-click.
26//!
27//! Round 3: rubber-band select on empty lane space (Shift adds; dragging any selected clip moves the whole
28//! selection as one undo step), drag past the top video / bottom audio row to drop onto a freshly created
29//! track (gutter preview, Esc cancels the gesture), keyframe diamonds in a value lane on tall clips (y =
30//! value, x = time, tooltip, one undo per gesture), project markers on the ruler and clip markers inside
31//! clips (click selects, double-click seeks, drag moves, right-click = Rename / Delete / Set label),
32//! clip colours from `Project.labels`, hatched Adjustment layers with an "adj" badge, and the Add Marker /
33//! Copy & Paste Attributes / Add Mask / Nest / Convert to Adjustment Layer context-menu entries.
34
35use crate::media::thumbs::ThumbCache;
36use crate::media::waveform::{Peaks, WaveformCache};
37use crate::model::{Asset, Clip, ClipKind, Ease, Id, Label, Project, TrackKind};
38use crate::theme::Palette;
39use crate::ui::tools::{draw_glyph, Glyph, Tool};
40
41/// What a header toggle paints: a picture, or a plain character.
42#[derive(Clone, Copy)]
43enum Cap {
44    Icon(Glyph),
45    Text(&'static str),
46}
47use crate::ui::DragPayload;
48use eframe::egui::{
49    self, pos2, vec2, Align2, Color32, CornerRadius, CursorIcon, FontId, Pos2, Rangef, Rect, Sense, Shape, Stroke,
50    StrokeKind,
51};
52use std::path::PathBuf;
53
54const RULER_H: f32 = 22.0;
55/// Height of the subtitle lane pinned under the ruler (shown only when the project has cues).
56const SUB_LANE_H: f32 = 20.0;
57const EDGE_W: f32 = 6.0;
58const HANDLE_H: f32 = 4.0;
59const SNAP_PX: f32 = 8.0;
60/// Scrollbar strip thickness (points).
61const HBAR_H: f32 = 10.0;
62const VBAR_W: f32 = 10.0;
63/// Edge auto-scroll kicks in within this many points of the lanes edge.
64const SCROLL_MARGIN: f32 = 16.0;
65/// Volume line dB range: +12 dB at the top of the clip, -60 dB at the bottom, 0 dB at 70 % height.
66const DB_TOP: f32 = 12.0;
67const DB_BOT: f32 = -60.0;
68const MIN_TRACK_H: f32 = 24.0;
69const MAX_TRACK_H: f32 = 300.0;
70/// Clip height from which keyframe diamonds move into a value lane (y = value) instead of the bottom strip.
71const KEY_LANE_MIN: f32 = 28.0;
72/// Inset of the value lane inside the clip rect (points), top and bottom.
73const KEY_PAD: f32 = 3.0;
74/// Insertion gutter shown when clips are dragged past the first/last row.
75const GUTTER_H: f32 = 5.0;
76/// Marker flag size on the ruler / inside clips.
77const FLAG_W: f32 = 7.0;
78/// (major tick, minor tick) seconds; the first major that spans >= 80 px wins.
79const TICKS: [(f64, f64); 16] = [
80    (0.05, 0.01),
81    (0.1, 0.02),
82    (0.2, 0.05),
83    (0.5, 0.1),
84    (1.0, 0.2),
85    (2.0, 0.5),
86    (5.0, 1.0),
87    (10.0, 2.0),
88    (15.0, 5.0),
89    (30.0, 10.0),
90    (60.0, 15.0),
91    (120.0, 30.0),
92    (300.0, 60.0),
93    (600.0, 120.0),
94    (1800.0, 300.0),
95    (3600.0, 600.0),
96];
97
98pub struct TimelineState {
99    /// Pixels per second.
100    pub zoom: f32,
101    /// Horizontal scroll in seconds at the left edge of the lanes.
102    pub scroll_x: f64,
103    /// Vertical scroll in points.
104    pub scroll_y: f32,
105    /// Width of the header column in points.
106    pub header_w: f32,
107    /// Content rect (lanes area, excluding header & ruler) from the last frame — used by the app for file drops.
108    pub lanes_rect: egui::Rect,
109    /// Active move/trim gesture.
110    drag: Option<Drag>,
111    /// The user panned/zoomed while playing: stop following the playhead until it is back in view.
112    user_panned: bool,
113    /// Rubber band in progress: (press origin, add to the selection instead of replacing it).
114    band: Option<(Pos2, bool)>,
115    /// Marker picked by the last click (drawn highlighted).
116    pub selected_marker: Option<Id>,
117    /// Rename buffer for the marker context menu.
118    rename: Option<(Id, String)>,
119    /// Track under the last press on the lanes — where Ctrl+V pastes.
120    /// ponytail: an index, not an id, so removing a track just makes the next paste land on its neighbour.
121    pub last_track: Option<usize>,
122    /// Subtitle lane height (Alt+scroll over the lane resizes it, like tracks).
123    pub sub_h: f32,
124    /// Selected cues on the subtitle lane (band drag / Ctrl+click) — bulk convert/delete targets.
125    pub sub_sel: Vec<Id>,
126    /// Band-select in progress on the subtitle lane: press-origin time and "Shift held" (add to selection).
127    sub_band: Option<(f64, bool)>,
128    /// Cue edge being trimmed on the subtitle lane: (cue id, right edge?).
129    sub_trim: Option<(Id, bool)>,
130}
131
132impl Default for TimelineState {
133    fn default() -> Self {
134        Self {
135            zoom: 40.0,
136            scroll_x: 0.0,
137            scroll_y: 0.0,
138            header_w: 150.0,
139            lanes_rect: egui::Rect::NOTHING,
140            drag: None,
141            user_panned: false,
142            band: None,
143            selected_marker: None,
144            rename: None,
145            last_track: None,
146            sub_h: SUB_LANE_H,
147            sub_sel: Vec::new(),
148            sub_band: None,
149            sub_trim: None,
150        }
151    }
152}
153
154/// Paste a copied group (relative times, the way `presets::capture_template` stores one) at `at`, with
155/// fresh clip / link / marker ids. `Project::place_clips` picks the first track of each kind with room
156/// (adding one when nothing is free); `target` then pulls the group onto the row the user last clicked,
157/// if the whole group fits there.
158/// The paste flavours, shared by every timeline context menu. The app decides whether the clipboard
159/// actually holds anything — a menu that hid itself when empty would just look broken.
160fn paste_menu(ui: &mut egui::Ui, actions: &mut Vec<crate::hotkeys::Action>) {
161    use crate::hotkeys::Action;
162    for (label, a) in [
163        ("Paste", Action::PasteClips),
164        ("Paste Insert", Action::PasteInsert),
165        ("Paste At Top", Action::PasteAtTop),
166        ("Paste In Place", Action::PasteInPlace),
167    ] {
168        if ui.button(label).clicked() {
169            actions.push(a);
170            ui.close();
171        }
172    }
173}
174
175pub fn paste_clips(p: &mut Project, clips: Vec<Clip>, assets: Vec<Asset>, at: f64, target: Option<usize>) -> Vec<Id> {
176    let ids = p.place_clips(clips, assets, at);
177    if let Some(ti) = target.filter(|&i| i < p.tracks.len()) {
178        let kind = p.tracks[ti].kind;
179        let list = if kind == TrackKind::Video { p.video_tracks() } else { p.audio_tracks() };
180        let row = |t: usize| list.iter().position(|&x| x == t);
181        let from = ids.iter().filter_map(|&id| p.track_of(id)).filter_map(row).min();
182        if let (Some(to), Some(from)) = (row(ti), from) {
183            p.move_clips(&ids, 0.0, to as i32 - from as i32, Some(kind));
184        }
185    }
186    ids
187}
188
189impl TimelineState {
190    /// Timeline time at an absolute x position (points).
191    pub fn time_at(&self, x: f32) -> f64 {
192        self.scroll_x + ((x - self.lanes_rect.left()) / self.zoom) as f64
193    }
194    pub fn x_at(&self, t: f64) -> f32 {
195        self.lanes_rect.left() + ((t - self.scroll_x) as f32) * self.zoom
196    }
197    /// Track index under an absolute y position (points), given the project (for track heights).
198    pub fn track_at(&self, y: f32, project: &Project) -> Option<usize> {
199        if y < self.lanes_rect.top() {
200            return None;
201        }
202        let mut top = self.lanes_rect.top() - self.scroll_y;
203        for i in row_order(project) {
204            let h = project.tracks[i].height;
205            if y >= top && y < top + h {
206                return Some(i);
207            }
208            top += h;
209        }
210        None
211    }
212    /// Fit [0, duration] into `avail_w` points.
213    pub fn zoom_to_fit(&mut self, duration: f64, avail_w: f32) {
214        let d = duration.max(1.0) as f32;
215        self.zoom = ((avail_w - 20.0).max(50.0) / d).clamp(0.5, 2000.0);
216        self.scroll_x = 0.0;
217    }
218    /// Multiply zoom, keeping the time under `anchor_x` (absolute points) fixed if given.
219    pub fn zoom_by(&mut self, factor: f32, anchor_x: Option<f32>) {
220        let anchor_t = anchor_x.map(|x| self.time_at(x));
221        self.zoom = (self.zoom * factor).clamp(0.5, 2000.0);
222        if let (Some(t), Some(x)) = (anchor_t, anchor_x) {
223            self.scroll_x = (t - ((x - self.lanes_rect.left()) / self.zoom) as f64).max(0.0);
224        }
225    }
226    /// Scroll so `t` is visible (used when the playhead runs off-screen while playing). Suspended after a
227    /// user pan until `t` is back in view.
228    /// Scroll `t` into view unconditionally (an explicit seek: Home/End, prev/next cut, a subtitle
229    /// click, an MCP `playback.seek`). Unlike `ensure_visible` a previous user pan does not suppress it.
230    pub fn follow_playhead(&mut self, t: f64) {
231        self.user_panned = false;
232        self.ensure_visible(t);
233    }
234    pub fn ensure_visible(&mut self, t: f64) {
235        let w = (self.lanes_rect.width() / self.zoom) as f64;
236        if w <= 0.0 {
237            return;
238        }
239        if t >= self.scroll_x && t <= self.scroll_x + w {
240            self.user_panned = false;
241        } else if !self.user_panned {
242            self.scroll_x = (t - w * 0.1).max(0.0);
243        }
244    }
245}
246
247pub struct TimelineCtx<'a> {
248    pub project: &'a mut Project,
249    pub selection: &'a mut Vec<Id>,
250    /// Selected transition ids (timeline bands) — separate from the clip selection: clicking one
251    /// kind of thing deselects the other, Ctrl adds within its own kind.
252    pub sel_transitions: &'a mut Vec<Id>,
253    pub playhead: &'a mut f64,
254    /// Call with the project *before* mutating it (once per gesture) — pushes an undo snapshot.
255    pub undo: &'a mut dyn FnMut(&Project),
256    pub waveforms: &'a mut WaveformCache,
257    pub palette: &'a Palette,
258    pub snap: bool,
259    pub playing: bool,
260    /// Thumbnail cache for video/image filmstrips. None = no filmstrips (e.g. headless tests).
261    pub thumbs: Option<&'a mut ThumbCache>,
262    /// Timeline ranges shaded in `palette.selection` at 15 % alpha (auto-cut "keep" preview). Empty = nothing.
263    pub keep_ranges: &'a [(f64, f64)],
264    /// Movie-mode pre-render coverage: merged `(from, to, ready)` runs, drawn as a bar along the top
265    /// of the ruler. Empty = movie mode is off / nothing requested.
266    pub prerender: &'a [(f64, f64, bool)],
267    /// Active tool strip tool. Cut splits a clicked clip, Marker drops a marker, Stretch retimes an
268    /// edge drag instead of trimming it; everything else behaves as Select.
269    pub tool: crate::ui::tools::Tool,
270}
271
272#[derive(Default)]
273pub struct TimelineResponse {
274    /// The project was mutated (app marks dirty & re-renders).
275    pub edited: bool,
276    /// The playhead was moved by the user.
277    pub seeked: bool,
278    /// Files dropped via dnd `DragPayload::Path` — (path, timeline time, track index).
279    pub dropped_files: Vec<(PathBuf, f64, Option<usize>)>,
280    /// Other dnd payloads (Sequence / Template / Effect / Transition) dropped on the lanes — the app
281    /// places them; an Effect or a Transition goes onto the clip that contains the reported time.
282    pub dropped_other: Vec<(DragPayload, f64, Option<usize>)>,
283    /// Actions requested from the timeline's context menus (Retime, AddTransition, FreezeFrame, AutoCut, …).
284    pub actions: Vec<crate::hotkeys::Action>,
285    /// Container media replacement request — (clip id, pair mode).
286    pub replace_container: Option<(Id, bool)>,
287    /// A Sequence clip was double-clicked — the app calls `Project::open_sequence` with this sequence id.
288    pub open_sequence: Option<Id>,
289    /// "Edit labels…" was picked in a clip's colour submenu — the app opens its label editor.
290    pub edit_labels: bool,
291}
292
293struct Drag {
294    /// Pointer position where the gesture started.
295    origin: Pos2,
296    /// Project at gesture start — pushed as the undo snapshot on release, only if something changed.
297    before: Project,
298    g: Gesture,
299}
300
301enum Gesture {
302    /// Move `ids` (selection + links). `orig` = start time of each id at drag start; `tr` = track of the pressed
303    /// clip; `dt`/`dtrack` = applied so far. `new_track` = the pointer is past the first/last row of `kind`,
304    /// so releasing here adds a track and drops the clips on it.
305    Move { ids: Vec<Id>, orig: Vec<f64>, kind: TrackKind, tr: usize, dt: f64, dtrack: i32, new_track: bool },
306    /// Trim the start (`start`) or end edge of `ids`; `edge` = edge time at drag start.
307    Trim { ids: Vec<Id>, start: bool, edge: f64, changed: bool },
308    /// Rate-stretch one clip by dragging an edge: the source window (`src_len` source seconds) is kept
309    /// and the speed follows the new duration.
310    Stretch { id: Id, start: bool, edge: f64, src_len: f64, changed: bool },
311    /// Drag the volume line of an audio clip vertically.
312    Volume { id: Id, changed: bool },
313    /// Drag a fade handle horizontally (`out` = fade-out, else fade-in).
314    Fade { id: Id, out: bool, changed: bool },
315    /// Drag the keyframe diamond(s) at clip-local time `t`: horizontally = time (every property keyed there),
316    /// vertically = the value of curve-editor property `prop` (value-lane mode only), mapped through the
317    /// value `range` captured at press so the diamond does not chase the pointer as the range grows.
318    Keys { id: Id, t: f64, prop: Option<usize>, range: (f64, f64), changed: bool },
319    /// Drag a transition band edge (duration changes symmetrically around the cut).
320    TransDur { track: usize, id: Id, changed: bool },
321    /// Drag a marker: project marker (`clip` = None) or clip-local marker.
322    Marker { id: Id, clip: Option<Id>, changed: bool },
323    /// Spacer tool: shift every clip starting at or after the press time. `room` = how far left the
324    /// group can go before it hits the clip in front of it (or 0).
325    Spacer { ids: Vec<Id>, dt: f64, room: f64 },
326}
327
328/// Deferred project mutation (collected while the project is borrowed for drawing).
329enum Act {
330    Split,
331    Delete(bool),
332    Link,
333    Enable(bool),
334    AddTrack(TrackKind),
335    RemoveTrack(usize),
336    Mute(usize),
337    Solo(usize),
338    DropAsset(Id, f64, Option<usize>),
339    /// Colour label for the selection (0 = none / inherit asset).
340    Label(u8),
341    /// Copy the selection's effective labels onto their assets.
342    LabelToAsset,
343    /// Set the easing of every property keyed at clip-local time `t`.
344    SetEase(Id, f64, Ease),
345    /// Delete every keyframe at clip-local time `t`.
346    DelKeys(Id, f64),
347    RemoveTransition(Id),
348    RemoveTransitions(Vec<Id>),
349    /// Add a project marker at this timeline time.
350    AddMarker(f64),
351    /// Razor tool: split every clip crossing this timeline time.
352    SplitAt(f64),
353    RenameMarker(Id, String),
354    DelMarker(Id),
355    MarkerLabel(Id, u8),
356    /// Route the selected audio clips to a bus (0 = inherit the track's).
357    Bus(Id),
358    /// Replace media of a container clip.
359    ReplaceContainerMedia(Id),
360    /// Replace media of a container clip and linked audio.
361    ReplaceContainerPair(Id),
362    /// Convert selection to containers.
363    MakeContainer,
364    /// Remove container flag from selection.
365    UnmakeContainer,
366    /// Rename a container's slot label.
367    RenameContainer(Id, String),
368}
369
370/// Display order: video tracks reversed (Vn on top, V1 just above audio), then A1..An.
371fn row_order(p: &Project) -> impl Iterator<Item = usize> + '_ {
372    let n = p.tracks.len();
373    (0..n)
374        .rev()
375        .filter(move |&i| p.tracks[i].kind == TrackKind::Video)
376        .chain((0..n).filter(move |&i| p.tracks[i].kind == TrackKind::Audio))
377}
378
379/// Top y of track `ti` in display order (absolute points).
380fn row_top(state: &TimelineState, p: &Project, ti: usize) -> Option<f32> {
381    let mut top = state.lanes_rect.top() - state.scroll_y;
382    for i in row_order(p) {
383        if i == ti {
384            return Some(top);
385        }
386        top += p.tracks[i].height;
387    }
388    None
389}
390
391/// The clip under a drop at screen `pos` / timeline time `t`, with its lane rect — what an effect or a
392/// transition dragged out of its panel is aimed at (`app.rs` resolves the same clip from the reported
393/// time + track when the drop actually happens).
394fn drop_on_clip<'a>(state: &TimelineState, p: &'a Project, pos: Pos2, t: f64) -> Option<(Rect, &'a Clip)> {
395    let ti = state.track_at(pos.y, p)?;
396    let top = row_top(state, p, ti)?;
397    let clip = p.tracks.get(ti)?.clips.iter().find(|c| c.contains(t))?;
398    let rect = Rect::from_min_max(
399        pos2(state.x_at(clip.start), top + 1.0),
400        pos2(state.x_at(clip.end()), top + p.tracks[ti].height - 1.0),
401    );
402    Some((rect, clip))
403}
404
405/// Nearest candidate within `thr` of `t`.
406fn nearest(t: f64, thr: f64, candidates: impl Iterator<Item = f64>) -> Option<f64> {
407    let mut best: Option<f64> = None;
408    for x in candidates {
409        let d = (x - t).abs();
410        if d <= thr && best.map_or(true, |b| d < (b - t).abs()) {
411            best = Some(x);
412        }
413    }
414    best
415}
416
417/// Snap target for `t`: 0, playhead, in/out, edges of clips not in `exclude` — within `thr` seconds.
418fn snap_target(t: f64, thr: f64, p: &Project, playhead: f64, exclude: &[Id]) -> Option<f64> {
419    let edges = p.all_clips().filter(|(_, c)| !exclude.contains(&c.id)).flat_map(|(_, c)| [c.start, c.end()]);
420    nearest(t, thr, [0.0, playhead].into_iter().chain(p.in_point).chain(p.out_point).chain(edges))
421}
422
423/// Frame-quantise a pointer time and pull it onto the nearest snap candidate. Every tool that turns a
424/// pointer x into a time goes through here, so the razor, the marker tool, marker drags and media drops
425/// land on the same edges that moves and trims already snap to.
426/// Where the playhead lands when snapping is on: clip edges, in/out and 0, but NOT the playhead itself,
427/// which would always be the nearest candidate and pin it where it already is.
428fn snap_playhead(t: f64, on: bool, zoom: f32, p: &Project) -> f64 {
429    if !on {
430        return p.snap_frame(t);
431    }
432    let t = p.snap_frame(t);
433    let edges = p.all_clips().flat_map(|(_, c)| [c.start, c.end()]);
434    let cands = [0.0].into_iter().chain(p.in_point).chain(p.out_point).chain(edges);
435    nearest(t, (SNAP_PX / zoom) as f64, cands).unwrap_or(t)
436}
437
438fn snap_time(t: f64, on: bool, zoom: f32, p: &Project, playhead: f64, exclude: &[Id]) -> f64 {
439    if !on {
440        return t;
441    }
442    let t = p.snap_frame(t);
443    snap_target(t, (SNAP_PX / zoom) as f64, p, playhead, exclude).unwrap_or(t)
444}
445
446/// (major, minor) tick spacing in seconds for a zoom (px/s).
447fn tick_step(zoom: f32) -> (f64, f64) {
448    TICKS.iter().copied().find(|(major, _)| *major * zoom as f64 >= 80.0).unwrap_or(TICKS[TICKS.len() - 1])
449}
450
451fn tick_label(t: f64, major: f64) -> String {
452    let m = (t / 60.0).floor();
453    let s = t - m * 60.0;
454    if major >= 1.0 {
455        format!("{}:{:02}", m, s.round())
456    } else if major >= 0.1 {
457        format!("{}:{:04.1}", m, s)
458    } else {
459        format!("{}:{:05.2}", m, s)
460    }
461}
462
463fn toggle_button(
464    ui: &egui::Ui,
465    p: &egui::Painter,
466    rect: Rect,
467    id: egui::Id,
468    label: Cap,
469    on: bool,
470    pal: &Palette,
471    font: &FontId,
472) -> bool {
473    let r = ui.interact(rect, id, Sense::click());
474    let fill = if on { pal.accent } else { pal.panel };
475    let stroke = if r.hovered() { pal.accent } else { pal.border };
476    p.rect(rect, CornerRadius::same(2), fill, Stroke::new(1.0, stroke), StrokeKind::Inside);
477    match label {
478        Cap::Icon(g) => draw_glyph(p, rect, g, pal.text),
479        Cap::Text(t) => {
480            p.text(rect.center(), Align2::CENTER_CENTER, t, font.clone(), pal.text);
481        }
482    }
483    r.clicked()
484}
485
486/// One vertical min..max line per point column over the visible part of an audio clip.
487fn draw_waveform(p: &egui::Painter, peaks: &Peaks, clip: &Clip, vis: Rect, state: &TimelineState, color: Color32) {
488    let mid = vis.center().y;
489    let half = (vis.height() * 0.5 - 1.0).max(0.0);
490    let stroke = Stroke::new(1.0, color);
491    let mut x = vis.left().floor();
492    while x < vis.right() {
493        let t0 = clip.src_time(state.time_at(x));
494        let t1 = clip.src_time(state.time_at(x + 1.0));
495        let (lo, hi) = peaks.range(t0, t1);
496        if hi > lo {
497            p.vline(x + 0.5, Rangef::new(mid - hi * half, mid - lo * half), stroke);
498        }
499        x += 1.0;
500    }
501}
502
503/// Does the clip have any keyframe at all? Cheap (no allocation), unlike `Clip::key_times`, so the 1000-clip
504/// case never touches the keyframe path.
505fn has_keys(c: &Clip) -> bool {
506    c.animated().iter().any(|a| a.is_animated())
507        || c.effects.iter().any(|e| e.params.iter().any(|a| a.is_animated()) || e.mask.is_some())
508        || c.mask.is_some()
509        || c.graph.is_some()
510        || c.shape.is_some()
511}
512
513/// Value range the diamonds of `prop` are mapped through: the live auto-range, or the one frozen at the
514/// start of the drag so the dragged key does not push its own scale around.
515fn key_range(state: &TimelineState, clip: Id, prop: usize, a: &crate::model::Animated) -> (f64, f64) {
516    if let Some(Drag { g: Gesture::Keys { id, prop: Some(p), range, .. }, .. }) = &state.drag {
517        if *id == clip && *p == prop {
518            return *range;
519        }
520    }
521    crate::ui::curves::y_range(a)
522}
523
524/// The range the rest of the UI enforces for property `i` (inspector sliders for the base props,
525/// `ParamSpec` for effect params), so a value-lane drag cannot write past what a `DragValue` allows.
526/// `None` where nothing enforces one: Position X/Y and Rotation are unbounded everywhere, and Volume is
527/// a gain behind a nonlinear dB slider.
528/// ponytail: same property order as curves.rs — move it next to `prop_ref` if a second caller shows up.
529fn prop_range(c: &Clip, i: usize) -> Option<(f64, f64)> {
530    let nb = if c.is_visual() { 6 } else { 3 };
531    if i < nb {
532        return match (c.is_visual(), i) {
533            (true, 2) => Some((0.01, 20.0)),              // Scale
534            (true, 4) => Some((0.0, 1.0)),                // Opacity
535            (false, 1) => Some((-1.0, 1.0)),              // Pan
536            (_, _) if i == nb - 1 => Some((0.01, 100.0)), // Speed — same clamp as Clip::set_speed
537            _ => None,
538        };
539    }
540    let mut i = i - nb;
541    for e in &c.effects {
542        if i < e.params.len() {
543            return e.specs().get(i).map(|s| (s.min, s.max));
544        }
545        i -= e.params.len();
546    }
547    None
548}
549
550/// Diagonal hatching (adjustment layers read as "applies to everything below").
551fn hatch(p: &egui::Painter, vis: Rect, color: Color32) {
552    let (step, stroke, h) = (8.0f32, Stroke::new(1.0, color), vis.height());
553    let mut x = (vis.left() / step).floor() * step - h;
554    while x < vis.right() {
555        p.line_segment([pos2(x, vis.bottom()), pos2(x + h, vis.top())], stroke);
556        x += step;
557    }
558}
559
560/// Marker flag: a stem down from `top` with a pennant to the right.
561fn flag(p: &egui::Painter, x: f32, top: f32, bottom: f32, color: Color32, wide: bool) {
562    p.vline(x, Rangef::new(top, bottom), Stroke::new(if wide { 2.0 } else { 1.0 }, color));
563    p.add(Shape::convex_polygon(
564        vec![pos2(x, top), pos2(x + FLAG_W, top + 3.0), pos2(x, top + 6.0)],
565        color,
566        Stroke::NONE,
567    ));
568}
569
570/// Shared marker interaction (ruler flags and clip flags): click selects, double-click seeks, drag moves,
571/// right-click = Rename / Delete / Set label.
572#[allow(clippy::too_many_arguments)]
573fn marker_hit(
574    r: egui::Response,
575    m: &crate::model::Marker,
576    clip: Option<Id>,
577    clip_start: f64,
578    selected: &mut Option<Id>,
579    start: &mut Option<(Id, Option<Id>)>,
580    act: &mut Option<Act>,
581    rename: &mut Option<(Id, String)>,
582    seek: &mut Option<f64>,
583    labels: &[Label],
584) {
585    if r.clicked() {
586        *selected = Some(m.id);
587    }
588    if r.double_clicked() {
589        *seek = Some(clip_start + m.t);
590    }
591    if r.drag_started_by(egui::PointerButton::Primary) {
592        *selected = Some(m.id);
593        *start = Some((m.id, clip));
594    }
595    if r.secondary_clicked() {
596        *selected = Some(m.id);
597        *rename = Some((m.id, m.name.clone()));
598    }
599    let r = r.on_hover_ui(|ui| {
600        ui.label(if m.name.is_empty() { "(unnamed marker)" } else { m.name.as_str() });
601    });
602    r.context_menu(|ui| {
603        ui.horizontal(|ui| {
604            ui.label("Name");
605            if let Some((_, buf)) = rename.as_mut().filter(|(rid, _)| *rid == m.id) {
606                let te = ui.add(egui::TextEdit::singleline(buf).desired_width(140.0));
607                if te.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
608                    *act = Some(Act::RenameMarker(m.id, buf.clone()));
609                    ui.close();
610                }
611            }
612        });
613        if ui.button("Delete").clicked() {
614            *act = Some(Act::DelMarker(m.id));
615        }
616        ui.menu_button("Set label", |ui| {
617            if ui.button("None").clicked() {
618                *act = Some(Act::MarkerLabel(m.id, 0));
619            }
620            for (i, l) in labels.iter().enumerate() {
621                if ui.button(&l.name).clicked() {
622                    *act = Some(Act::MarkerLabel(m.id, i as u8 + 1));
623                }
624            }
625        });
626    });
627}
628
629/// Wave colour of an audio clip: the plain palette green while it has no label, otherwise its own
630/// (already computed) body colour pushed to the opposite end of the brightness scale — the body is
631/// painted in that colour, so an unshifted wave would be invisible on it.
632fn wave_color(body: Color32, label: u8, pal: &Palette) -> Color32 {
633    if label == 0 {
634        return pal.waveform;
635    }
636    let target = if body.intensity() > 0.5 { Color32::BLACK } else { Color32::WHITE };
637    body.lerp_to_gamma(target, 0.55)
638}
639
640fn label_color(p: &Project, idx: u8, fallback: Color32) -> Color32 {
641    match p.label_color(idx) {
642        Some([r, g, b]) => Color32::from_rgb(r, g, b),
643        None => fallback,
644    }
645}
646
647fn diamond(c: Pos2, r: f32, color: Color32) -> Shape {
648    Shape::convex_polygon(
649        vec![pos2(c.x, c.y - r), pos2(c.x + r, c.y), pos2(c.x, c.y + r), pos2(c.x - r, c.y)],
650        color,
651        Stroke::NONE,
652    )
653}
654
655/// dB → fraction of the clip height measured from the bottom. Piecewise linear: -60 dB = 0, 0 dB = 0.7,
656/// +12 dB = 1 (so unity gain sits at 70 % height and the usable attenuation range gets most of the clip).
657fn db_frac(db: f32) -> f32 {
658    if db >= 0.0 {
659        0.7 + (db / DB_TOP) * 0.3
660    } else {
661        0.7 * (1.0 - db / DB_BOT)
662    }
663}
664
665fn frac_db(f: f32) -> f32 {
666    if f >= 0.7 {
667        (f - 0.7) / 0.3 * DB_TOP
668    } else {
669        (1.0 - f / 0.7) * DB_BOT
670    }
671}
672
673fn gain_db(g: f32) -> f32 {
674    (20.0 * g.max(1e-4).log10()).clamp(DB_BOT, DB_TOP)
675}
676
677/// Filmstrip of source-time thumbnails across a video/image clip. Non-blocking: misses are queued in the
678/// ThumbCache worker and painted on a later frame.
679fn draw_filmstrip(
680    p: &egui::Painter,
681    ectx: &egui::Context,
682    state: &TimelineState,
683    clip: &Clip,
684    asset: &Asset,
685    rect: Rect,
686    vis: Rect,
687    thumbs: &mut ThumbCache,
688) {
689    let band = 16.0; // name row above the strip
690    let h = rect.height() - band;
691    if h < 12.0 || !vis.is_positive() {
692        return;
693    }
694    // ponytail: 16 px decode buckets, so a track-height drag reuses thumbs instead of queueing a fresh
695    // set (new cache key + new t grid) every frame. Ceiling: <5 % horizontal squash from the rounding.
696    let hq = (((h as u32 + 8) / 16) * 16).max(16);
697    let aspect = if asset.height > 0 { asset.width as f32 / asset.height as f32 } else { 16.0 / 9.0 };
698    let step = (hq as f32 * aspect).max(80.0);
699    let pc = p.with_clip_rect(vis);
700    let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
701    let mut n = ((vis.left() - rect.left()) / step).floor().max(0.0);
702    loop {
703        let x = rect.left() + n * step;
704        if x >= vis.right() || x >= rect.right() {
705            break;
706        }
707        let t = clip.src_time(state.time_at(x)).max(0.0);
708        if let Some((tex, [tw, th])) = thumbs.texture(ectx, &asset.path, t, hq) {
709            let w = h * tw as f32 / th.max(1) as f32;
710            pc.image(tex, Rect::from_min_size(pos2(x, rect.top() + band), vec2(w.min(step), h)), uv, Color32::WHITE);
711        }
712        n += 1.0;
713    }
714}
715
716/// Colour submenu: the project's own labels (name + colour), not the built-in defaults.
717fn label_menu(ui: &mut egui::Ui, labels: &[Label], act: &mut Option<Act>, edit_labels: &mut bool) {
718    if ui.button("None").clicked() {
719        *act = Some(Act::Label(0));
720    }
721    for (i, l) in labels.iter().enumerate() {
722        let color = Color32::from_rgb(l.color[0], l.color[1], l.color[2]);
723        if ui.button(egui::RichText::new(l.name.clone()).color(color)).clicked() {
724            *act = Some(Act::Label(i as u8 + 1));
725        }
726    }
727    ui.separator();
728    if ui.button("Apply label to asset").clicked() {
729        *act = Some(Act::LabelToAsset);
730    }
731    if ui.button("Edit labels…").clicked() {
732        *edit_labels = true;
733    }
734}
735
736#[allow(clippy::too_many_arguments)]
737fn clip_menu(
738    ui: &mut egui::Ui,
739    clip_id: Id,
740    is_container: bool,
741    linked: bool,
742    enabled: bool,
743    audio: bool,
744    labels: &[Label],
745    buses: &[crate::model::Bus],
746    act: &mut Option<Act>,
747    actions: &mut Vec<crate::hotkeys::Action>,
748    edit_labels: &mut bool,
749) {
750    use crate::hotkeys::Action;
751    if ui.button("Copy").clicked() {
752        actions.push(Action::CopyClips);
753    }
754    if ui.button("Cut").clicked() {
755        actions.push(Action::CutClips);
756    }
757    if ui.button("Paste").clicked() {
758        actions.push(Action::PasteClips);
759    }
760    ui.separator();
761    if ui.button("Split at Playhead").clicked() {
762        *act = Some(Act::Split);
763    }
764    if ui.button("Delete").clicked() {
765        *act = Some(Act::Delete(false));
766    }
767    if ui.button("Ripple Delete").clicked() {
768        *act = Some(Act::Delete(true));
769    }
770    ui.separator();
771    if is_container {
772        ui.menu_button("Container", |ui| {
773            if ui.button("Replace Media…").clicked() {
774                *act = Some(Act::ReplaceContainerMedia(clip_id));
775                ui.close_menu();
776            }
777            if !audio && ui.button("Replace Pair…").clicked() {
778                *act = Some(Act::ReplaceContainerPair(clip_id));
779                ui.close_menu();
780            }
781            ui.separator();
782            if ui.button("Remove Container").clicked() {
783                *act = Some(Act::UnmakeContainer);
784                ui.close_menu();
785            }
786        });
787    } else if ui.button("Convert to Container").clicked() {
788        *act = Some(Act::MakeContainer);
789    }
790    ui.separator();
791    if ui.button("Retime…").clicked() {
792        actions.push(Action::Retime);
793    }
794    if ui.button("Freeze Frame at Playhead").clicked() {
795        actions.push(Action::FreezeFrame);
796    }
797    // ponytail: one entry — Action::AddTransition always targets the cut on the selected clip's left.
798    // "at End" pushed the same action, so it either duplicated this or toasted "no left neighbour".
799    // The Transitions pane covers the right-hand cut (transitions_ui::right_neighbor).
800    if ui.button("Add Transition at Start").clicked() {
801        actions.push(Action::AddTransition);
802    }
803
804    if ui.button("Add Transition at End").clicked() {
805        actions.push(Action::AddTransitionEnd);
806    }
807    if ui.button("Auto-cut…").clicked() {
808        actions.push(Action::AutoCut);
809    }
810    ui.separator();
811    if ui.button("Add Marker").clicked() {
812        actions.push(Action::AddMarker);
813    }
814    if ui.button("Copy Attributes").clicked() {
815        actions.push(Action::CopyAttributes);
816    }
817    if ui.button("Paste Attributes…").clicked() {
818        actions.push(Action::PasteAttributes);
819    }
820    // a mask means nothing on audio; that clip wants its mixer routing instead
821    if audio {
822        ui.menu_button("Bus", |ui| {
823            if ui.button("(track)").clicked() {
824                *act = Some(Act::Bus(0));
825            }
826            for b in buses {
827                if ui.button(&b.name).clicked() {
828                    *act = Some(Act::Bus(b.id));
829                }
830            }
831        });
832    } else if ui.button("Add Mask").clicked() {
833        actions.push(Action::AddMask);
834    }
835    ui.separator();
836    if ui.button("Nest into Sequence…").clicked() {
837        actions.push(Action::NestSequence);
838    }
839    if ui.button("Convert to Adjustment Layer").clicked() {
840        actions.push(Action::AddAdjustment);
841    }
842    if ui.button("Save as Template…").clicked() {
843        actions.push(Action::SaveTemplate);
844    }
845    ui.menu_button("Color Label", |ui| label_menu(ui, labels, act, edit_labels));
846    ui.separator();
847    if ui.button(if linked { "Unlink" } else { "Link" }).clicked() {
848        *act = Some(Act::Link);
849    }
850    if ui.button(if enabled { "Disable" } else { "Enable" }).clicked() {
851        *act = Some(Act::Enable(!enabled));
852    }
853}
854
855pub fn show(ui: &mut egui::Ui, state: &mut TimelineState, mut c: TimelineCtx<'_>) -> TimelineResponse {
856    let mut out = TimelineResponse::default();
857    let pal = *c.palette;
858    let full = ui.available_rect_before_wrap();
859    ui.allocate_rect(full, Sense::hover());
860    let id = ui.id().with("timeline");
861    let content_h: f32 = c.project.tracks.iter().map(|t| t.height).sum();
862    let sub_h = if c.project.subtitles.is_empty() { 0.0 } else { state.sub_h };
863    let vbar_w = if content_h > full.height() - RULER_H - sub_h - HBAR_H { VBAR_W } else { 0.0 };
864    let ruler =
865        Rect::from_min_max(pos2(full.left() + state.header_w, full.top()), pos2(full.right(), full.top() + RULER_H));
866    let subs_lane =
867        Rect::from_min_max(pos2(ruler.left(), ruler.bottom()), pos2(full.right() - vbar_w, ruler.bottom() + sub_h));
868    let lanes =
869        Rect::from_min_max(pos2(ruler.left(), subs_lane.bottom()), pos2(full.right() - vbar_w, full.bottom() - HBAR_H));
870    let header = Rect::from_min_max(pos2(full.left(), lanes.top()), pos2(lanes.left(), full.bottom()));
871    let body = Rect::from_min_max(pos2(full.left(), lanes.top()), lanes.max);
872    state.lanes_rect = lanes;
873    if c.playing {
874        state.ensure_visible(*c.playhead);
875    } else {
876        state.user_panned = false;
877    }
878    let (mods, pointer, primary_down, escape) =
879        ui.input(|i| (i.modifiers, i.pointer.latest_pos(), i.pointer.primary_down(), i.key_pressed(egui::Key::Escape)));
880    // Esc aborts the gesture: put the project back as it was at press time and drop the band.
881    if escape {
882        state.band = None;
883        if let Some(d) = state.drag.take() {
884            *c.project = d.before;
885        }
886    }
887
888    // ---- scroll / zoom (only when hovered) ----
889    if ui.rect_contains_pointer(full) {
890        if let Some(pos) = pointer {
891            let sx = state.scroll_x;
892            let (delta, zoom) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta()));
893            if zoom != 1.0 {
894                state.zoom_by(zoom.clamp(0.5, 2.0), Some(pos.x));
895            }
896            if delta != egui::Vec2::ZERO {
897                if mods.alt {
898                    if sub_h > 0.0 && subs_lane.contains(pos) {
899                        state.sub_h = (state.sub_h + delta.y * 0.25).clamp(SUB_LANE_H, 80.0);
900                    } else if let Some(ti) = state.track_at(pos.y, c.project) {
901                        let t = &mut c.project.tracks[ti];
902                        t.height = (t.height + delta.y * 0.25).clamp(MIN_TRACK_H, MAX_TRACK_H);
903                    }
904                } else {
905                    state.scroll_x = (state.scroll_x - (delta.x / state.zoom) as f64).max(0.0);
906                    state.scroll_y -= delta.y;
907                }
908            }
909            if state.scroll_x != sx {
910                state.user_panned = true;
911            }
912        }
913    }
914    state.scroll_y = state.scroll_y.clamp(0.0, (content_h - lanes.height()).max(0.0));
915
916    let small = egui::TextStyle::Small.resolve(ui.style());
917    let font = egui::TextStyle::Body.resolve(ui.style());
918    let painter = ui.painter().clone();
919    let bp = painter.with_clip_rect(body);
920    let lp = painter.with_clip_rect(lanes);
921    let rp = painter.with_clip_rect(ruler);
922    painter.rect_filled(full, 0, pal.bg);
923    painter.rect_filled(Rect::from_min_max(full.min, pos2(header.right(), full.bottom())), 0, pal.header);
924    painter.rect_filled(ruler, 0, pal.header);
925
926    // in/out shading (ruler + lanes)
927    let (ip, op) = (c.project.in_point, c.project.out_point);
928    if ip.is_some() || op.is_some() {
929        let xa = ip.map(|t| state.x_at(t)).unwrap_or(lanes.left()).max(lanes.left());
930        let xb = op.map(|t| state.x_at(t)).unwrap_or(lanes.right()).min(lanes.right());
931        if xb > xa {
932            painter.rect_filled(
933                Rect::from_min_max(pos2(xa, ruler.top()), pos2(xb, lanes.bottom())),
934                0,
935                pal.in_out.gamma_multiply(0.12),
936            );
937        }
938        for t in ip.into_iter().chain(op) {
939            rp.vline(state.x_at(t), ruler.y_range(), Stroke::new(1.0, pal.in_out));
940        }
941    }
942
943    // lanes background response first so clips (added later) win hit-testing
944    let lanes_resp = ui.interact(lanes, id.with("lanes"), Sense::click_and_drag());
945    let ruler_resp = ui.interact(ruler, id.with("ruler"), Sense::click_and_drag());
946    // playhead line: registered before the clips so egui's thin-widget tie-break lets the 6 px edge handles
947    // beat the 8 px playhead, which still beats clip bodies
948    let px = state.x_at(*c.playhead);
949    let ph_resp = ui
950        .interact(
951            Rect::from_x_y_ranges(px - 4.0..=px + 4.0, lanes.y_range()).intersect(lanes),
952            id.with("ph"),
953            Sense::click_and_drag(),
954        )
955        .on_hover_cursor(CursorIcon::ResizeHorizontal);
956
957    // the row under the press is where Ctrl+V pastes — right-click counts, so the context menu's
958    // Paste lands on the row you opened it over
959    if ui.input(|i| i.pointer.primary_pressed() || i.pointer.secondary_pressed()) {
960        if let Some(ti) = pointer.filter(|p| lanes.contains(*p)).and_then(|p| state.track_at(p.y, c.project)) {
961            state.last_track = Some(ti);
962        }
963    }
964
965    let mut act: Option<Act> = None;
966    let mut click: Option<Id> = None;
967    let mut trans_click: Option<Id> = None;
968    // drop selected transitions that no longer exist (removed, pruned, project swapped)
969    c.sel_transitions.retain(|id| c.project.tracks.iter().any(|t| t.transitions.iter().any(|x| x.id == *id)));
970    // spacer tool: a press on empty lane space opens a gap instead of rubber-banding
971    let mut start_spacer = false;
972    let mut start_move: Option<Id> = None;
973    let mut start_trim: Option<(Id, bool)> = None;
974    let mut start_vol: Option<Id> = None;
975    let mut start_fade: Option<(Id, bool)> = None;
976    let mut start_key: Option<(Id, f64, Option<usize>)> = None;
977    let mut start_trans: Option<(usize, Id)> = None;
978    let mut start_marker: Option<(Id, Option<Id>)> = None;
979    let mut resize: Option<(usize, f32)> = None;
980    let mut divider_y: Option<f32> = None;
981    let mut key_hits: Vec<(Id, f64, Pos2, Option<usize>)> = Vec::new();
982    let mut marker_hits: Vec<(Id, Id, Rect)> = Vec::new();
983    let linked_sel = c.project.expand_links(c.selection);
984    let thin = Stroke::new(1.0, pal.border);
985    // rubber band: hit-tested while the rows are painted, applied on release
986    let band_rect = state.band.map(|(o, _)| Rect::from_two_pos(o, pointer.unwrap_or(o)).intersect(lanes));
987    let mut band_ids: Vec<Id> = Vec::new();
988    let mut band_trans: Vec<Id> = Vec::new();
989    // marker rename buffer, moved out of `state` so the menu closures can borrow it while `state` paints
990    let mut rename = state.rename.take();
991    let mut seek_marker: Option<f64> = None;
992    let labels: &[Label] = &c.project.labels;
993    let buses: &[crate::model::Bus] = &c.project.buses;
994
995    // ---- rows ----
996    let mut y = lanes.top() - state.scroll_y;
997    for ti in row_order(c.project) {
998        let track = &c.project.tracks[ti];
999        let row = Rect::from_min_max(pos2(lanes.left(), y), pos2(lanes.right(), y + track.height));
1000        y += track.height;
1001        if track.kind == TrackKind::Video {
1002            divider_y = Some(row.bottom());
1003        }
1004        if row.bottom() < lanes.top() || row.top() > lanes.bottom() {
1005            continue;
1006        }
1007        bp.hline(body.x_range(), row.bottom() - 0.5, thin);
1008
1009        // header cell
1010        let hr = Rect::from_min_max(pos2(header.left(), row.top()), pos2(header.right(), row.bottom()));
1011        let tid = id.with(track.id);
1012        let hresp = ui.interact(hr, tid.with("hdr"), Sense::click());
1013        let active = c.project.active(ti);
1014        let bw = vec2(18.0, 16.0);
1015        let sb = Rect::from_center_size(pos2(hr.right() - 4.0 - bw.x * 0.5, hr.center().y), bw);
1016        let mb = sb.translate(vec2(-(bw.x + 3.0), 0.0));
1017        bp.with_clip_rect(Rect::from_min_max(hr.min, pos2(mb.left() - 2.0, hr.bottom()))).text(
1018            pos2(hr.left() + 6.0, hr.center().y),
1019            Align2::LEFT_CENTER,
1020            &track.name,
1021            font.clone(),
1022            if active { pal.text } else { pal.text_dim },
1023        );
1024        // audio: M = muted; video: V = visible (= !muted)
1025        let is_video = track.kind == TrackKind::Video;
1026        // real-world icons: an eye for video visibility, a speaker for audio mute
1027        let (m_label, m_on) = if is_video {
1028            (Cap::Icon(if track.muted { Glyph::EyeOff } else { Glyph::Eye }), !track.muted)
1029        } else {
1030            (Cap::Icon(if track.muted { Glyph::SpeakerOff } else { Glyph::SpeakerOn }), track.muted)
1031        };
1032        if toggle_button(ui, &bp, mb, tid.with("m"), m_label, m_on, &pal, &small) {
1033            act = Some(Act::Mute(ti));
1034        }
1035        if toggle_button(ui, &bp, sb, tid.with("s"), Cap::Text("S"), track.solo, &pal, &small) {
1036            act = Some(Act::Solo(ti));
1037        }
1038        let (empty, muted, solo) = (track.clips.is_empty(), track.muted, track.solo);
1039        hresp.context_menu(|ui| {
1040            if ui.button("Add Video Track").clicked() {
1041                act = Some(Act::AddTrack(TrackKind::Video));
1042            }
1043            if ui.button("Add Audio Track").clicked() {
1044                act = Some(Act::AddTrack(TrackKind::Audio));
1045            }
1046            if ui.add_enabled(empty, egui::Button::new("Remove Track")).clicked() {
1047                act = Some(Act::RemoveTrack(ti));
1048            }
1049            ui.separator();
1050            if ui.button(if muted { "Unmute" } else { "Mute" }).clicked() {
1051                act = Some(Act::Mute(ti));
1052            }
1053            if ui.button(if solo { "Unsolo" } else { "Solo" }).clicked() {
1054                act = Some(Act::Solo(ti));
1055            }
1056        });
1057
1058        // clips
1059        for clip in &track.clips {
1060            let (x0, x1) = (state.x_at(clip.start), state.x_at(clip.end()));
1061            if x1 < lanes.left() || x0 > lanes.right() {
1062                continue;
1063            }
1064            let rect = Rect::from_min_max(pos2(x0, row.top() + 1.0), pos2(x1, row.bottom() - 1.0));
1065            let vis = rect.intersect(lanes);
1066            if let Some(br) = band_rect {
1067                if br.intersects(rect) {
1068                    band_ids.push(clip.id);
1069                }
1070            }
1071            // custom labels win over the per-kind fill (Shape / Adjustment have their own)
1072            let clip_label = c.project.clip_label(clip);
1073            let mut color = label_color(c.project, clip_label, pal.clip_color(clip.kind));
1074            if !clip.enabled || !active {
1075                color = color.gamma_multiply(0.5);
1076            }
1077            lp.rect_filled(rect, 0, color);
1078            lp.rect_stroke(rect, 0, thin, StrokeKind::Inside);
1079            if clip.container {
1080                lp.rect_stroke(rect, 0, Stroke::new(1.5, pal.accent), StrokeKind::Inside);
1081            }
1082            // sub-pixel clips (zoomed way out): fill only — no text, waveform, diamonds or hit-testing.
1083            // ponytail: 4 pt is well under the ~18 pt a clip needs for its trim handles, and the rubber
1084            // band still catches them; give them their own interact if that ever bites.
1085            let detailed = vis.width() >= 4.0;
1086            if clip.kind == ClipKind::Adjustment && detailed {
1087                hatch(&lp.with_clip_rect(vis), vis, pal.text.gamma_multiply(0.25));
1088            } else if clip.is_empty_container() && detailed {
1089                hatch(&lp.with_clip_rect(vis), vis, pal.accent.gamma_multiply(0.35));
1090            }
1091            if !detailed {
1092                if c.selection.contains(&clip.id) {
1093                    lp.rect_stroke(rect, 0, Stroke::new(2.0, pal.selection), StrokeKind::Inside);
1094                }
1095                continue;
1096            }
1097            if clip.kind == ClipKind::Audio {
1098                if let Some(asset) = c.project.asset(clip.asset) {
1099                    if let Some(peaks) = c.waveforms.get(&asset.path, clip.audio_stream) {
1100                        draw_waveform(&lp, &peaks, clip, vis.shrink(1.0), state, wave_color(color, clip_label, &pal));
1101                    }
1102                }
1103            } else if matches!(clip.kind, ClipKind::Video | ClipKind::Image) {
1104                if let (Some(th), Some(asset)) = (c.thumbs.as_deref_mut(), c.project.asset(clip.asset)) {
1105                    draw_filmstrip(&lp, ui.ctx(), state, clip, asset, rect, vis.shrink(1.0), th);
1106                }
1107            }
1108            let name_pc = lp.with_clip_rect(vis.shrink(1.0));
1109            let name_pos = pos2(vis.left() + 4.0, rect.top() + 2.0);
1110            if clip.kind == ClipKind::Sequence {
1111                let badge = Rect::from_min_size(name_pos, vec2(15.0, 15.0));
1112                draw_glyph(&name_pc, badge, Glyph::Sequence, pal.text);
1113                name_pc.text(pos2(badge.right(), name_pos.y), Align2::LEFT_TOP, &clip.name, font.clone(), pal.text);
1114            } else if clip.container {
1115                let badge = Rect::from_min_size(name_pos, vec2(15.0, 15.0));
1116                draw_glyph(&name_pc, badge, Glyph::Container, pal.accent);
1117                let label = if clip.is_empty_container() {
1118                    if !clip.container_label.is_empty() {
1119                        format!("[{}] (Empty)", clip.container_label)
1120                    } else {
1121                        "Container (Empty)".to_string()
1122                    }
1123                } else if !clip.container_label.is_empty()
1124                    && !clip.name.contains(&format!("[{}]", clip.container_label))
1125                {
1126                    format!("{} [{}]", clip.name, clip.container_label)
1127                } else {
1128                    clip.name.clone()
1129                };
1130                name_pc.text(pos2(badge.right() + 2.0, name_pos.y), Align2::LEFT_TOP, &label, font.clone(), pal.text);
1131            } else {
1132                name_pc.text(name_pos, Align2::LEFT_TOP, &clip.name, font.clone(), pal.text);
1133            }
1134            if clip.is_retimed() {
1135                if clip.freeze.is_some() {
1136                    // a snowflake, painted: the frozen clip has no rate to print
1137                    let f = Rect::from_min_size(pos2(rect.right() - 17.0, rect.top() + 1.0), vec2(16.0, 16.0));
1138                    draw_glyph(&name_pc, f, Glyph::Snowflake, pal.text);
1139                } else {
1140                    let mut s = String::new();
1141                    if (clip.speed - 1.0).abs() > 1e-9 {
1142                        s = if clip.speed < 1.0 {
1143                            format!("{:.0} %", clip.speed * 100.0)
1144                        } else if clip.speed.fract().abs() < 1e-6 {
1145                            format!("{}x", clip.speed as i64)
1146                        } else {
1147                            format!("{:.2}x", clip.speed)
1148                        };
1149                    }
1150                    if clip.reverse {
1151                        if !s.is_empty() {
1152                            s.push(' ');
1153                        }
1154                        s.push_str("rev");
1155                    }
1156                    name_pc.text(
1157                        pos2(rect.right() - 3.0, rect.top() + 2.0),
1158                        Align2::RIGHT_TOP,
1159                        s,
1160                        small.clone(),
1161                        pal.text,
1162                    );
1163                }
1164            }
1165            // retime preview: while the Stretch tool drags this clip's edge, show the rate it is landing on
1166            // big in the middle — the corner badge is easy to miss mid-drag
1167            if matches!(&state.drag, Some(Drag { g: Gesture::Stretch { id, .. }, .. }) if *id == clip.id) {
1168                name_pc.text(
1169                    vis.center(),
1170                    Align2::CENTER_CENTER,
1171                    format!("{:.2}x", clip.speed),
1172                    font.clone(),
1173                    pal.accent,
1174                );
1175            }
1176            if clip.kind == ClipKind::Adjustment {
1177                name_pc.text(
1178                    pos2(rect.right() - 3.0, rect.bottom() - 2.0),
1179                    Align2::RIGHT_BOTTOM,
1180                    "adj",
1181                    small.clone(),
1182                    pal.text,
1183                );
1184            }
1185            if clip.kind == ClipKind::Audio {
1186                // volume line (dB mapped) + fade ramps/handles over the waveform
1187                let vs = Stroke::new(1.0, pal.accent);
1188                if clip.volume.is_animated() {
1189                    let mut last: Option<Pos2> = None;
1190                    let mut x = vis.left();
1191                    while x <= vis.right() + 4.0 {
1192                        let lt = (state.time_at(x) - clip.start).clamp(0.0, clip.duration);
1193                        let y = rect.bottom() - db_frac(gain_db(clip.volume.at(lt) as f32)) * rect.height();
1194                        let pnt = pos2(x.min(vis.right()), y);
1195                        if let Some(l) = last {
1196                            name_pc.line_segment([l, pnt], vs);
1197                        }
1198                        last = Some(pnt);
1199                        x += 4.0;
1200                    }
1201                } else {
1202                    let y = rect.bottom() - db_frac(gain_db(clip.volume.value as f32)) * rect.height();
1203                    name_pc.hline(vis.x_range(), y, vs);
1204                }
1205            }
1206            if clip.kind != ClipKind::Adjustment {
1207                // fade ramps + handles: audio gain fades, opacity fades on visual clips
1208                let fs = Stroke::new(1.0, pal.text);
1209                let fi_x = (rect.left() + clip.fade_in as f32 * state.zoom).min(rect.right());
1210                let fo_x = (rect.right() - clip.fade_out as f32 * state.zoom).max(rect.left());
1211                if clip.fade_in > 0.0 {
1212                    name_pc.line_segment([pos2(rect.left(), rect.bottom()), pos2(fi_x, rect.top())], fs);
1213                }
1214                if clip.fade_out > 0.0 {
1215                    name_pc.line_segment([pos2(fo_x, rect.top()), pos2(rect.right(), rect.bottom())], fs);
1216                }
1217                for hx in [fi_x, fo_x] {
1218                    lp.rect_filled(Rect::from_center_size(pos2(hx, rect.top() + 3.0), vec2(6.0, 6.0)), 0, pal.text);
1219                }
1220            }
1221            // keyframe diamonds: on a tall clip in a value lane — 0 % at the bottom, 100 % at the top of the
1222            // range of the first property keyed at that time (the curve editor's auto-range, so both panes
1223            // agree) — otherwise in the bottom strip
1224            if has_keys(clip) {
1225                let lane = rect.height() >= KEY_LANE_MIN;
1226                let props = crate::ui::curves::prop_count(clip);
1227                for t in clip.key_times() {
1228                    let kx = state.x_at(clip.start + t);
1229                    if kx < lanes.left() - 6.0 || kx > lanes.right() + 6.0 {
1230                        continue;
1231                    }
1232                    // ponytail: the lane follows the curve editor's property list (transform + effect
1233                    // params). Mask / graph / shape keys keep the bottom strip — extend curves::prop_ref
1234                    // to them if they ever need a value lane too. Audio volume (prop 0) too: it is drawn
1235                    // and dragged on the dB scale above, which the linear lane would contradict.
1236                    let first = if clip.is_visual() { 0 } else { 1 };
1237                    let prop = lane
1238                        .then(|| {
1239                            (first..props)
1240                                .find(|&i| crate::ui::curves::prop_ref(clip, i).is_some_and(|a| a.has_key_at(t)))
1241                        })
1242                        .flatten();
1243                    let ky = match prop.and_then(|i| crate::ui::curves::prop_ref(clip, i).map(|a| (i, a))) {
1244                        Some((i, a)) => {
1245                            let (lo, hi) = key_range(state, clip.id, i, a);
1246                            let f = ((a.at(t) - lo) / (hi - lo).max(1e-9)).clamp(0.0, 1.0) as f32;
1247                            rect.bottom() - KEY_PAD - f * (rect.height() - 2.0 * KEY_PAD)
1248                        }
1249                        None => rect.bottom() - 5.0,
1250                    };
1251                    let kp = pos2(kx, ky);
1252                    lp.add(diamond(kp, 4.0, pal.keyframe));
1253                    key_hits.push((clip.id, t, kp, prop));
1254                }
1255            }
1256            // clip markers (clip-local time), drawn from the top of the clip down
1257            for m in &clip.markers {
1258                let mx = state.x_at(clip.start + m.t);
1259                if mx < vis.left() - FLAG_W || mx > vis.right() {
1260                    continue;
1261                }
1262                let sel = state.selected_marker == Some(m.id);
1263                flag(
1264                    &lp.with_clip_rect(vis),
1265                    mx,
1266                    rect.top() + 1.0,
1267                    rect.bottom(),
1268                    label_color(c.project, m.label, pal.text),
1269                    sel,
1270                );
1271                // interaction is registered after the clip bodies (below) so the small flags win hit-testing
1272                let mr = Rect::from_min_size(pos2(mx - 2.0, rect.top()), vec2(FLAG_W + 4.0, 10.0)).intersect(lanes);
1273                if mr.is_positive() {
1274                    marker_hits.push((clip.id, m.id, mr));
1275                }
1276            }
1277            let selected = c.selection.contains(&clip.id);
1278            if selected {
1279                lp.rect_stroke(rect, 0, Stroke::new(2.0, pal.selection), StrokeKind::Inside);
1280            } else if linked_sel.contains(&clip.id) {
1281                lp.rect_stroke(rect, 0, Stroke::new(1.0, pal.selection), StrokeKind::Inside);
1282            }
1283
1284            // interaction: body, then volume line, then edges, then fade handles on top
1285            let cid = id.with(clip.id);
1286            let br = ui.interact(vis, cid, Sense::click_and_drag());
1287            if br.clicked() {
1288                // razor / marker tools act where the pointer is instead of selecting
1289                let (snap_on, zoom, ph) = (c.snap, state.zoom, *c.playhead);
1290                match c.tool {
1291                    Tool::Cut => {
1292                        let x = ui.input(|i| i.pointer.latest_pos()).unwrap_or(vis.center()).x;
1293                        let t = snap_time(state.time_at(x), snap_on, zoom, c.project, ph, &[]);
1294                        act = Some(Act::SplitAt(t));
1295                    }
1296                    Tool::Marker => {
1297                        let x = ui.input(|i| i.pointer.latest_pos()).unwrap_or(vis.center()).x;
1298                        let t = snap_time(state.time_at(x), snap_on, zoom, c.project, ph, &[]);
1299                        act = Some(Act::AddMarker(t.max(0.0)));
1300                    }
1301                    _ => click = Some(clip.id),
1302                }
1303            }
1304            if br.double_clicked() {
1305                c.selection.clear();
1306                c.selection.push(clip.id);
1307                if clip.kind == ClipKind::Sequence {
1308                    out.open_sequence = Some(clip.sequence);
1309                } else if let Some(p) = br.interact_pointer_pos() {
1310                    *c.playhead = snap_playhead(state.time_at(p.x).max(0.0), c.snap, state.zoom, c.project);
1311                    out.seeked = true;
1312                }
1313            }
1314            if br.drag_started_by(egui::PointerButton::Primary) {
1315                start_move = Some(clip.id);
1316            }
1317            let (linked, enabled, aud, is_cont) =
1318                (clip.link != 0, clip.enabled, clip.kind == ClipKind::Audio, clip.container);
1319            let mut rclick = br.secondary_clicked();
1320            br.context_menu(|ui| {
1321                clip_menu(
1322                    ui,
1323                    clip.id,
1324                    is_cont,
1325                    linked,
1326                    enabled,
1327                    aud,
1328                    labels,
1329                    buses,
1330                    &mut act,
1331                    &mut out.actions,
1332                    &mut out.edit_labels,
1333                )
1334            });
1335            if clip.kind == ClipKind::Audio {
1336                if let Some(pos) = pointer {
1337                    if pos.x >= vis.left() && pos.x <= vis.right() {
1338                        let lt = (state.time_at(pos.x) - clip.start).clamp(0.0, clip.duration);
1339                        let y = rect.bottom() - db_frac(gain_db(clip.volume.at(lt) as f32)) * rect.height();
1340                        let vr = Rect::from_x_y_ranges(vis.x_range(), (y - 4.0)..=(y + 4.0)).intersect(vis);
1341                        if vr.is_positive() {
1342                            let r = ui
1343                                .interact(vr, cid.with("vol"), Sense::drag())
1344                                .on_hover_cursor(CursorIcon::ResizeVertical);
1345                            if r.drag_started_by(egui::PointerButton::Primary) {
1346                                start_vol = Some(clip.id);
1347                            }
1348                        }
1349                    }
1350                }
1351            }
1352            if rect.width() >= 3.0 * EDGE_W {
1353                let le = Rect::from_min_max(rect.min, pos2(rect.left() + EDGE_W, rect.bottom())).intersect(lanes);
1354                let re = Rect::from_min_max(pos2(rect.right() - EDGE_W, rect.top()), rect.max).intersect(lanes);
1355                for (er, is_start, salt) in [(le, true, "l"), (re, false, "r")] {
1356                    if !er.is_positive() {
1357                        continue;
1358                    }
1359                    let r = ui
1360                        .interact(er, cid.with(salt), Sense::click_and_drag())
1361                        .on_hover_cursor(CursorIcon::ResizeHorizontal);
1362                    if r.clicked() {
1363                        click = Some(clip.id);
1364                    }
1365                    if r.drag_started_by(egui::PointerButton::Primary) {
1366                        start_trim = Some((clip.id, is_start));
1367                    }
1368                    rclick |= r.secondary_clicked();
1369                    r.context_menu(|ui| {
1370                        clip_menu(
1371                            ui,
1372                            clip.id,
1373                            is_cont,
1374                            linked,
1375                            enabled,
1376                            aud,
1377                            labels,
1378                            buses,
1379                            &mut act,
1380                            &mut out.actions,
1381                            &mut out.edit_labels,
1382                        )
1383                    });
1384                }
1385            }
1386            if clip.kind != ClipKind::Adjustment {
1387                let fi_x = (rect.left() + clip.fade_in as f32 * state.zoom).min(rect.right());
1388                let fo_x = (rect.right() - clip.fade_out as f32 * state.zoom).max(rect.left());
1389                for (hx, is_out, salt) in [(fi_x, false, "fi"), (fo_x, true, "fo")] {
1390                    let fr = Rect::from_center_size(pos2(hx, rect.top() + 3.0), vec2(10.0, 10.0)).intersect(lanes);
1391                    if !fr.is_positive() {
1392                        continue;
1393                    }
1394                    let r =
1395                        ui.interact(fr, cid.with(salt), Sense::drag()).on_hover_cursor(CursorIcon::ResizeHorizontal);
1396                    if r.drag_started_by(egui::PointerButton::Primary) {
1397                        start_fade = Some((clip.id, is_out));
1398                    }
1399                }
1400            }
1401            if rclick && !selected {
1402                c.selection.clear();
1403                c.selection.push(clip.id);
1404            }
1405        }
1406
1407        // transitions: an overlapping band on each valid cut / clip edge
1408        for tr in &track.transitions {
1409            let Some((left, right)) = track.transition_clips(tr) else { continue };
1410            // the played window is clamped to the clip(s) (an over-long transition can't reach past them)
1411            let Some((cut, half)) = tr.cut_half(left, right) else { continue };
1412            let (wa, wb) = (cut - half, cut + half);
1413            let (xa, xb) = (state.x_at(wa), state.x_at(wb));
1414            if xb < lanes.left() || xa > lanes.right() {
1415                continue;
1416            }
1417            let band = Rect::from_min_max(pos2(xa, row.top() + 1.0), pos2(xb, row.bottom() - 1.0));
1418            let bvis = band.intersect(lanes);
1419            if let Some(br) = band_rect {
1420                if br.intersects(band) {
1421                    band_trans.push(tr.id);
1422                }
1423            }
1424            let selected = c.sel_transitions.contains(&tr.id);
1425            lp.rect_filled(band, 0, pal.bg.gamma_multiply(0.5));
1426            if selected {
1427                lp.rect_filled(band, 0, pal.selection.gamma_multiply(0.25));
1428            }
1429            let (bs, bc) = if selected { (2.0, pal.selection) } else { (1.0, pal.accent) };
1430            lp.rect_stroke(band, 0, Stroke::new(bs, bc), StrokeKind::Inside);
1431            if bvis.is_positive() {
1432                lp.with_clip_rect(bvis).text(
1433                    band.center(),
1434                    Align2::CENTER_CENTER,
1435                    tr.kind.name(),
1436                    small.clone(),
1437                    pal.text,
1438                );
1439                let br = ui.interact(bvis, id.with(tr.id), Sense::click());
1440                if br.clicked() {
1441                    trans_click = Some(tr.id);
1442                }
1443                if br.secondary_clicked() && !selected {
1444                    c.sel_transitions.clear();
1445                    c.sel_transitions.push(tr.id);
1446                    c.selection.clear();
1447                }
1448                br.context_menu(|ui| {
1449                    let many = selected && c.sel_transitions.len() > 1;
1450                    let label = if many {
1451                        format!("Remove {} Selected Transitions", c.sel_transitions.len())
1452                    } else {
1453                        "Remove Transition".into()
1454                    };
1455                    if ui.button(label).clicked() {
1456                        act = Some(if many {
1457                            Act::RemoveTransitions(c.sel_transitions.clone())
1458                        } else {
1459                            Act::RemoveTransition(tr.id)
1460                        });
1461                    }
1462                });
1463            }
1464            for (er, salt) in [
1465                (Rect::from_min_max(band.min, pos2(band.left() + EDGE_W, band.bottom())), "a"),
1466                (Rect::from_min_max(pos2(band.right() - EDGE_W, band.top()), band.max), "b"),
1467            ] {
1468                let er = er.intersect(lanes);
1469                if !er.is_positive() {
1470                    continue;
1471                }
1472                let r = ui
1473                    .interact(er, id.with(tr.id).with(salt), Sense::click_and_drag())
1474                    .on_hover_cursor(CursorIcon::ResizeHorizontal);
1475                if r.drag_started_by(egui::PointerButton::Primary) {
1476                    start_trans = Some((ti, tr.id));
1477                }
1478                r.context_menu(|ui| {
1479                    if ui.button("Remove Transition").clicked() {
1480                        act = Some(Act::RemoveTransition(tr.id));
1481                    }
1482                });
1483            }
1484        }
1485
1486        // track resize handle: spans the full width (header + lanes), registered after the clips so it wins
1487        let handle = Rect::from_min_max(pos2(body.left(), row.bottom() - HANDLE_H), pos2(body.right(), row.bottom()));
1488        let hd = ui.interact(handle, tid.with("h"), Sense::drag()).on_hover_cursor(CursorIcon::ResizeVertical);
1489        if hd.dragged() {
1490            resize = Some((ti, hd.drag_delta().y));
1491        }
1492    }
1493
1494    // keyframe diamonds: registered after everything in the rows so the small targets win hit-testing
1495    for (i, &(kcid, kt, kp, kprop)) in key_hits.iter().enumerate() {
1496        let cursor = if kprop.is_some() { CursorIcon::Move } else { CursorIcon::ResizeHorizontal };
1497        let r = ui
1498            .interact(Rect::from_center_size(kp, vec2(10.0, 10.0)), id.with(("key", i)), Sense::click_and_drag())
1499            .on_hover_cursor(cursor);
1500        if r.drag_started_by(egui::PointerButton::Primary) {
1501            start_key = Some((kcid, kt, kprop));
1502        }
1503        let clip = c.project.clip(kcid);
1504        let r = r.on_hover_ui(|ui| {
1505            // built only while hovered — a per-frame format! per diamond is not worth it
1506            match (clip, kprop) {
1507                (Some(cl), Some(pi)) => {
1508                    let v = crate::ui::curves::prop_ref(cl, pi).map(|a| a.at(kt)).unwrap_or(0.0);
1509                    ui.label(format!("{} {:.3}\n{:.2} s", crate::ui::curves::prop_label(cl, pi), v, cl.start + kt));
1510                }
1511                (Some(cl), None) => {
1512                    ui.label(format!("{:.2} s", cl.start + kt));
1513                }
1514                _ => {}
1515            }
1516        });
1517        r.context_menu(|ui| {
1518            for e in Ease::ALL {
1519                if ui.button(e.name()).clicked() {
1520                    act = Some(Act::SetEase(kcid, kt, e));
1521                }
1522            }
1523            for (name, e) in Ease::PRESETS {
1524                if ui.button(name).clicked() {
1525                    act = Some(Act::SetEase(kcid, kt, e));
1526                }
1527            }
1528            ui.separator();
1529            if ui.button("Delete keyframe(s)").clicked() {
1530                act = Some(Act::DelKeys(kcid, kt));
1531            }
1532        });
1533    }
1534
1535    // clip markers, on top of the clip bodies
1536    for &(cid, mid, mr) in &marker_hits {
1537        let Some((cl, m)) = c.project.clip(cid).and_then(|cl| Some((cl, cl.markers.iter().find(|m| m.id == mid)?)))
1538        else {
1539            continue;
1540        };
1541        let r = ui.interact(mr, id.with(("cmk", mid)), Sense::click_and_drag());
1542        marker_hit(
1543            r,
1544            m,
1545            Some(cid),
1546            cl.start,
1547            &mut state.selected_marker,
1548            &mut start_marker,
1549            &mut act,
1550            &mut rename,
1551            &mut seek_marker,
1552            labels,
1553        );
1554    }
1555
1556    // auto-cut preview: shade the kept ranges over the lanes
1557    for &(ka, kb) in c.keep_ranges {
1558        let xa = state.x_at(ka).max(lanes.left());
1559        let xb = state.x_at(kb).min(lanes.right());
1560        if xb > xa {
1561            lp.rect_filled(
1562                Rect::from_min_max(pos2(xa, lanes.top()), pos2(xb, lanes.bottom())),
1563                0,
1564                pal.selection.gamma_multiply(0.15),
1565            );
1566        }
1567    }
1568    if let Some(dy) = divider_y {
1569        if c.project.tracks.iter().any(|t| t.kind == TrackKind::Audio) {
1570            bp.hline(body.x_range(), dy - 1.0, Stroke::new(2.0, pal.border));
1571        }
1572    }
1573
1574    // ---- rubber band ----
1575    if let (Some(br), Some((_, add))) = (band_rect, state.band) {
1576        lp.rect_filled(br, 0, pal.accent.gamma_multiply(0.15));
1577        lp.rect_stroke(br, 0, Stroke::new(1.0, pal.accent), StrokeKind::Inside);
1578        if !primary_down {
1579            if !add {
1580                c.selection.clear();
1581                c.sel_transitions.clear();
1582            }
1583            for cid in band_ids.drain(..) {
1584                if !c.selection.contains(&cid) {
1585                    c.selection.push(cid);
1586                }
1587            }
1588            for tid in band_trans.drain(..) {
1589                if !c.sel_transitions.contains(&tid) {
1590                    c.sel_transitions.push(tid);
1591                }
1592            }
1593            state.band = None;
1594        }
1595    }
1596    // ---- insertion gutter: dragging clips past the first/last row of their kind ----
1597    if let Some(Drag { g: Gesture::Move { kind, new_track: true, .. }, .. }) = &state.drag {
1598        let g = if *kind == TrackKind::Video {
1599            Rect::from_min_max(lanes.min, pos2(lanes.right(), lanes.top() + GUTTER_H))
1600        } else {
1601            Rect::from_min_max(pos2(lanes.left(), lanes.bottom() - GUTTER_H), lanes.max)
1602        };
1603        lp.rect_filled(g, 0, pal.accent);
1604    }
1605
1606    // ---- pre-render bar: a thin strip along the very top of the ruler ----
1607    for &(a, b, ready) in c.prerender {
1608        let (xa, xb) = (state.x_at(a), state.x_at(b));
1609        let seg = Rect::from_min_max(pos2(xa.max(ruler.left()), ruler.top()), pos2(xb, ruler.top() + 3.0));
1610        if seg.is_positive() {
1611            let col = if ready { pal.selection } else { pal.text_dim };
1612            rp.rect_filled(seg, 0, col);
1613        }
1614    }
1615
1616    // ---- ruler ticks ----
1617    let (major, minor) = tick_step(state.zoom);
1618    let ratio = (major / minor).round() as i64;
1619    let t_end = state.time_at(ruler.right());
1620    let mut i = (state.scroll_x / minor).floor() as i64;
1621    loop {
1622        let t = i as f64 * minor;
1623        if t > t_end {
1624            break;
1625        }
1626        let x = state.x_at(t);
1627        if i % ratio == 0 {
1628            rp.vline(x, Rangef::new(ruler.bottom() - 8.0, ruler.bottom()), Stroke::new(1.0, pal.text_dim));
1629            rp.text(
1630                pos2(x + 3.0, ruler.top() + 1.0),
1631                Align2::LEFT_TOP,
1632                tick_label(t, major),
1633                small.clone(),
1634                pal.text_dim,
1635            );
1636        } else {
1637            rp.vline(x, Rangef::new(ruler.bottom() - 4.0, ruler.bottom()), thin);
1638        }
1639        i += 1;
1640    }
1641    painter.hline(ruler.x_range(), ruler.bottom() - 0.5, thin);
1642    painter.vline(header.right() - 0.5, full.y_range(), thin);
1643
1644    // ---- project markers on the ruler ----
1645    for m in &c.project.markers {
1646        let mx = state.x_at(m.t);
1647        if mx < ruler.left() - FLAG_W || mx > ruler.right() {
1648            continue;
1649        }
1650        let sel = state.selected_marker == Some(m.id);
1651        flag(&rp, mx, ruler.top() + 1.0, ruler.bottom(), label_color(c.project, m.label, pal.accent), sel);
1652        let mr = Rect::from_min_size(pos2(mx - 2.0, ruler.top()), vec2(FLAG_W + 4.0, RULER_H)).intersect(ruler);
1653        if mr.is_positive() {
1654            let r = ui.interact(mr, id.with(("mk", m.id)), Sense::click_and_drag());
1655            marker_hit(
1656                r,
1657                m,
1658                None,
1659                0.0,
1660                &mut state.selected_marker,
1661                &mut start_marker,
1662                &mut act,
1663                &mut rename,
1664                &mut seek_marker,
1665                labels,
1666            );
1667        }
1668    }
1669    let ph_now = *c.playhead;
1670    ruler_resp.context_menu(|ui| {
1671        if ui.button("Add Marker at Playhead").clicked() {
1672            act = Some(Act::AddMarker(ph_now));
1673        }
1674        ui.separator();
1675        paste_menu(ui, &mut out.actions);
1676    });
1677
1678    // ---- subtitle lane: the burned-in cues as little clips above the video rows ----
1679    state.sub_sel.retain(|id| c.project.subtitles.iter().any(|q| q.id == *id));
1680    if sub_h > 0.0 {
1681        enum SubAct {
1682            Convert(Vec<Id>),
1683            Delete(Vec<Id>),
1684            Split(Id),
1685            Range,
1686            Clear,
1687        }
1688        let mut sub_act: Option<SubAct> = None;
1689        let inout = match (c.project.in_point, c.project.out_point) {
1690            (Some(a), Some(b)) if b > a => Some((a, b)),
1691            _ => None,
1692        };
1693        // lane background first so the cue rects (registered later) win hit-testing
1694        let lane_resp = ui.interact(subs_lane, id.with("subs_lane"), Sense::click_and_drag());
1695        let sp = painter.with_clip_rect(subs_lane);
1696        sp.rect_filled(subs_lane, 0, pal.header.gamma_multiply(0.5));
1697        sp.hline(subs_lane.x_range(), subs_lane.bottom() - 0.5, thin);
1698        painter.text(
1699            pos2(full.left() + 6.0, subs_lane.center().y),
1700            Align2::LEFT_CENTER,
1701            "Subtitles",
1702            small.clone(),
1703            pal.text_dim,
1704        );
1705        // drag on empty lane = band-select over time (Shift adds); click = deselect
1706        if lane_resp.drag_started_by(egui::PointerButton::Primary) {
1707            if let Some(o) = lane_resp.interact_pointer_pos() {
1708                state.sub_band = Some((state.time_at(o.x), mods.shift));
1709            }
1710        }
1711        if lane_resp.clicked() {
1712            state.sub_sel.clear();
1713        }
1714        let band_range = state.sub_band.and_then(|(t0, _)| {
1715            let p = pointer?;
1716            let t1 = state.time_at(p.x);
1717            Some((t0.min(t1), t0.max(t1)))
1718        });
1719        if let Some((a, b)) = band_range {
1720            sp.rect_filled(
1721                Rect::from_x_y_ranges(state.x_at(a)..=state.x_at(b), subs_lane.y_range()),
1722                0,
1723                pal.selection.gamma_multiply(0.2),
1724            );
1725        }
1726        if state.sub_band.is_some() && !primary_down {
1727            let (_, add) = state.sub_band.take().expect("checked above");
1728            if let Some((a, b)) = band_range {
1729                let hit: Vec<Id> =
1730                    c.project.subtitles.iter().filter(|q| q.end > a && q.start < b).map(|q| q.id).collect();
1731                if !add {
1732                    state.sub_sel.clear();
1733                }
1734                for h in hit {
1735                    if !state.sub_sel.contains(&h) {
1736                        state.sub_sel.push(h);
1737                    }
1738                }
1739            }
1740        }
1741        for cue in &c.project.subtitles {
1742            let (xa, xb) = (state.x_at(cue.start), state.x_at(cue.end));
1743            if xb < subs_lane.left() || xa > subs_lane.right() {
1744                continue;
1745            }
1746            let rect =
1747                Rect::from_min_max(pos2(xa, subs_lane.top() + 2.0), pos2(xb.max(xa + 2.0), subs_lane.bottom() - 2.0));
1748            let r = ui.interact(rect.intersect(subs_lane), id.with(("sub", cue.id)), Sense::click());
1749            let sel = state.sub_sel.contains(&cue.id);
1750            let a = if sel {
1751                0.7
1752            } else if r.hovered() {
1753                0.55
1754            } else {
1755                0.3
1756            };
1757            sp.rect_filled(rect, 3.0, pal.selection.gamma_multiply(a));
1758            sp.rect_stroke(rect, 3.0, if sel { Stroke::new(1.5, pal.accent) } else { thin }, StrokeKind::Inside);
1759            sp.with_clip_rect(rect.intersect(subs_lane)).text(
1760                pos2(rect.left() + 3.0, rect.center().y),
1761                Align2::LEFT_CENTER,
1762                &cue.text,
1763                small.clone(),
1764                pal.text,
1765            );
1766            if r.clicked() {
1767                if mods.ctrl || mods.shift {
1768                    // Ctrl/Shift+click toggles membership, like clips
1769                    if sel {
1770                        state.sub_sel.retain(|q| *q != cue.id);
1771                    } else {
1772                        state.sub_sel.push(cue.id);
1773                    }
1774                } else {
1775                    state.sub_sel = vec![cue.id];
1776                    *c.playhead = cue.start;
1777                    out.seeked = true;
1778                }
1779            }
1780            // trim handles on both edges, like a clip's
1781            for right in [false, true] {
1782                let er = Rect::from_center_size(
1783                    pos2(if right { xb } else { xa }, rect.center().y),
1784                    vec2(6.0, rect.height()),
1785                );
1786                let e = ui
1787                    .interact(er.intersect(subs_lane), id.with(("sub_e", cue.id, right)), Sense::drag())
1788                    .on_hover_cursor(CursorIcon::ResizeHorizontal);
1789                if e.drag_started_by(egui::PointerButton::Primary) {
1790                    (c.undo)(c.project);
1791                    state.sub_trim = Some((cue.id, right));
1792                }
1793            }
1794            // right-click outside the selection retargets it, like every explorer
1795            if r.secondary_clicked() && !sel {
1796                state.sub_sel = vec![cue.id];
1797            }
1798            let targets: Vec<Id> = if sel && state.sub_sel.len() > 1 { state.sub_sel.clone() } else { vec![cue.id] };
1799            let n = targets.len();
1800            let plural = |what: &str| if n > 1 { format!("{what} ({n})") } else { what.to_string() };
1801            let cid = cue.id;
1802            let ph_in = *c.playhead > cue.start + 0.05 && *c.playhead < cue.end - 0.05;
1803            r.on_hover_text(&cue.text).context_menu(|ui| {
1804                if ui.add_enabled(ph_in, egui::Button::new("Split at Playhead")).on_hover_text("Ctrl+B").clicked() {
1805                    sub_act = Some(SubAct::Split(cid));
1806                    ui.close();
1807                }
1808                if ui.button(plural("Convert to Text Clip")).clicked() {
1809                    sub_act = Some(SubAct::Convert(targets.clone()));
1810                    ui.close();
1811                }
1812                if ui.button(plural("Delete Cue")).clicked() {
1813                    sub_act = Some(SubAct::Delete(targets.clone()));
1814                    ui.close();
1815                }
1816                ui.separator();
1817                if ui.add_enabled(inout.is_some(), egui::Button::new("Delete Cues in In/Out Range")).clicked() {
1818                    sub_act = Some(SubAct::Range);
1819                    ui.close();
1820                }
1821                if ui.button("Clear All Subtitles").clicked() {
1822                    sub_act = Some(SubAct::Clear);
1823                    ui.close();
1824                }
1825            });
1826        }
1827        // active edge drag: follow the pointer while held, sort on release
1828        if let Some((tid, right)) = state.sub_trim {
1829            if primary_down {
1830                if let (Some(p), Some(q)) = (pointer, c.project.subtitles.iter_mut().find(|q| q.id == tid)) {
1831                    let t = state.time_at(p.x).max(0.0);
1832                    if right {
1833                        q.end = t.max(q.start + 0.1);
1834                    } else {
1835                        q.start = t.clamp(0.0, q.end - 0.1);
1836                    }
1837                    out.edited = true;
1838                }
1839            } else {
1840                state.sub_trim = None;
1841                c.project.sort_cues();
1842                out.edited = true;
1843            }
1844        }
1845        if let Some(a) = sub_act {
1846            (c.undo)(c.project);
1847            match a {
1848                SubAct::Convert(ids) => {
1849                    c.project.cues_to_text_clips(Some(&ids));
1850                }
1851                SubAct::Split(id) => {
1852                    c.project.split_cue(id, *c.playhead);
1853                }
1854                SubAct::Delete(ids) => c.project.subtitles.retain(|q| !ids.contains(&q.id)),
1855                SubAct::Range => {
1856                    if let Some((a, b)) = inout {
1857                        c.project.subtitles.retain(|q| q.end <= a || q.start >= b);
1858                    }
1859                }
1860                SubAct::Clear => c.project.subtitles.clear(),
1861            }
1862            state.sub_sel.clear();
1863            out.edited = true;
1864        }
1865    }
1866
1867    // ---- playhead (recomputed: a double-click seek above moves it this frame) ----
1868    let px = state.x_at(*c.playhead);
1869    if px >= lanes.left() - 1.0 && px <= lanes.right() + 1.0 {
1870        let pp = painter.with_clip_rect(Rect::from_min_max(ruler.min, lanes.max));
1871        pp.vline(px, Rangef::new(ruler.top(), lanes.bottom()), Stroke::new(1.5, pal.playhead));
1872        pp.add(Shape::convex_polygon(
1873            vec![pos2(px - 5.0, ruler.top()), pos2(px + 5.0, ruler.top()), pos2(px, ruler.top() + 7.0)],
1874            pal.playhead,
1875            Stroke::NONE,
1876        ));
1877    }
1878    // scrub: ruler press/drag or playhead line drag
1879    let scrub_x = if primary_down && (ruler_resp.is_pointer_button_down_on() || ruler_resp.dragged()) {
1880        ruler_resp.interact_pointer_pos()
1881    } else if ph_resp.dragged_by(egui::PointerButton::Primary) {
1882        ph_resp.interact_pointer_pos()
1883    } else {
1884        None
1885    };
1886    if let Some(p) = scrub_x {
1887        *c.playhead = snap_playhead(state.time_at(p.x).max(0.0), c.snap, state.zoom, c.project);
1888        out.seeked = true;
1889    }
1890
1891    // ---- lanes background: deselect / rubber band / context menu / dnd ----
1892    if lanes_resp.clicked() && !mods.ctrl {
1893        // the marker tool also works on empty lanes; everything else just deselects
1894        match (c.tool, lanes_resp.interact_pointer_pos()) {
1895            (Tool::Marker, Some(pp)) => {
1896                let t = snap_time(state.time_at(pp.x), c.snap, state.zoom, c.project, *c.playhead, &[]);
1897                act = Some(Act::AddMarker(t.max(0.0)));
1898            }
1899            _ => {
1900                c.selection.clear();
1901                c.sel_transitions.clear();
1902            }
1903        }
1904    }
1905    // a press that missed every clip starts a rubber band (Shift adds to the selection)
1906    if state.drag.is_none() && lanes_resp.drag_started_by(egui::PointerButton::Primary) {
1907        if c.tool == Tool::Spacer {
1908            start_spacer = true;
1909        } else if let Some(o) = lanes_resp.interact_pointer_pos().or(pointer) {
1910            state.band = Some((o, mods.shift));
1911        }
1912    }
1913    lanes_resp.context_menu(|ui| {
1914        paste_menu(ui, &mut out.actions);
1915        ui.separator();
1916        if ui.button("Add Video Track").clicked() {
1917            act = Some(Act::AddTrack(TrackKind::Video));
1918        }
1919        if ui.button("Add Audio Track").clicked() {
1920            act = Some(Act::AddTrack(TrackKind::Audio));
1921        }
1922    });
1923    if let Some(pos) = pointer {
1924        let (snap_on, ph) = (c.snap, *c.playhead);
1925        let drop_t = move |state: &TimelineState, p: &Project| {
1926            snap_time(state.time_at(pos.x), snap_on, state.zoom, p, ph, &[]).max(0.0)
1927        };
1928        if let Some(payload) = lanes_resp.dnd_hover_payload::<DragPayload>() {
1929            let t = drop_t(state, c.project);
1930            // an effect lands on ONE clip and a transition on ONE cut, so they highlight what is under
1931            // the pointer instead of a ghost clip that would lie about a duration
1932            let target = match &*payload {
1933                DragPayload::Effect(k) => drop_on_clip(state, c.project, pos, t)
1934                    .filter(|(_, cl)| (cl.kind == ClipKind::Audio) == k.applies_to_audio())
1935                    .map(|(r, _)| r),
1936                DragPayload::Transition(_) => drop_on_clip(state, c.project, pos, t).map(|(r, cl)| {
1937                    // the half you are over picks the cut (same rule the app applies on release)
1938                    let x = if crate::ui::transitions_ui::drop_at_end(cl, t) { r.right() } else { r.left() };
1939                    Rect::from_min_max(pos2(x - 6.0, r.top()), pos2(x + 6.0, r.bottom()))
1940                }),
1941                _ => None,
1942            };
1943            if let Some(hit) = target {
1944                lp.rect_filled(hit, 0, pal.selection.gamma_multiply(0.35));
1945                lp.rect_stroke(hit, 0, Stroke::new(2.0, pal.selection), StrokeKind::Inside);
1946            } else if !matches!(&*payload, DragPayload::Effect(_) | DragPayload::Transition(_)) {
1947                let dur = match &*payload {
1948                    DragPayload::Asset(aid) => {
1949                        c.project.asset(*aid).map(|a| if a.kind == ClipKind::Image { 5.0 } else { a.duration })
1950                    }
1951                    DragPayload::Sequence(sid) => Some(c.project.sequence_duration(*sid)),
1952                    _ => None,
1953                }
1954                .unwrap_or(2.0);
1955                let ti = state.track_at(pos.y, c.project).or_else(|| c.project.video_tracks().first().copied());
1956                if let Some((ti, top)) = ti.and_then(|ti| Some((ti, row_top(state, c.project, ti)?))) {
1957                    let h = c.project.tracks[ti].height;
1958                    let ghost =
1959                        Rect::from_min_max(pos2(state.x_at(t), top + 1.0), pos2(state.x_at(t + dur), top + h - 1.0));
1960                    lp.rect_filled(ghost, 0, pal.selection.gamma_multiply(0.3));
1961                    lp.rect_stroke(ghost, 0, Stroke::new(1.0, pal.selection), StrokeKind::Inside);
1962                }
1963            }
1964        }
1965        if let Some(payload) = lanes_resp.dnd_release_payload::<DragPayload>() {
1966            let t = drop_t(state, c.project);
1967            let ti = state.track_at(pos.y, c.project);
1968            match &*payload {
1969                DragPayload::Asset(aid) => act = Some(Act::DropAsset(*aid, t, ti)),
1970                DragPayload::Path(p) => out.dropped_files.push((PathBuf::from(p), t, ti)),
1971                other => out.dropped_other.push((other.clone(), t, ti)),
1972            }
1973        }
1974    }
1975
1976    // ---- scrollbars ----
1977    {
1978        let hbar = Rect::from_min_max(pos2(lanes.left(), lanes.bottom()), pos2(full.right(), full.bottom()));
1979        painter.rect_filled(hbar, 0, pal.panel);
1980        let vis_w = (lanes.width() / state.zoom) as f64;
1981        let total = (c.project.duration() * 1.1).max(state.scroll_x + vis_w).max(1e-6);
1982        let max_sx = (total - vis_w).max(0.0);
1983        let bw = lanes.width().max(1.0);
1984        let tw = ((vis_w / total) as f32 * bw).clamp(20.0f32.min(bw), bw);
1985        let tx = lanes.left() + ((state.scroll_x / total) as f32 * bw).min(bw - tw).max(0.0);
1986        let thumb = Rect::from_min_max(pos2(tx, hbar.top() + 2.0), pos2(tx + tw, full.bottom() - 2.0));
1987        let hb = ui.interact(
1988            Rect::from_min_max(hbar.min, pos2(lanes.right(), full.bottom())),
1989            id.with("hbar"),
1990            Sense::click(),
1991        );
1992        let ht = ui.interact(thumb, id.with("hthumb"), Sense::drag());
1993        if ht.dragged() {
1994            state.scroll_x = (state.scroll_x + (ht.drag_delta().x / bw) as f64 * total).clamp(0.0, max_sx);
1995            state.user_panned = true;
1996        }
1997        if hb.clicked() {
1998            if let Some(p) = hb.interact_pointer_pos() {
1999                let dir = if p.x < thumb.left() {
2000                    -1.0
2001                } else if p.x > thumb.right() {
2002                    1.0
2003                } else {
2004                    0.0
2005                };
2006                state.scroll_x = (state.scroll_x + dir * vis_w).clamp(0.0, max_sx);
2007                state.user_panned = true;
2008            }
2009        }
2010        let fill = if ht.dragged() {
2011            pal.accent
2012        } else if ht.hovered() {
2013            pal.text_dim
2014        } else {
2015            pal.border
2016        };
2017        painter.rect_filled(thumb, CornerRadius::same(3), fill);
2018        if vbar_w > 0.0 {
2019            let vbar = Rect::from_min_max(pos2(lanes.right(), lanes.top()), pos2(full.right(), lanes.bottom()));
2020            painter.rect_filled(vbar, 0, pal.panel);
2021            let bh = vbar.height().max(1.0);
2022            let max_sy = (content_h - lanes.height()).max(0.0);
2023            let th = (lanes.height() / content_h * bh).clamp(20.0f32.min(bh), bh);
2024            let ty = vbar.top() + (state.scroll_y / content_h * bh).min(bh - th).max(0.0);
2025            let vthumb = Rect::from_min_max(pos2(vbar.left() + 2.0, ty), pos2(vbar.right() - 2.0, ty + th));
2026            let vb = ui.interact(vbar, id.with("vbar"), Sense::click());
2027            let vt = ui.interact(vthumb, id.with("vthumb"), Sense::drag());
2028            if vt.dragged() {
2029                state.scroll_y = (state.scroll_y + vt.drag_delta().y / bh * content_h).clamp(0.0, max_sy);
2030            }
2031            if vb.clicked() {
2032                if let Some(p) = vb.interact_pointer_pos() {
2033                    let dir = if p.y < vthumb.top() {
2034                        -1.0
2035                    } else if p.y > vthumb.bottom() {
2036                        1.0
2037                    } else {
2038                        0.0
2039                    };
2040                    state.scroll_y = (state.scroll_y + dir * lanes.height()).clamp(0.0, max_sy);
2041                }
2042            }
2043            let vfill = if vt.dragged() {
2044                pal.accent
2045            } else if vt.hovered() {
2046                pal.text_dim
2047            } else {
2048                pal.border
2049            };
2050            painter.rect_filled(vthumb, CornerRadius::same(3), vfill);
2051        }
2052    }
2053
2054    // ---- apply deferred bits ----
2055    if let Some((ti, dy)) = resize {
2056        // ponytail: track height is cosmetic — no undo snapshot, not marked as an edit
2057        let t = &mut c.project.tracks[ti];
2058        t.height = (t.height + dy).clamp(MIN_TRACK_H, MAX_TRACK_H);
2059    }
2060    if let Some(tid) = trans_click {
2061        // transitions select like clips: click replaces, Ctrl toggles; the clip selection makes way
2062        if mods.ctrl {
2063            match c.sel_transitions.iter().position(|&x| x == tid) {
2064                Some(i) => {
2065                    c.sel_transitions.remove(i);
2066                }
2067                None => c.sel_transitions.push(tid),
2068            }
2069        } else {
2070            *c.sel_transitions = vec![tid];
2071            c.selection.clear();
2072        }
2073    }
2074    if let Some(cid) = click {
2075        if !mods.ctrl {
2076            c.sel_transitions.clear();
2077        }
2078        // click = whole link group, Alt+click = just this clip, Ctrl toggles the group
2079        let group = if mods.alt { vec![cid] } else { c.project.expand_links(&[cid]) };
2080        if mods.ctrl {
2081            if c.selection.contains(&cid) {
2082                c.selection.retain(|x| !group.contains(x));
2083            } else {
2084                for g in group {
2085                    if !c.selection.contains(&g) {
2086                        c.selection.push(g);
2087                    }
2088                }
2089            }
2090        } else {
2091            *c.selection = group;
2092        }
2093    }
2094    if let Some(a) = act {
2095        let p = &mut *c.project;
2096        (c.undo)(p);
2097        let ids = p.expand_links(c.selection);
2098        match a {
2099            Act::SplitAt(t) => {
2100                p.split_at(t, None);
2101            }
2102            Act::Split => {
2103                p.split_at(*c.playhead, Some(&ids));
2104            }
2105            Act::Delete(ripple) => {
2106                p.delete_clips(&ids, ripple);
2107                c.selection.clear();
2108            }
2109            Act::Link => p.toggle_link(&ids),
2110            Act::Enable(on) => p.set_enabled(c.selection, on),
2111            Act::AddTrack(kind) => {
2112                p.add_track(kind);
2113            }
2114            Act::RemoveTrack(ti) => p.remove_track(ti),
2115            Act::Mute(ti) => p.tracks[ti].muted = !p.tracks[ti].muted,
2116            Act::Solo(ti) => p.tracks[ti].solo = !p.tracks[ti].solo,
2117            Act::DropAsset(aid, t, ti) => {
2118                let vt = ti.filter(|&i| p.tracks[i].kind == TrackKind::Video);
2119                p.insert_asset_clips(aid, t, vt);
2120            }
2121            Act::Label(l) => {
2122                for id in &ids {
2123                    if let Some(cl) = p.clip_mut(*id) {
2124                        cl.label = l;
2125                    }
2126                }
2127            }
2128            Act::LabelToAsset => {
2129                let pairs: Vec<(Id, u8)> = ids
2130                    .iter()
2131                    .filter_map(|&id| p.clip(id).filter(|cl| cl.uses_asset()).map(|cl| (cl.asset, p.clip_label(cl))))
2132                    .collect();
2133                for (aid, l) in pairs {
2134                    if let Some(a) = p.asset_mut(aid) {
2135                        a.label = l;
2136                    }
2137                }
2138            }
2139            Act::SetEase(cid, t, e) => {
2140                if let Some(cl) = p.clip_mut(cid) {
2141                    for a in cl.all_animated_mut() {
2142                        a.set_ease_at(t, e);
2143                    }
2144                }
2145            }
2146            Act::DelKeys(cid, t) => {
2147                if let Some(cl) = p.clip_mut(cid) {
2148                    for a in cl.all_animated_mut() {
2149                        if a.has_key_at(t) {
2150                            a.toggle_key(t);
2151                        }
2152                    }
2153                }
2154            }
2155            Act::RemoveTransition(tid) => p.remove_transition(tid),
2156            Act::RemoveTransitions(tids) => {
2157                for tid in tids {
2158                    p.remove_transition(tid);
2159                }
2160                c.sel_transitions.clear();
2161            }
2162            Act::AddMarker(t) => {
2163                let mid = p.add_marker(t, "Marker");
2164                state.selected_marker = Some(mid);
2165            }
2166            Act::RenameMarker(mid, name) => {
2167                if let Some(m) = p.marker_mut(mid) {
2168                    m.name = name;
2169                }
2170            }
2171            Act::DelMarker(mid) => {
2172                p.remove_marker(mid);
2173                state.selected_marker = None;
2174            }
2175            Act::MarkerLabel(mid, l) => {
2176                if let Some(m) = p.marker_mut(mid) {
2177                    m.label = l;
2178                }
2179            }
2180            Act::Bus(b) => {
2181                for id in &ids {
2182                    if let Some(cl) = p.clip_mut(*id).filter(|cl| cl.kind == ClipKind::Audio) {
2183                        cl.bus = b;
2184                    }
2185                }
2186            }
2187            Act::ReplaceContainerMedia(cid) => {
2188                out.replace_container = Some((cid, false));
2189            }
2190            Act::ReplaceContainerPair(cid) => {
2191                out.replace_container = Some((cid, true));
2192            }
2193            Act::MakeContainer => {
2194                p.make_container(&ids);
2195            }
2196            Act::UnmakeContainer => {
2197                p.unmake_container(&ids);
2198            }
2199            Act::RenameContainer(cid, name) => {
2200                if let Some(cl) = p.clip_mut(cid) {
2201                    cl.container_label = name;
2202                }
2203            }
2204        }
2205        out.edited = true;
2206    }
2207    state.rename = rename;
2208    if let Some(t) = seek_marker {
2209        *c.playhead = c.project.snap_frame(t.max(0.0));
2210        out.seeked = true;
2211    }
2212
2213    // ---- gestures ----
2214    let origin = ui.input(|i| i.pointer.press_origin()).or(pointer).unwrap_or(Pos2::ZERO);
2215    // with the spacer tool every lane press is a gap gesture, clip bodies and edges included
2216    let spacer = c.tool == Tool::Spacer && (start_spacer || start_move.is_some() || start_trim.is_some());
2217    if let Some(cid) = start_move.or(start_trim.map(|(id, _)| id)).filter(|_| !spacer) {
2218        if !c.selection.contains(&cid) {
2219            if !mods.ctrl {
2220                c.selection.clear();
2221            }
2222            c.selection.push(cid);
2223        }
2224    }
2225    if spacer {
2226        let t0 = state.time_at(origin.x).max(0.0);
2227        let ids: Vec<Id> = c.project.all_clips().filter(|(_, cl)| cl.start >= t0).map(|(_, cl)| cl.id).collect();
2228        // how far left the whole group can go: the tightest gap in front of it, per track
2229        let room = c
2230            .project
2231            .tracks
2232            .iter()
2233            .filter_map(|t| {
2234                let first = t.clips.iter().map(|cl| cl.start).filter(|&s| s >= t0).fold(f64::INFINITY, f64::min);
2235                let prev = t.clips.iter().filter(|cl| cl.start < t0).map(|cl| cl.end()).fold(0.0, f64::max);
2236                first.is_finite().then_some(first - prev)
2237            })
2238            .fold(f64::INFINITY, f64::min);
2239        let room = if room.is_finite() { room.max(0.0) } else { 0.0 };
2240        state.drag = Some(Drag { origin, before: c.project.clone(), g: Gesture::Spacer { ids, dt: 0.0, room } });
2241    } else if let Some(cid) = start_move {
2242        if let Some(tr) = c.project.track_of(cid) {
2243            let ids = c.project.expand_links(c.selection);
2244            let orig = ids.iter().map(|&id| c.project.clip(id).map(|cl| cl.start).unwrap_or(0.0)).collect();
2245            let kind = c.project.tracks[tr].kind;
2246            let g = Gesture::Move { ids, orig, kind, tr, dt: 0.0, dtrack: 0, new_track: false };
2247            state.drag = Some(Drag { origin, before: c.project.clone(), g });
2248        }
2249    } else if let Some((cid, start)) = start_trim {
2250        if let Some(clip) = c.project.clip(cid) {
2251            let edge = if start { clip.start } else { clip.end() };
2252            // linked clips trim together when their edge coincides
2253            let ids: Vec<Id> = c
2254                .project
2255                .linked(cid)
2256                .into_iter()
2257                .filter(|&id| {
2258                    c.project
2259                        .clip(id)
2260                        .map_or(false, |cl| ((if start { cl.start } else { cl.end() }) - edge).abs() < 1e-6)
2261                })
2262                .collect();
2263            let g = if c.tool == Tool::Stretch {
2264                Gesture::Stretch { id: cid, start, edge, src_len: clip.src_len(), changed: false }
2265            } else {
2266                Gesture::Trim { ids, start, edge, changed: false }
2267            };
2268            state.drag = Some(Drag { origin, before: c.project.clone(), g });
2269        }
2270    } else if let Some(cid) = start_vol {
2271        state.drag = Some(Drag { origin, before: c.project.clone(), g: Gesture::Volume { id: cid, changed: false } });
2272    } else if let Some((cid, fout)) = start_fade {
2273        state.drag =
2274            Some(Drag { origin, before: c.project.clone(), g: Gesture::Fade { id: cid, out: fout, changed: false } });
2275    } else if let Some((cid, kt, prop)) = start_key {
2276        // freeze the value scale at press time: dragging a key must not move the scale under itself
2277        let range = prop
2278            .and_then(|pi| c.project.clip(cid).and_then(|cl| crate::ui::curves::prop_ref(cl, pi)))
2279            .map(crate::ui::curves::y_range)
2280            .unwrap_or((0.0, 1.0));
2281        state.drag = Some(Drag {
2282            origin,
2283            before: c.project.clone(),
2284            g: Gesture::Keys { id: cid, t: kt, prop, range, changed: false },
2285        });
2286    } else if let Some((tri, tid)) = start_trans {
2287        state.drag = Some(Drag {
2288            origin,
2289            before: c.project.clone(),
2290            g: Gesture::TransDur { track: tri, id: tid, changed: false },
2291        });
2292    } else if let Some((mid, mclip)) = start_marker {
2293        state.drag = Some(Drag {
2294            origin,
2295            before: c.project.clone(),
2296            g: Gesture::Marker { id: mid, clip: mclip, changed: false },
2297        });
2298    }
2299    let hover_tr = pointer.and_then(|pos| state.track_at(pos.y, c.project));
2300    // scalar copies + closures so the gesture arms can use geometry while `state.drag` is mutably borrowed
2301    let (lx, sx, sy, ltop) = (lanes.left(), state.scroll_x, state.scroll_y, lanes.top());
2302    let zoom0 = state.zoom;
2303    let t_at = move |x: f32| sx + ((x - lx) / zoom0) as f64;
2304    let row_top_of = move |p: &Project, ti: usize| -> Option<f32> {
2305        let mut top = ltop - sy;
2306        for i in row_order(p) {
2307            if i == ti {
2308                return Some(top);
2309            }
2310            top += p.tracks[i].height;
2311        }
2312        None
2313    };
2314    if let (Some(drag), Some(pos)) = (state.drag.as_mut(), pointer) {
2315        let zoom = zoom0;
2316        let dx = (pos.x - drag.origin.x) as f64 / zoom as f64;
2317        let ox = drag.origin.x; // grab point (auto-scroll compensates it), for gestures that edit at one time
2318        let thr = (SNAP_PX / zoom) as f64;
2319        let p = &mut *c.project;
2320        match &mut drag.g {
2321            Gesture::Move { ids, orig, kind, tr, dt, dtrack, new_track } => {
2322                // past the first video row (up) or the last audio row (down): offer a fresh track.
2323                // Armed off the painted gutter bands, not the first/last row — those are scrolled away
2324                // once the lanes scroll, which used to make the gesture unreachable.
2325                *new_track = match kind {
2326                    TrackKind::Video => pos.y < lanes.top() + GUTTER_H,
2327                    TrackKind::Audio => pos.y > lanes.bottom() - GUTTER_H,
2328                };
2329                let mut want = dx;
2330                if c.snap {
2331                    let s0 = orig.first().copied().unwrap_or(0.0);
2332                    want = p.snap_frame(s0 + want) - s0;
2333                    let mut best: Option<f64> = None;
2334                    for (&id, &s) in ids.iter().zip(orig.iter()) {
2335                        let Some(cl) = p.clip(id) else { continue };
2336                        for edge in [s + want, s + want + cl.duration] {
2337                            if let Some(tgt) = snap_target(edge, thr, p, *c.playhead, ids) {
2338                                let adj = tgt - edge;
2339                                if best.map_or(true, |b: f64| adj.abs() < b.abs()) {
2340                                    best = Some(adj);
2341                                }
2342                            }
2343                        }
2344                    }
2345                    want += best.unwrap_or(0.0);
2346                }
2347                let min_start = orig.iter().copied().fold(f64::INFINITY, f64::min);
2348                want = want.max(-min_start);
2349                // destination row = same-kind track under the pointer (keeps the last one while outside)
2350                let list = if *kind == TrackKind::Video { p.video_tracks() } else { p.audio_tracks() };
2351                let pos_of = |t: usize| list.iter().position(|&x| x == t);
2352                let want_tr = match (hover_tr.and_then(pos_of), pos_of(*tr)) {
2353                    (Some(h), Some(o)) => h as i32 - o as i32,
2354                    _ => *dtrack,
2355                };
2356                let (ddt, ddtr) = (want - *dt, want_tr - *dtrack);
2357                if ddt.abs() > 1e-9 || ddtr != 0 {
2358                    if p.move_clips(ids, ddt, ddtr, Some(*kind)) {
2359                        *dt = want;
2360                        *dtrack = want_tr;
2361                    } else if ddtr != 0 && ddt.abs() > 1e-9 {
2362                        if p.move_clips(ids, ddt, 0, Some(*kind)) {
2363                            *dt = want;
2364                        } else if p.move_clips(ids, 0.0, ddtr, Some(*kind)) {
2365                            *dtrack = want_tr;
2366                        }
2367                    }
2368                }
2369            }
2370            Gesture::Trim { ids, start, edge, changed } => {
2371                let mut want = *edge + dx;
2372                if c.snap {
2373                    want = p.snap_frame(want);
2374                    if let Some(t) = snap_target(want, thr, p, *c.playhead, ids) {
2375                        want = t;
2376                    }
2377                }
2378                // all-or-nothing (like move_clips): linked clips keep identical extents when one is blocked
2379                let mut upd = Vec::with_capacity(ids.len());
2380                for &id in ids.iter() {
2381                    let Some((ti, ci)) = p.find(id) else { continue };
2382                    let mut tmp = p.tracks[ti].clips[ci].clone();
2383                    if *start {
2384                        let hr = p.head_room(&tmp);
2385                        tmp.trim_start(want, hr);
2386                    } else {
2387                        let md = p.max_clip_duration(&tmp);
2388                        tmp.trim_end(want, md);
2389                    }
2390                    if !p.tracks[ti].fits(tmp.start, tmp.duration, &[id]) {
2391                        upd.clear();
2392                        break;
2393                    }
2394                    upd.push((ti, ci, tmp));
2395                }
2396                for (ti, ci, tmp) in upd {
2397                    let cl = &p.tracks[ti].clips[ci];
2398                    if cl.start != tmp.start || cl.duration != tmp.duration {
2399                        p.tracks[ti].clips[ci] = tmp;
2400                        *changed = true;
2401                    }
2402                }
2403            }
2404            Gesture::Stretch { id, start, edge, src_len, changed } => {
2405                let mut want = *edge + dx;
2406                if c.snap {
2407                    want = p.snap_frame(want);
2408                    if let Some(t) = snap_target(want, thr, p, *c.playhead, &[*id]) {
2409                        want = t;
2410                    }
2411                }
2412                if let Some((ti, ci)) = p.find(*id) {
2413                    let cl = &p.tracks[ti].clips[ci];
2414                    let (new_start, dur) = if *start {
2415                        let w = want.clamp(0.0, cl.end() - crate::model::MIN_CLIP);
2416                        (w, cl.end() - w)
2417                    } else {
2418                        (cl.start, want - cl.start)
2419                    };
2420                    let dur = dur.max(crate::model::MIN_CLIP);
2421                    if p.tracks[ti].fits(new_start, dur, &[*id]) {
2422                        let cl = &mut p.tracks[ti].clips[ci];
2423                        if (cl.duration - dur).abs() > 1e-9 || (cl.start - new_start).abs() > 1e-9 {
2424                            // speed = source seconds per timeline second: the window is untouched
2425                            cl.set_speed(*src_len / dur);
2426                            cl.start = new_start;
2427                            *changed = true;
2428                        }
2429                    }
2430                }
2431            }
2432            Gesture::Volume { id, changed } => {
2433                if let Some((ti, ci)) = p.find(*id) {
2434                    if let Some(top) = row_top_of(p, ti) {
2435                        let h = (p.tracks[ti].height - 2.0).max(1.0);
2436                        let frac = ((top + p.tracks[ti].height - 1.0 - pos.y) / h).clamp(0.0, 1.0);
2437                        let db = frac_db(frac);
2438                        let gain = if db <= DB_BOT + 0.25 { 0.0 } else { 10f64.powf(db as f64 / 20.0) };
2439                        let cl = &mut p.tracks[ti].clips[ci];
2440                        if cl.volume.is_animated() {
2441                            // latch to the grab time: editing at the live x inserts a key per frame of the drag
2442                            let lt = (t_at(ox) - cl.start).clamp(0.0, cl.duration);
2443                            cl.volume.set_at(lt, gain);
2444                            *changed = true;
2445                        } else if (cl.volume.value - gain).abs() > 1e-9 {
2446                            cl.volume.value = gain;
2447                            *changed = true;
2448                        }
2449                    }
2450                }
2451            }
2452            Gesture::Fade { id, out: fout, changed } => {
2453                if let Some(cl) = p.clip_mut(*id) {
2454                    let t = t_at(pos.x);
2455                    let v = if *fout {
2456                        (cl.end() - t).clamp(0.0, cl.duration)
2457                    } else {
2458                        (t - cl.start).clamp(0.0, cl.duration)
2459                    };
2460                    let dst = if *fout { &mut cl.fade_out } else { &mut cl.fade_in };
2461                    if (*dst - v).abs() > 1e-9 {
2462                        *dst = v;
2463                        *changed = true;
2464                    }
2465                }
2466            }
2467            Gesture::Keys { id, t, prop, range, changed } => {
2468                let nt = p.snap_frame(t_at(pos.x));
2469                if let Some(cl) = p.clip_mut(*id) {
2470                    let lt = (nt - cl.start).clamp(0.0, cl.duration);
2471                    if (lt - *t).abs() > 1e-9 {
2472                        cl.move_keys(*t, lt);
2473                        *t = lt;
2474                        *changed = true;
2475                    }
2476                }
2477                // value lane: y inside the clip rect → value, through the range frozen at press time
2478                if let (Some(pi), Some((ti, ci))) = (*prop, p.find(*id)) {
2479                    if let Some(top) = row_top_of(p, ti) {
2480                        let h = p.tracks[ti].height;
2481                        let inner = (h - 2.0 - 2.0 * KEY_PAD).max(1.0);
2482                        let f = (((top + h - 1.0 - KEY_PAD) - pos.y) / inner).clamp(0.0, 1.0) as f64;
2483                        let v = range.0 + f * (range.1 - range.0);
2484                        // the lane auto-scales past the property's own bounds (y_range pads by 10 %) —
2485                        // clamp the written value the way every DragValue for it does
2486                        let v = prop_range(&p.tracks[ti].clips[ci], pi).map_or(v, |(lo, hi)| v.clamp(lo, hi));
2487                        if let Some(a) = crate::ui::curves::prop_mut(&mut p.tracks[ti].clips[ci], pi) {
2488                            if let Some(ki) = a.key_index_at(*t) {
2489                                if (a.keys[ki].v - v).abs() > 1e-9 {
2490                                    a.keys[ki].v = v;
2491                                    *changed = true;
2492                                }
2493                            }
2494                        }
2495                    }
2496                }
2497            }
2498            Gesture::TransDur { track, id, changed } => {
2499                // the project can be replaced mid-drag (undo, MCP project.open/sequence.open): index may be stale
2500                if let Some(tr) = p.tracks.get_mut(*track) {
2501                    // duration follows the dragged edge: distance from the anchor (the cut, or the
2502                    // clip edge the transition hangs off), capped by what the clip(s) can supply
2503                    let lim = tr.transitions.iter().find(|t| t.id == *id).and_then(|t| {
2504                        let (l, r) = tr.transition_clips(t)?;
2505                        Some(match t.edge {
2506                            crate::model::TransitionEdge::Cut => (r?.start, 2.0, 2.0 * l?.duration.min(r?.duration)),
2507                            crate::model::TransitionEdge::In => (r?.start, 1.0, r?.duration),
2508                            crate::model::TransitionEdge::Out => (l?.end(), 1.0, l?.duration),
2509                        })
2510                    });
2511                    if let Some((anchor, scale, max)) = lim {
2512                        // cap: the Transitions panel's 0.1..5 s range, and what the clip(s) can actually supply
2513                        let d = ((t_at(pos.x) - anchor).abs() * scale).min(max).min(5.0).max(0.1);
2514                        if let Some(t) = tr.transitions.iter_mut().find(|t| t.id == *id) {
2515                            if (t.duration - d).abs() > 1e-9 {
2516                                t.duration = d;
2517                                *changed = true;
2518                            }
2519                        }
2520                    }
2521                }
2522            }
2523            Gesture::Marker { id, clip, changed } => {
2524                // markers always land on a frame; snapping additionally pulls them onto clip edges
2525                let want = p.snap_frame(t_at(pos.x).max(0.0));
2526                let want = if c.snap { snap_target(want, thr, p, *c.playhead, &[]).unwrap_or(want) } else { want };
2527                // clip markers are clip-local and stay inside their clip
2528                let nt = match clip.and_then(|cid| p.clip(cid)) {
2529                    Some(cl) => (want - cl.start).clamp(0.0, cl.duration),
2530                    None => want,
2531                };
2532                if let Some(m) = p.marker_mut(*id) {
2533                    if (m.t - nt).abs() > 1e-9 {
2534                        m.t = nt;
2535                        *changed = true;
2536                    }
2537                }
2538                if clip.is_none() && *changed {
2539                    p.sort_markers();
2540                }
2541            }
2542            Gesture::Spacer { ids, dt, room } => {
2543                // move_clips is all-or-nothing, so the clip in front of the group is never overrun
2544                let mut want = if c.snap { p.snap_frame(dx) } else { dx };
2545                if c.snap {
2546                    // what the user watches move is the group's leading edge: snap that, not the raw delta
2547                    let now = ids.iter().filter_map(|&id| p.clip(id)).map(|cl| cl.start).fold(f64::INFINITY, f64::min);
2548                    let lead = now - *dt; // where that edge sat before the gesture
2549                    if lead.is_finite() {
2550                        if let Some(t) = snap_target(lead + want, thr, p, *c.playhead, ids) {
2551                            want = t - lead;
2552                        }
2553                    }
2554                }
2555                let want = want.max(-*room);
2556                if (want - *dt).abs() > 1e-9 && p.move_clips(ids, want - *dt, 0, None) {
2557                    *dt = want;
2558                }
2559            }
2560        }
2561    }
2562    // ---- edge auto-scroll while dragging (playhead scrub, move/trim, keyframes, …) ----
2563    if state.drag.is_some() || scrub_x.is_some() {
2564        if let Some(pos) = pointer {
2565            let over = if pos.x < lanes.left() + SCROLL_MARGIN {
2566                pos.x - (lanes.left() + SCROLL_MARGIN)
2567            } else if pos.x > lanes.right() - SCROLL_MARGIN {
2568                pos.x - (lanes.right() - SCROLL_MARGIN)
2569            } else {
2570                0.0
2571            };
2572            if over != 0.0 {
2573                let ds = (over as f64 * 0.2) / state.zoom as f64; // ~proportional to the overshoot, per frame
2574                let ns = (state.scroll_x + ds).max(0.0);
2575                let applied = ns - state.scroll_x;
2576                if applied != 0.0 {
2577                    state.scroll_x = ns;
2578                    state.user_panned = true;
2579                    // keep pixel-based gestures (move/trim use pointer - origin) tracking the scrolled content
2580                    if let Some(d) = state.drag.as_mut() {
2581                        d.origin.x -= (applied * state.zoom as f64) as f32;
2582                    }
2583                    ui.ctx().request_repaint();
2584                }
2585            }
2586        }
2587    }
2588    if !primary_down {
2589        if let Some(d) = state.drag.take() {
2590            let mut edited = match &d.g {
2591                Gesture::Move { dt, dtrack, .. } => *dt != 0.0 || *dtrack != 0,
2592                Gesture::Spacer { dt, .. } => *dt != 0.0,
2593                Gesture::Trim { changed, .. }
2594                | Gesture::Stretch { changed, .. }
2595                | Gesture::Volume { changed, .. }
2596                | Gesture::Fade { changed, .. }
2597                | Gesture::Keys { changed, .. }
2598                | Gesture::TransDur { changed, .. }
2599                | Gesture::Marker { changed, .. } => *changed,
2600            };
2601            // released over the gutter: add a track at the far end and drop the clips of that kind on it
2602            if let Gesture::Move { ids, kind, new_track: true, .. } = &d.g {
2603                let p = &mut *c.project;
2604                let ti = p.add_track(*kind);
2605                let list = if *kind == TrackKind::Video { p.video_tracks() } else { p.audio_tracks() };
2606                let pos_of = |t: usize| list.iter().position(|&x| x == t).map(|i| i as i32);
2607                let from =
2608                    ids.iter().filter_map(|&cid| p.track_of(cid)).find(|&t| p.tracks[t].kind == *kind).and_then(pos_of);
2609                match from.zip(pos_of(ti)).filter(|(f, to)| f != to) {
2610                    Some((f, to)) if p.move_clips(ids, 0.0, to - f, Some(*kind)) => edited = true,
2611                    _ => p.remove_track(ti),
2612                }
2613            }
2614            if edited {
2615                (c.undo)(&d.before);
2616                out.edited = true;
2617            }
2618        }
2619    }
2620    out
2621}
2622
2623#[cfg(test)]
2624mod tests {
2625    use super::*;
2626    use crate::model::Track;
2627
2628    #[test]
2629    fn nearest_within_threshold() {
2630        let cands = [0.0, 1.0, 2.5, 10.0];
2631        assert_eq!(nearest(1.1, 0.2, cands.iter().copied()), Some(1.0));
2632        assert_eq!(nearest(1.8, 0.5, cands.iter().copied()), None);
2633        assert_eq!(nearest(1.8, 0.8, cands.iter().copied()), Some(2.5));
2634        assert_eq!(nearest(0.04, 0.05, cands.iter().copied()), Some(0.0));
2635        assert_eq!(nearest(5.0, 100.0, [].into_iter()), None);
2636    }
2637
2638    #[test]
2639    fn snap_targets_exclude_moving_clips() {
2640        let mut p = Project::new();
2641        p.tracks[0].clips.push(Clip::new(7, ClipKind::Video, "a", 2.0, 3.0));
2642        p.tracks[0].clips.push(Clip::new(8, ClipKind::Video, "b", 6.0, 1.0));
2643        // end of clip 7 = 5.0 is the nearest target
2644        assert_eq!(snap_target(5.1, 0.2, &p, 20.0, &[]), Some(5.0));
2645        // excluded → falls back to nothing in range
2646        assert_eq!(snap_target(5.1, 0.2, &p, 20.0, &[7]), None);
2647        // playhead and 0 count too
2648        assert_eq!(snap_target(19.9, 0.2, &p, 20.0, &[]), Some(20.0));
2649        assert_eq!(snap_target(0.1, 0.2, &p, 20.0, &[]), Some(0.0));
2650    }
2651
2652    #[test]
2653    fn tick_spacing_scales_with_zoom() {
2654        assert_eq!(tick_step(40.0), (2.0, 0.5));
2655        assert_eq!(tick_step(2000.0), (0.05, 0.01));
2656        assert_eq!(tick_step(0.5), (300.0, 60.0));
2657        let mut last = 0.0;
2658        for z in [0.5, 1.0, 5.0, 40.0, 200.0, 2000.0] {
2659            let (major, minor) = tick_step(z);
2660            assert!(major * z as f64 >= 80.0 || major == 3600.0);
2661            assert!(major > minor && ((major / minor) - (major / minor).round()).abs() < 1e-9);
2662            assert!(major <= last || last == 0.0);
2663            last = major;
2664        }
2665        assert_eq!(tick_label(65.0, 5.0), "1:05");
2666        assert_eq!(tick_label(2.5, 0.5), "0:02.5");
2667        assert_eq!(tick_label(0.25, 0.05), "0:00.25");
2668    }
2669
2670    #[test]
2671    fn track_at_maps_rows() {
2672        let mut p = Project::new(); // V1 A1
2673        p.add_track(TrackKind::Video); // V2 at index 1
2674        p.add_track(TrackKind::Audio); // A2 at index 3
2675        assert_eq!(
2676            p.tracks.iter().map(|t| t.kind).collect::<Vec<_>>(),
2677            [TrackKind::Video, TrackKind::Video, TrackKind::Audio, TrackKind::Audio]
2678        );
2679        let heights = [50.0, 70.0, 40.0, 60.0];
2680        for (t, h) in p.tracks.iter_mut().zip(heights) {
2681            t.height = h;
2682        }
2683        let mut s = TimelineState {
2684            lanes_rect: Rect::from_min_max(pos2(100.0, 200.0), pos2(900.0, 500.0)),
2685            ..Default::default()
2686        };
2687        // display order: V2 (70) V1 (50) A1 (40) A2 (60)
2688        assert_eq!(row_order(&p).collect::<Vec<_>>(), [1, 0, 2, 3]);
2689        assert_eq!(s.track_at(210.0, &p), Some(1));
2690        assert_eq!(s.track_at(269.9, &p), Some(1));
2691        assert_eq!(s.track_at(270.0, &p), Some(0));
2692        assert_eq!(s.track_at(330.0, &p), Some(2));
2693        assert_eq!(s.track_at(375.0, &p), Some(3));
2694        assert_eq!(s.track_at(420.0, &p), None);
2695        assert_eq!(s.track_at(150.0, &p), None); // above the lanes (ruler)
2696        s.scroll_y = 100.0;
2697        assert_eq!(s.track_at(210.0, &p), Some(0));
2698        assert_eq!(row_top(&s, &p, 2), Some(220.0));
2699        let _ = Track::new(1, TrackKind::Video, "x");
2700    }
2701
2702    #[test]
2703    fn time_x_roundtrip() {
2704        let mut s = TimelineState {
2705            lanes_rect: Rect::from_min_max(pos2(100.0, 0.0), pos2(900.0, 100.0)),
2706            ..Default::default()
2707        };
2708        s.zoom = 50.0;
2709        s.scroll_x = 2.0;
2710        assert!((s.time_at(s.x_at(7.25)) - 7.25).abs() < 1e-4);
2711        assert_eq!(s.x_at(2.0), 100.0);
2712    }
2713
2714    #[test]
2715    fn ensure_visible_follows_unless_user_panned() {
2716        let mut s = TimelineState {
2717            lanes_rect: Rect::from_min_max(pos2(100.0, 0.0), pos2(900.0, 100.0)),
2718            zoom: 40.0, // 20 s visible
2719            scroll_x: 30.0,
2720            ..Default::default()
2721        };
2722        s.ensure_visible(2.0);
2723        assert_eq!(s.scroll_x, 0.0);
2724        s.scroll_x = 30.0;
2725        s.user_panned = true;
2726        s.ensure_visible(2.0);
2727        assert_eq!(s.scroll_x, 30.0, "panned away: no snap back");
2728        s.ensure_visible(35.0);
2729        assert!(!s.user_panned, "playhead back in view resumes following");
2730        s.ensure_visible(60.0);
2731        assert_eq!(s.scroll_x, 58.0);
2732    }
2733
2734    // ---- headless egui harness: real layout + hit-testing + gestures ----
2735    use crate::media::Backend;
2736    use crate::model::{Asset, AudioStreamInfo, Effect, EffectKind};
2737    use egui::{Event, Modifiers, PointerButton, RawInput};
2738
2739    struct Harness {
2740        ctx: egui::Context,
2741        state: TimelineState,
2742        project: Project,
2743        selection: Vec<Id>,
2744        sel_transitions: Vec<Id>,
2745        playhead: f64,
2746        undos: usize,
2747        waves: WaveformCache,
2748        tool: Tool,
2749        snap: bool,
2750        time: f64,
2751        /// Paint list of the last frame (asserting on what was actually drawn).
2752        shapes: Vec<egui::epaint::ClippedShape>,
2753    }
2754
2755    impl Harness {
2756        fn new() -> Self {
2757            let ctx = egui::Context::default();
2758            let mut project = Project::new();
2759            let aid = project.add_asset(Asset {
2760                id: 0,
2761                path: "C:/x.mp4".into(),
2762                kind: ClipKind::Video,
2763                duration: 10.0,
2764                width: 1280,
2765                height: 720,
2766                fps: 30.0,
2767                audio_streams: vec![AudioStreamInfo { channels: 2, sample_rate: 48000, ..Default::default() }],
2768                codec: "h264".into(),
2769                folder: String::new(),
2770                tags: Vec::new(),
2771                label: 0,
2772                description: String::new(),
2773            });
2774            project.insert_asset_clips(aid, 0.0, None);
2775            let waves = WaveformCache::new(ctx.clone(), Backend::Ffmpeg);
2776            let mut h = Self {
2777                ctx,
2778                state: TimelineState::default(),
2779                project,
2780                selection: Vec::new(),
2781                sel_transitions: Vec::new(),
2782                playhead: 0.0,
2783                undos: 0,
2784                waves,
2785                tool: Tool::Select,
2786                snap: false,
2787                time: 0.0,
2788                shapes: Vec::new(),
2789            };
2790            h.frame(vec![]); // layout pass: sets lanes_rect
2791            h
2792        }
2793        fn frame(&mut self, events: Vec<Event>) -> TimelineResponse {
2794            self.frame_m(events, Modifiers::NONE)
2795        }
2796        fn frame_m(&mut self, events: Vec<Event>, mods: Modifiers) -> TimelineResponse {
2797            self.time += 0.05;
2798            let input = RawInput {
2799                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 400.0))),
2800                time: Some(self.time),
2801                modifiers: mods,
2802                events,
2803                ..Default::default()
2804            };
2805            let pal = Palette::new(true, Color32::from_rgb(0, 120, 212));
2806            let Harness { ctx, state, project, selection, sel_transitions, playhead, undos, waves, tool, snap, .. } =
2807                self;
2808            let mut resp = None;
2809            let full = ctx.run(input, |ctx| {
2810                egui::CentralPanel::default().show(ctx, |ui| {
2811                    let mut undo = |_: &Project| *undos += 1;
2812                    resp = Some(show(
2813                        ui,
2814                        state,
2815                        TimelineCtx {
2816                            project,
2817                            selection,
2818                            sel_transitions,
2819                            playhead,
2820                            undo: &mut undo,
2821                            waveforms: waves,
2822                            palette: &pal,
2823                            snap: *snap,
2824                            playing: false,
2825                            thumbs: None,
2826                            keep_ranges: &[],
2827                            prerender: &[],
2828                            tool: *tool,
2829                        },
2830                    ));
2831                });
2832            });
2833            self.shapes = full.shapes;
2834            resp.unwrap()
2835        }
2836        /// Every text painted last frame, with its top-left position.
2837        fn texts(&self) -> Vec<(String, Pos2)> {
2838            self.shapes
2839                .iter()
2840                .filter_map(|cs| match &cs.shape {
2841                    Shape::Text(t) => Some((t.galley.text().to_string(), t.pos)),
2842                    _ => None,
2843                })
2844                .collect()
2845        }
2846        fn painted_text(&self, needle: &str) -> Option<Pos2> {
2847            self.texts().into_iter().find(|(s, _)| s.contains(needle)).map(|(_, p)| p)
2848        }
2849        /// Is any filled rect painted in this colour?
2850        fn has_fill(&self, color: Color32) -> bool {
2851            self.shapes.iter().any(|cs| matches!(&cs.shape, Shape::Rect(r) if r.fill == color))
2852        }
2853        fn press(&mut self, pos: Pos2) -> TimelineResponse {
2854            self.press_m(pos, Modifiers::NONE)
2855        }
2856        fn press_m(&mut self, pos: Pos2, mods: Modifiers) -> TimelineResponse {
2857            self.frame_m(vec![Event::PointerMoved(pos)], mods);
2858            self.frame_m(
2859                vec![Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: mods }],
2860                mods,
2861            )
2862        }
2863        fn release(&mut self, pos: Pos2) -> TimelineResponse {
2864            self.release_m(pos, Modifiers::NONE)
2865        }
2866        fn release_m(&mut self, pos: Pos2, mods: Modifiers) -> TimelineResponse {
2867            self.frame_m(
2868                vec![Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: mods }],
2869                mods,
2870            )
2871        }
2872        /// press at `from`, move in steps to `to`, release; returns the OR of `edited` over the gesture.
2873        fn drag(&mut self, from: Pos2, to: Pos2) -> bool {
2874            self.press(from);
2875            let mut edited = false;
2876            for i in 1..=4 {
2877                let p = from + (to - from) * (i as f32 / 4.0);
2878                edited |= self.frame(vec![Event::PointerMoved(p)]).edited;
2879            }
2880            edited |= self.release(to).edited;
2881            edited |= self.frame(vec![]).edited;
2882            edited
2883        }
2884        fn video_clip(&self) -> &Clip {
2885            &self.project.tracks[0].clips[0]
2886        }
2887        fn audio_clip(&self) -> &Clip {
2888            &self.project.tracks[1].clips[0]
2889        }
2890    }
2891
2892    /// The tool strip drives the timeline: razor splits where you click, the marker tool drops a
2893    /// marker there, and stretch retimes an edge drag instead of trimming it.
2894    #[test]
2895    fn tools_cut_mark_and_stretch() {
2896        let mut h = Harness::new();
2897        let lanes = h.state.lanes_rect;
2898        let p = pos2(lanes.left() + 100.0, lanes.top() + 30.0);
2899
2900        h.tool = Tool::Cut;
2901        let before = h.project.tracks[0].clips.len();
2902        h.press(p);
2903        h.release(p);
2904        h.frame(vec![]);
2905        assert_eq!(h.project.tracks[0].clips.len(), before + 1, "razor click must split the clip");
2906
2907        h.tool = Tool::Marker;
2908        assert!(h.project.markers.is_empty());
2909        let p2 = pos2(lanes.left() + 220.0, lanes.top() + 30.0);
2910        h.press(p2);
2911        h.release(p2);
2912        h.frame(vec![]);
2913        assert_eq!(h.project.markers.len(), 1, "marker tool click must drop a marker");
2914
2915        // fresh timeline: the razor above left a clip butting up against the edge we want to stretch
2916        let mut h = Harness::new();
2917        let lanes = h.state.lanes_rect;
2918        h.tool = Tool::Stretch;
2919        let id = h.video_clip().id;
2920        let (dur0, src0) = (h.video_clip().duration, h.video_clip().src_len());
2921        let edge = lanes.left() + (h.video_clip().end() as f32) * h.state.zoom;
2922        assert!(h.drag(pos2(edge, lanes.top() + 30.0), pos2(edge + 80.0, lanes.top() + 30.0)));
2923        let c = h.project.clip(id).unwrap();
2924        assert!(c.duration > dur0 + 0.5, "stretch must lengthen the clip: {} -> {}", dur0, c.duration);
2925        assert!((c.src_len() - src0).abs() < 1e-6, "stretch must keep the source window: {} -> {}", src0, c.src_len());
2926        assert!(c.speed < 1.0, "a longer clip over the same source must slow down: {}", c.speed);
2927    }
2928
2929    /// Snapping is not just for move/trim: the razor, the marker tool and marker drags land on the same
2930    /// candidates as everything else. In/out points are used as the candidates here — the playhead has a
2931    /// grab zone that would eat the clicks.
2932    #[test]
2933    fn every_tool_snaps() {
2934        let mut h = Harness::new();
2935        h.snap = true;
2936        h.project.in_point = Some(3.0);
2937        h.project.out_point = Some(5.0);
2938        let lanes = h.state.lanes_rect;
2939        let y = lanes.top() + 30.0;
2940        let off = lanes.left() + 3.0 * h.state.zoom + 4.0; // 4 px past the in point, inside the 8 px threshold
2941
2942        // marker tool first: the razor's cut would put a trim handle over the spot we click
2943        h.tool = Tool::Marker;
2944        let p = pos2(off, y);
2945        h.press(p);
2946        h.release(p);
2947        h.frame(vec![]);
2948        assert_eq!(h.project.markers.len(), 1, "marker tool dropped nothing");
2949        assert!((h.project.markers[0].t - 3.0).abs() < 1e-9, "marker tool must snap: {}", h.project.markers[0].t);
2950
2951        h.tool = Tool::Cut;
2952        h.press(p);
2953        h.release(p);
2954        h.frame(vec![]);
2955        assert_eq!(h.project.tracks[0].clips.len(), 2, "razor did not split");
2956        let cut = h.project.tracks[0].clips[1].start;
2957        assert!((cut - 3.0).abs() < 1e-9, "razor must snap to the in point: {cut}");
2958
2959        // and dragging the ruler flag snaps too — over to the out point, the only candidate near the drop
2960        h.tool = Tool::Select;
2961        let y = lanes.top() - RULER_H + 4.0;
2962        assert!(h.drag(pos2(h.state.x_at(3.0) + 1.0, y), pos2(h.state.x_at(5.0) + 5.0, y)), "marker drag edits");
2963        assert!((h.project.markers[0].t - 5.0).abs() < 1e-9, "marker drag must snap: {}", h.project.markers[0].t);
2964    }
2965
2966    #[test]
2967    fn headless_click_selects_and_drag_moves_linked() {
2968        let mut h = Harness::new();
2969        let lanes = h.state.lanes_rect;
2970        assert!(lanes.width() > 300.0, "lanes rect not laid out: {lanes:?}");
2971        let vid = h.video_clip().id;
2972        let aud = h.audio_clip().id;
2973        // V1 is the top row (video tracks above audio): click inside the clip selects the link group
2974        let p = pos2(lanes.left() + 100.0, lanes.top() + 30.0);
2975        h.press(p);
2976        h.release(p);
2977        h.frame(vec![]);
2978        assert_eq!(h.selection, vec![vid, aud]);
2979        // click on empty lane → deselect
2980        let empty = pos2(lanes.left() + 600.0, lanes.top() + 30.0);
2981        h.press(empty);
2982        h.release(empty);
2983        h.frame(vec![]);
2984        assert!(h.selection.is_empty());
2985        // drag the clip right by 120 px = 3 s at zoom 40
2986        let edited = h.drag(p, p + vec2(120.0, 0.0));
2987        assert!(edited);
2988        assert_eq!(h.undos, 1);
2989        assert!((h.video_clip().start - 3.0).abs() < 0.05, "video start {}", h.video_clip().start);
2990        assert!((h.audio_clip().start - 3.0).abs() < 0.05, "audio (linked) start {}", h.audio_clip().start);
2991        assert!(h.state.drag.is_none());
2992        // add V2 (displayed above V1) and drag the clip up one row → it lands on V2, audio stays on A1
2993        h.project.add_track(TrackKind::Video);
2994        h.frame(vec![]);
2995        let v1_h = h.project.tracks[0].height;
2996        let v2_h = h.project.tracks[1].height;
2997        let from = pos2(h.state.x_at(3.0) + 50.0, lanes.top() + v2_h + v1_h * 0.5);
2998        assert!(h.drag(from, from - vec2(0.0, v2_h)));
2999        assert_eq!(h.project.tracks[1].clips.len(), 1, "clip should be on V2");
3000        assert!((h.project.tracks[1].clips[0].start - 3.0).abs() < 0.05);
3001        assert_eq!(h.project.tracks[2].clips.len(), 1, "audio stays on A1");
3002        assert_eq!(h.undos, 2);
3003    }
3004
3005    #[test]
3006    fn headless_trim_end_and_ruler_scrub() {
3007        let mut h = Harness::new();
3008        let lanes = h.state.lanes_rect;
3009        let x_end = h.state.x_at(10.0);
3010        // drag the right edge of the video clip left by 80 px = 2 s
3011        let from = pos2(x_end - 2.0, lanes.top() + 30.0);
3012        let edited = h.drag(from, from - vec2(80.0, 0.0));
3013        assert!(edited);
3014        assert_eq!(h.undos, 1);
3015        assert!((h.video_clip().duration - 8.0).abs() < 0.05, "duration {}", h.video_clip().duration);
3016        assert!((h.audio_clip().duration - 8.0).abs() < 0.05, "linked audio duration {}", h.audio_clip().duration);
3017        // ruler press scrubs the playhead (frame-snapped)
3018        let rx = h.state.x_at(4.0) + 1.0;
3019        let r = h.press(pos2(rx, lanes.top() - RULER_H * 0.5));
3020        assert!(r.seeked);
3021        assert!((h.playhead - 4.0).abs() < 0.05, "playhead {}", h.playhead);
3022        h.release(pos2(rx, lanes.top() - RULER_H * 0.5));
3023    }
3024
3025    #[test]
3026    fn headless_edge_handle_beats_playhead_after_split() {
3027        let mut h = Harness::new();
3028        let lanes = h.state.lanes_rect;
3029        h.playhead = 5.0;
3030        h.project.split_at(5.0, None);
3031        h.frame(vec![]);
3032        assert_eq!(h.project.tracks[0].clips.len(), 2);
3033        // press inside the right clip's left edge zone, which overlaps the playhead hit-rect, drag 1 s right
3034        let from = pos2(h.state.x_at(5.0) + 2.0, lanes.top() + 30.0);
3035        assert!(h.drag(from, from + vec2(40.0, 0.0)));
3036        assert_eq!(h.undos, 1);
3037        let right = &h.project.tracks[0].clips[1];
3038        assert!((right.start - 6.0).abs() < 0.05, "trimmed start {}", right.start);
3039        assert!((h.project.tracks[1].clips[1].start - 6.0).abs() < 0.05, "linked audio trims too");
3040        assert_eq!(h.playhead, 5.0, "playhead not scrubbed");
3041    }
3042
3043    #[test]
3044    fn headless_linked_trim_is_all_or_nothing_and_no_dead_undo() {
3045        let mut h = Harness::new();
3046        let lanes = h.state.lanes_rect;
3047        // press-and-hold / blocked move (clip at 0 dragged left): nothing changes, no undo entry
3048        let p = pos2(lanes.left() + 100.0, lanes.top() + 30.0);
3049        h.press(p);
3050        h.frame(vec![]);
3051        h.release(p);
3052        assert!(!h.drag(p, p - vec2(80.0, 0.0)));
3053        assert_eq!(h.undos, 0);
3054        assert_eq!(h.video_clip().start, 0.0);
3055        // V1 2-10 linked with A1 2-10; unlinked audio on A1 at 0-1.5 blocks the audio's left edge
3056        h.project.tracks[0].clips[0].trim_start(2.0, f64::INFINITY);
3057        h.project.tracks[1].clips[0].trim_start(2.0, f64::INFINITY);
3058        h.project.tracks[1].clips.insert(0, Clip::new(99, ClipKind::Audio, "blk", 0.0, 1.5));
3059        h.frame(vec![]);
3060        let from = pos2(h.state.x_at(2.0) + 2.0, lanes.top() + 30.0);
3061        assert!(h.drag(from, from - vec2(80.0, 0.0))); // to t = 0 in 0.5 s steps
3062        assert_eq!(h.undos, 1);
3063        let (v, a) = (&h.project.tracks[0].clips[0], &h.project.tracks[1].clips[1]);
3064        assert!((v.start - 1.5).abs() < 0.05, "video start {}", v.start);
3065        assert_eq!(v.start, a.start, "linked clips keep identical extents");
3066        assert_eq!(v.end(), a.end());
3067    }
3068
3069    #[test]
3070    fn headless_cross_track_move_uses_track_under_pointer() {
3071        let mut h = Harness::new();
3072        let lanes = h.state.lanes_rect;
3073        h.project.add_track(TrackKind::Video); // V2 (index 1), displayed above V1
3074        h.project.tracks[1].height = 200.0;
3075        h.frame(vec![]);
3076        // drag the V1 clip up: pointer ends 150 px into the tall V2 row -> lands on V2 (dy/row_h would overshoot)
3077        let from = pos2(h.state.x_at(0.0) + 50.0, lanes.top() + 200.0 + 30.0);
3078        assert!(h.drag(from, pos2(from.x, lanes.top() + 150.0)));
3079        assert_eq!(h.project.tracks[1].clips.len(), 1, "clip on V2");
3080        assert_eq!(h.project.tracks[0].clips.len(), 0);
3081        // drag it down 150 px: still inside V2 -> no change, no undo
3082        let from = pos2(from.x, lanes.top() + 10.0);
3083        assert!(!h.drag(from, pos2(from.x, lanes.top() + 160.0)));
3084        assert_eq!(h.project.tracks[1].clips.len(), 1, "still on V2");
3085        assert_eq!(h.undos, 1);
3086    }
3087
3088    /// An effect card dragged out of the Effects panel is reported against the clip under the pointer
3089    /// (the app pushes it on that clip's stack); over empty lane space it is reported with no clip so
3090    /// the app can say so instead of swallowing the gesture.
3091    #[test]
3092    fn headless_dnd_effect_targets_the_clip_under_the_pointer() {
3093        use crate::model::EffectKind;
3094        let mut h = Harness::new();
3095        let lanes = h.state.lanes_rect;
3096        let on_clip = pos2(lanes.left() + 200.0, lanes.top() + 30.0); // t = 5 s, inside the 0..10 clip
3097        h.press(pos2(10.0, 10.0));
3098        egui::DragAndDrop::set_payload(&h.ctx, DragPayload::Effect(EffectKind::Blur));
3099        h.frame(vec![Event::PointerMoved(on_clip)]);
3100        let hit = drop_on_clip(&h.state, &h.project, on_clip, 5.0).map(|(_, c)| c.id);
3101        assert_eq!(hit, Some(h.project.tracks[0].clips[0].id), "the drag highlights that clip");
3102        let r = h.release(on_clip);
3103        assert!(!r.edited, "the timeline changes nothing itself — the app adds the effect");
3104        assert_eq!(r.dropped_other.len(), 1);
3105        let (payload, t, ti) = &r.dropped_other[0];
3106        assert!(matches!(payload, DragPayload::Effect(EffectKind::Blur)));
3107        assert_eq!(*ti, Some(0));
3108        assert!(h.project.tracks[0].clips[0].contains(*t), "reported time is inside the clip: {t}");
3109
3110        // past the end of the clip: still reported, but on nothing
3111        let empty = pos2(lanes.left() + 480.0, lanes.top() + 30.0); // t = 12 s
3112        h.press(pos2(10.0, 10.0));
3113        egui::DragAndDrop::set_payload(&h.ctx, DragPayload::Effect(EffectKind::Blur));
3114        h.frame(vec![Event::PointerMoved(empty)]);
3115        assert!(drop_on_clip(&h.state, &h.project, empty, 12.0).is_none(), "nothing to highlight");
3116        let r = h.release(empty);
3117        let (_, t, _) = &r.dropped_other[0];
3118        assert!(!h.project.tracks[0].clips[0].contains(*t));
3119    }
3120
3121    #[test]
3122    fn headless_dnd_drops_asset_and_ctrl_wheel_zooms() {
3123        let mut h = Harness::new();
3124        let lanes = h.state.lanes_rect;
3125        // dnd: press somewhere else, set the payload, hover the lanes past the clip, release → inserted there
3126        let aid = h.project.assets[0].id;
3127        h.press(pos2(10.0, 10.0));
3128        egui::DragAndDrop::set_payload(&h.ctx, DragPayload::Asset(aid));
3129        let drop = pos2(lanes.left() + 480.0, lanes.top() + 30.0); // t = 12 s, after the 10 s clip
3130        h.frame(vec![Event::PointerMoved(drop)]);
3131        let r = h.release(drop);
3132        assert!(r.edited);
3133        assert_eq!(h.undos, 1);
3134        assert_eq!(h.project.tracks[0].clips.len(), 2);
3135        let start = h.project.tracks[0].clips[1].start;
3136        assert!((start - 12.0).abs() < 0.05, "start {start}");
3137        assert_eq!(h.project.tracks[1].clips.len(), 2);
3138        // ctrl+wheel over the lanes zooms
3139        let over = pos2(lanes.left() + 200.0, lanes.top() + 30.0);
3140        h.frame(vec![Event::PointerMoved(over)]);
3141        for _ in 0..6 {
3142            h.frame(vec![Event::MouseWheel {
3143                unit: egui::MouseWheelUnit::Point,
3144                delta: vec2(0.0, 40.0),
3145                modifiers: Modifiers::CTRL,
3146            }]);
3147        }
3148        assert!(h.state.zoom > 40.0, "zoom {}", h.state.zoom);
3149    }
3150
3151    #[test]
3152    fn volume_db_mapping_roundtrip() {
3153        assert!((db_frac(0.0) - 0.7).abs() < 1e-6, "0 dB sits at 70 % height");
3154        assert_eq!(db_frac(DB_TOP), 1.0);
3155        assert_eq!(db_frac(DB_BOT), 0.0);
3156        for f in [0.0, 0.2, 0.5, 0.7, 0.9, 1.0] {
3157            assert!((db_frac(frac_db(f)) - f).abs() < 1e-4, "roundtrip at {f}");
3158        }
3159        assert!(gain_db(1.0).abs() < 1e-6);
3160        assert!((gain_db(2.0) - 6.02).abs() < 0.01);
3161        assert_eq!(gain_db(0.0), DB_BOT);
3162    }
3163
3164    #[test]
3165    fn headless_volume_line_drag_changes_volume() {
3166        let mut h = Harness::new();
3167        let lanes = h.state.lanes_rect;
3168        // A1 row sits under V1 (height 64); audio clip rect is inset 1 pt; line at 70 % height (unity gain)
3169        let row_top = lanes.top() + h.project.tracks[0].height;
3170        let rect_h = h.project.tracks[1].height - 2.0;
3171        let line_y = (row_top + h.project.tracks[1].height - 1.0) - 0.7 * rect_h;
3172        let from = pos2(lanes.left() + 100.0, line_y);
3173        assert!(h.drag(from, from + vec2(0.0, 20.0)), "volume drag edits");
3174        assert_eq!(h.undos, 1);
3175        let v = &h.audio_clip().volume;
3176        assert!(!v.is_animated(), "constant volume stays constant");
3177        assert!(v.value > 0.0 && v.value < 0.5, "gain lowered, got {}", v.value);
3178        // clip untouched otherwise
3179        assert_eq!(h.audio_clip().start, 0.0);
3180        assert!((h.audio_clip().duration - 10.0).abs() < 1e-6);
3181    }
3182
3183    #[test]
3184    fn headless_volume_line_drag_keys_once_on_animated_clip() {
3185        let mut h = Harness::new();
3186        let lanes = h.state.lanes_rect;
3187        // keyframed volume, unity at both ends → the line still sits at 70 % height
3188        h.project.tracks[1].clips[0].volume.toggle_key(0.0);
3189        h.project.tracks[1].clips[0].volume.toggle_key(9.0);
3190        h.frame(vec![]);
3191        let row_top = lanes.top() + h.project.tracks[0].height;
3192        let rect_h = h.project.tracks[1].height - 2.0;
3193        let line_y = (row_top + h.project.tracks[1].height - 1.0) - 0.7 * rect_h;
3194        let from = pos2(lanes.left() + 40.0, line_y); // t = 1 s at zoom 40
3195        assert!(h.drag(from, from + vec2(160.0, 12.0)), "volume drag edits");
3196        let v = &h.audio_clip().volume;
3197        // one key at the grab time, edited in place for the rest of the gesture — not one per frame
3198        assert_eq!(v.keys.len(), 3, "keys {:?}", v.keys);
3199        assert!((v.keys[1].t - 1.0).abs() < 1e-6, "key at the grab time, got {:?}", v.keys);
3200        assert!(v.keys[1].v < 1.0, "grabbed key lowered, got {:?}", v.keys);
3201    }
3202
3203    #[test]
3204    fn headless_keyframe_drag_moves_key() {
3205        let mut h = Harness::new();
3206        let lanes = h.state.lanes_rect;
3207        h.project.tracks[0].height = MIN_TRACK_H; // < KEY_LANE_MIN → bottom strip, time only
3208        h.project.tracks[0].clips[0].opacity.toggle_key(2.0);
3209        h.frame(vec![]);
3210        let row_bottom = lanes.top() + h.project.tracks[0].height;
3211        let kp = pos2(h.state.x_at(2.0), row_bottom - 1.0 - 5.0);
3212        assert!(h.drag(kp, kp + vec2(40.0, 20.0)), "keyframe drag edits");
3213        assert_eq!(h.undos, 1);
3214        let keys = &h.project.tracks[0].clips[0].opacity.keys;
3215        assert_eq!(keys.len(), 1);
3216        assert!((keys[0].t - 3.0).abs() < 1.0 / 30.0 + 1e-6, "key moved to {}", keys[0].t);
3217        assert_eq!(keys[0].v, 1.0, "short clip: vertical drag does not touch the value");
3218        assert_eq!(h.project.tracks[0].clips[0].start, 0.0, "clip not moved");
3219    }
3220
3221    #[test]
3222    fn headless_keyframe_value_lane_drag_changes_time_and_value() {
3223        let mut h = Harness::new();
3224        let lanes = h.state.lanes_rect;
3225        h.project.tracks[0].clips[0].opacity.toggle_key(2.0);
3226        h.frame(vec![]);
3227        // 64 pt row → value lane. One key at 1.0 auto-ranges to 0.5..1.5, so it sits mid-lane.
3228        let th = h.project.tracks[0].height;
3229        let inner = th - 2.0 - 2.0 * KEY_PAD;
3230        let kp = pos2(h.state.x_at(2.0), lanes.top() + th - 1.0 - KEY_PAD - 0.5 * inner);
3231        assert!(h.drag(kp, kp + vec2(40.0, 0.25 * inner)), "value-lane drag edits");
3232        assert_eq!(h.undos, 1, "one undo for the whole gesture");
3233        let keys = &h.project.tracks[0].clips[0].opacity.keys;
3234        assert_eq!(keys.len(), 1);
3235        assert!((keys[0].t - 3.0).abs() < 1.0 / 30.0 + 1e-6, "time moved to {}", keys[0].t);
3236        assert!((keys[0].v - 0.75).abs() < 0.05, "value dragged down to {}", keys[0].v);
3237        assert_eq!(h.project.tracks[0].clips[0].start, 0.0, "clip not moved");
3238    }
3239
3240    #[test]
3241    fn headless_rubber_band_selects_and_moves_together() {
3242        let mut h = Harness::new();
3243        let lanes = h.state.lanes_rect;
3244        h.project.tracks[1].clips.clear(); // drop the linked audio: two video clips only
3245        h.project.split_at(5.0, None);
3246        h.frame(vec![]);
3247        let (a, b) = (h.project.tracks[0].clips[0].id, h.project.tracks[0].clips[1].id);
3248        // press on empty lane space below the tracks, drag up across both clips
3249        let from = pos2(h.state.x_at(9.0), lanes.top() + 200.0);
3250        h.drag(from, pos2(h.state.x_at(4.0), lanes.top() + 5.0));
3251        assert_eq!(h.selection, vec![a, b], "band selected both clips");
3252        assert!(h.state.band.is_none(), "band cleared on release");
3253        // dragging one of them moves the whole selection, as one undo step
3254        let grab = pos2(h.state.x_at(1.0), lanes.top() + 30.0);
3255        assert!(h.drag(grab, grab + vec2(40.0, 0.0)));
3256        assert_eq!(h.undos, 1);
3257        assert!((h.project.tracks[0].clips[0].start - 1.0).abs() < 0.05);
3258        assert!((h.project.tracks[0].clips[1].start - 6.0).abs() < 0.05);
3259    }
3260
3261    #[test]
3262    fn headless_rubber_band_shift_adds_and_esc_cancels() {
3263        let mut h = Harness::new();
3264        let lanes = h.state.lanes_rect;
3265        h.project.tracks[1].clips.clear();
3266        h.project.split_at(5.0, None);
3267        h.frame(vec![]);
3268        let (a, b) = (h.project.tracks[0].clips[0].id, h.project.tracks[0].clips[1].id);
3269        let empty_y = lanes.top() + 200.0;
3270        // band over the left clip only
3271        h.drag(pos2(h.state.x_at(0.5), empty_y), pos2(h.state.x_at(2.0), lanes.top() + 5.0));
3272        assert_eq!(h.selection, vec![a]);
3273        // Shift-band over the right clip adds to it
3274        let (from, to) = (pos2(h.state.x_at(7.0), empty_y), pos2(h.state.x_at(9.0), lanes.top() + 5.0));
3275        h.press_m(from, Modifiers::SHIFT);
3276        for i in 1..=4 {
3277            let p = from + (to - from) * (i as f32 / 4.0);
3278            h.frame_m(vec![Event::PointerMoved(p)], Modifiers::SHIFT);
3279        }
3280        h.release_m(to, Modifiers::SHIFT);
3281        h.frame(vec![]);
3282        assert_eq!(h.selection, vec![a, b], "Shift added to the selection");
3283        // Esc during a clip move puts everything back and leaves no undo entry
3284        let grab = pos2(h.state.x_at(1.0), lanes.top() + 30.0);
3285        h.press(grab);
3286        h.frame(vec![Event::PointerMoved(grab + vec2(80.0, 0.0))]);
3287        assert!(h.project.tracks[0].clips[0].start > 0.5, "moved mid-drag");
3288        h.frame(vec![Event::Key {
3289            key: egui::Key::Escape,
3290            physical_key: None,
3291            pressed: true,
3292            repeat: false,
3293            modifiers: Modifiers::NONE,
3294        }]);
3295        h.release(grab + vec2(80.0, 0.0));
3296        h.frame(vec![]);
3297        assert_eq!(h.project.tracks[0].clips[0].start, 0.0, "Esc restored the pre-drag project");
3298        assert_eq!(h.project.tracks[0].clips.len(), 2);
3299        assert_eq!(h.undos, 0, "cancelled gesture pushes no undo");
3300    }
3301
3302    #[test]
3303    fn headless_drag_past_top_row_creates_a_video_track() {
3304        let mut h = Harness::new();
3305        let lanes = h.state.lanes_rect;
3306        let vid = h.video_clip().id;
3307        let from = pos2(h.state.x_at(2.0), lanes.top() + 30.0);
3308        // drag up into the ruler: the gutter appears, release drops the clip on a fresh V2
3309        h.press(from);
3310        for y in [lanes.top() + 10.0, lanes.top() - 5.0, lanes.top() - 12.0] {
3311            h.frame(vec![Event::PointerMoved(pos2(from.x, y))]);
3312        }
3313        assert!(matches!(h.state.drag, Some(Drag { g: Gesture::Move { new_track: true, .. }, .. })), "gutter armed");
3314        h.release(pos2(from.x, lanes.top() - 12.0));
3315        h.frame(vec![]);
3316        assert_eq!(h.project.video_tracks().len(), 2, "a video track was created");
3317        assert_eq!(h.project.tracks[1].clips.len(), 1, "clip on the new top track");
3318        assert_eq!(h.project.tracks[1].clips[0].id, vid);
3319        assert!(h.project.tracks[0].clips.is_empty(), "V1 is empty now");
3320        assert_eq!(h.project.tracks[2].clips.len(), 1, "linked audio stayed on A1");
3321        assert_eq!(h.undos, 1);
3322        // and the same past the bottom for audio: A2 appears below A1
3323        let a1_top = lanes.top() + h.project.tracks[1].height + h.project.tracks[0].height;
3324        let from = pos2(h.state.x_at(2.0), a1_top + 30.0);
3325        let to = pos2(from.x, lanes.bottom() - 2.0);
3326        h.press(from);
3327        for y in [a1_top + 80.0, lanes.bottom() - 40.0, to.y] {
3328            h.frame(vec![Event::PointerMoved(pos2(from.x, y))]);
3329        }
3330        h.release(to);
3331        h.frame(vec![]);
3332        assert_eq!(h.project.audio_tracks().len(), 2, "an audio track was created");
3333        assert_eq!(h.project.tracks[3].clips.len(), 1, "audio clip moved to A2");
3334        assert!(h.project.tracks[2].clips.is_empty(), "A1 is empty now");
3335        assert_eq!(h.undos, 2);
3336    }
3337
3338    #[test]
3339    fn headless_track_gutters_arm_while_the_lanes_scroll() {
3340        let mut h = Harness::new();
3341        let lanes = h.state.lanes_rect;
3342        let (vid, aud) = (h.video_clip().id, h.audio_clip().id);
3343        for _ in 0..5 {
3344            h.project.add_track(TrackKind::Video);
3345        }
3346        h.state.scroll_y = 80.0; // past RULER_H, where the old row-relative arm zones were empty
3347        h.frame(vec![]);
3348        assert!(h.state.scroll_y > RULER_H, "lanes did not scroll: {}", h.state.scroll_y);
3349        let armed = |h: &Harness| matches!(h.state.drag, Some(Drag { g: Gesture::Move { new_track: true, .. }, .. }));
3350        let clip_at = |h: &Harness, id| {
3351            let ti = h.project.track_of(id).unwrap();
3352            pos2(h.state.x_at(2.0), row_top(&h.state, &h.project, ti).unwrap() + 30.0)
3353        };
3354        // audio first (the bottom gutter): drag A1's clip onto the bottom edge of the lanes
3355        let (from, to) = (clip_at(&h, aud), pos2(h.state.x_at(2.0), lanes.bottom() - 2.0));
3356        h.press(from);
3357        h.frame(vec![Event::PointerMoved(to)]);
3358        assert!(armed(&h), "audio gutter armed while scrolled");
3359        h.release(to);
3360        h.frame(vec![]);
3361        assert_eq!(h.project.audio_tracks().len(), 2, "an audio track was created");
3362        // then video (the top gutter): drag V1's clip onto the top edge
3363        let (from, to) = (clip_at(&h, vid), pos2(h.state.x_at(2.0), lanes.top() + 2.0));
3364        h.press(from);
3365        h.frame(vec![Event::PointerMoved(to)]);
3366        assert!(armed(&h), "video gutter armed while scrolled");
3367        h.release(to);
3368        h.frame(vec![]);
3369        assert_eq!(h.project.video_tracks().len(), 7, "a video track was created");
3370    }
3371
3372    #[test]
3373    fn headless_value_lane_clamps_to_the_property_range() {
3374        let mut h = Harness::new();
3375        let lanes = h.state.lanes_rect;
3376        h.project.tracks[0].clips[0].opacity.toggle_key(0.0);
3377        h.project.tracks[0].clips[0].opacity.toggle_key(2.0);
3378        h.project.tracks[0].clips[0].opacity.keys[1].v = 0.0;
3379        h.frame(vec![]);
3380        // keys at 1.0 and 0.0 auto-range to -0.5..1.5, so the low key sits a quarter up the lane and
3381        // dragging it to the top of the lane asks for 1.5
3382        let th = h.project.tracks[0].height;
3383        let inner = th - 2.0 - 2.0 * KEY_PAD;
3384        let kp = pos2(h.state.x_at(2.0), lanes.top() + th - 1.0 - KEY_PAD - 0.25 * inner);
3385        assert!(h.drag(kp, pos2(kp.x, lanes.top() + 2.0)), "value-lane drag edits");
3386        let keys = &h.project.tracks[0].clips[0].opacity.keys;
3387        assert_eq!(keys[1].v, 1.0, "opacity clamped to its 0..1 range, got {keys:?}");
3388        // effect params clamp to their ParamSpec
3389        assert_eq!(prop_range(h.video_clip(), 2), Some((0.01, 20.0)), "Scale");
3390        assert_eq!(prop_range(h.video_clip(), 0), None, "Position X is unbounded");
3391        assert_eq!(prop_range(h.audio_clip(), 0), None, "Volume is dB-scaled");
3392        assert_eq!(prop_range(h.audio_clip(), 1), Some((-1.0, 1.0)), "Pan");
3393        assert_eq!(prop_range(h.video_clip(), 5), Some((0.01, 100.0)), "Speed");
3394        assert_eq!(prop_range(h.audio_clip(), 2), Some((0.01, 100.0)), "Speed (audio)");
3395        h.project.tracks[0].clips[0].effects.push(Effect::new(EffectKind::Blur));
3396        let spec = h.video_clip().effects[0].specs()[0];
3397        assert_eq!(prop_range(h.video_clip(), 6), Some((spec.min, spec.max)), "first Blur param");
3398    }
3399
3400    #[test]
3401    fn headless_audio_volume_keys_stay_in_the_bottom_strip() {
3402        let mut h = Harness::new();
3403        let lanes = h.state.lanes_rect;
3404        h.project.tracks[1].clips[0].volume.toggle_key(2.0);
3405        h.frame(vec![]);
3406        // the volume line is dB-mapped, so its keys keep the time-only strip: dragging one is time-only
3407        let row_bottom = lanes.top() + h.project.tracks[0].height + h.project.tracks[1].height;
3408        let kp = pos2(h.state.x_at(2.0), row_bottom - 1.0 - 5.0);
3409        assert!(h.drag(kp, kp + vec2(40.0, -20.0)), "keyframe drag edits");
3410        let keys = &h.audio_clip().volume.keys;
3411        assert_eq!(keys.len(), 1);
3412        assert!((keys[0].t - 3.0).abs() < 1.0 / 30.0 + 1e-6, "key moved to {}", keys[0].t);
3413        assert_eq!(keys[0].v, 1.0, "vertical drag does not touch a volume key's value");
3414    }
3415
3416    #[test]
3417    fn headless_marker_click_seek_and_drag() {
3418        let mut h = Harness::new();
3419        let lanes = h.state.lanes_rect;
3420        let mid = h.project.add_marker(2.0, "cut here");
3421        h.frame(vec![]);
3422        let mp = pos2(h.state.x_at(2.0) + 1.0, lanes.top() - RULER_H + 4.0);
3423        h.press(mp);
3424        h.release(mp);
3425        h.frame(vec![]);
3426        assert_eq!(h.state.selected_marker, Some(mid), "click selects the marker");
3427        assert_eq!(h.playhead, 0.0, "a marker click does not scrub the ruler");
3428        // drag it 80 px right = 2 s
3429        assert!(h.drag(mp, mp + vec2(80.0, 0.0)), "marker drag edits");
3430        assert_eq!(h.undos, 1, "one undo per marker drag");
3431        assert!((h.project.markers[0].t - 4.0).abs() < 0.05, "marker at {}", h.project.markers[0].t);
3432        // and a clip marker moves inside its clip
3433        let cid = h.video_clip().id;
3434        let cm = h.project.add_clip_marker(cid, 1.0, "beat").unwrap();
3435        h.frame(vec![]);
3436        let cp = pos2(h.state.x_at(1.0) + 1.0, lanes.top() + 4.0);
3437        assert!(h.drag(cp, cp + vec2(40.0, 0.0)), "clip marker drag edits");
3438        assert_eq!(h.undos, 2);
3439        let m = h.video_clip().markers.iter().find(|m| m.id == cm).unwrap();
3440        assert!((m.t - 2.0).abs() < 0.05, "clip marker local t {}", m.t);
3441    }
3442
3443    #[test]
3444    fn headless_clip_colour_and_menu_come_from_project_labels() {
3445        let mut h = Harness::new();
3446        h.project.labels.clear();
3447        let idx = h.project.add_label("Neon", [10, 200, 30]);
3448        h.project.tracks[0].clips[0].label = idx;
3449        h.frame(vec![]);
3450        assert!(h.has_fill(Color32::from_rgb(10, 200, 30)), "clip painted in the project label's colour");
3451        // the colour submenu lists the project's labels plus "Edit labels…"
3452        let (mut act, mut edit) = (None, false);
3453        let labels = h.project.labels.clone();
3454        let mut pos = None;
3455        let full = h.ctx.run(
3456            RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), ..Default::default() },
3457            |ctx| {
3458                egui::CentralPanel::default().show(ctx, |ui| label_menu(ui, &labels, &mut act, &mut edit));
3459            },
3460        );
3461        for cs in &full.shapes {
3462            if let Shape::Text(t) = &cs.shape {
3463                if t.galley.text().contains("Neon") {
3464                    pos = Some(t.pos);
3465                }
3466                assert!(!t.galley.text().contains("Orange"), "built-in labels must not leak in");
3467            }
3468        }
3469        let pos = pos.expect("the project label is listed");
3470        // click it → Act::Label(1)
3471        let click = pos + vec2(4.0, 4.0);
3472        for pressed in [true, false] {
3473            let _ = h.ctx.run(
3474                RawInput {
3475                    screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))),
3476                    events: vec![Event::PointerButton {
3477                        pos: click,
3478                        button: PointerButton::Primary,
3479                        pressed,
3480                        modifiers: Modifiers::NONE,
3481                    }],
3482                    ..Default::default()
3483                },
3484                |ctx| {
3485                    egui::CentralPanel::default().show(ctx, |ui| label_menu(ui, &labels, &mut act, &mut edit));
3486                },
3487            );
3488        }
3489        assert!(matches!(act, Some(Act::Label(1))), "clicking the label picks index 1, got {:?}", act.is_some());
3490    }
3491
3492    #[test]
3493    fn headless_adjustment_and_shape_clips_paint() {
3494        use crate::model::ShapeKind;
3495        let mut h = Harness::new();
3496        h.project.add_adjustment_clip(11.0, 3.0);
3497        h.project.add_shape_clip(ShapeKind::Rect, 15.0, 3.0);
3498        h.state.zoom = 20.0;
3499        h.frame(vec![]);
3500        let pal = Palette::new(true, Color32::from_rgb(0, 120, 212));
3501        assert!(h.has_fill(pal.clip_adjust), "adjustment clip uses its own fill");
3502        assert!(h.has_fill(pal.clip_shape), "shape clip uses its own fill");
3503        assert!(h.painted_text("adj").is_some(), "adjustment badge painted");
3504        assert!(h.painted_text("Rect").is_some(), "shape clip name painted");
3505    }
3506
3507    #[test]
3508    fn headless_1000_clips_stays_fast() {
3509        let mut h = Harness::new();
3510        h.project = Project::new();
3511        for _ in 0..3 {
3512            h.project.add_track(TrackKind::Video);
3513        }
3514        let tracks = h.project.video_tracks();
3515        for i in 0..1000usize {
3516            let (ti, n) = (tracks[i % tracks.len()], (i / tracks.len()) as f64);
3517            let mut c = Clip::new(i as Id + 1000, ClipKind::Video, "clip", n * 2.0, 1.8);
3518            c.label = (i % 8) as u8 + 1;
3519            h.project.tracks[ti].clips.push(c);
3520        }
3521        h.project.tidy();
3522        h.frame(vec![]);
3523        let lanes = h.state.lanes_rect;
3524        h.state.zoom_to_fit(h.project.duration(), lanes.width());
3525        h.frame(vec![]);
3526        let t0 = std::time::Instant::now();
3527        for i in 0..20 {
3528            h.frame(vec![Event::PointerMoved(pos2(lanes.left() + 5.0 * i as f32, lanes.top() + 20.0))]);
3529        }
3530        let ms = t0.elapsed().as_secs_f64() * 1000.0 / 20.0;
3531        println!("timeline: 1000 clips, zoom-to-fit: {ms:.2} ms/frame");
3532        assert_eq!(h.project.all_clips().count(), 1000);
3533        // ~0.5 ms on this machine (dev profile, opt-level 1); the budget is 3 ms, the ceiling catches
3534        // O(n²) or per-clip-allocation regressions without being flaky on a loaded box
3535        assert!(ms < 10.0, "1000-clip frame took {ms:.2} ms");
3536        // and the same again zoomed in, where clips get their full detail treatment
3537        h.state.zoom = 40.0;
3538        h.state.scroll_x = 0.0;
3539        let t0 = std::time::Instant::now();
3540        for _ in 0..20 {
3541            h.frame(vec![]);
3542        }
3543        let ms = t0.elapsed().as_secs_f64() * 1000.0 / 20.0;
3544        println!("timeline: 1000 clips, zoom 40: {ms:.2} ms/frame");
3545        assert!(ms < 10.0, "zoomed-in frame took {ms:.2} ms");
3546    }
3547
3548    /// Transition bands select like clips: click replaces (and clears the clip selection), Ctrl+click
3549    /// adds, clicking a clip clears them again, and the rubber band picks up the bands it crosses.
3550    #[test]
3551    fn transition_bands_select_like_clips() {
3552        use crate::model::TransitionKind;
3553        let mut h = Harness::new();
3554        let lanes = h.state.lanes_rect;
3555        h.project.split_at(4.0, None);
3556        h.project.split_at(7.0, None);
3557        let c2 = h.project.tracks[0].clips[1].id;
3558        let c3 = h.project.tracks[0].clips[2].id;
3559        let t1 = h.project.add_transition(c2, TransitionKind::CrossFade, 1.0).unwrap();
3560        let t2 = h.project.add_transition(c3, TransitionKind::CrossFade, 1.0).unwrap();
3561        h.selection = vec![c2];
3562        h.frame(vec![]);
3563        let band1 = pos2(h.state.x_at(4.0), lanes.top() + 30.0);
3564        let band2 = pos2(h.state.x_at(7.0), lanes.top() + 30.0);
3565        h.press(band1);
3566        h.release(band1);
3567        assert_eq!(h.sel_transitions, vec![t1]);
3568        assert!(h.selection.is_empty(), "selecting a transition clears the clip selection");
3569        // Ctrl+click adds the second, Ctrl+click again toggles it off
3570        h.press_m(band2, Modifiers::CTRL);
3571        h.release_m(band2, Modifiers::CTRL);
3572        assert_eq!(h.sel_transitions, vec![t1, t2]);
3573        h.press_m(band2, Modifiers::CTRL);
3574        h.release_m(band2, Modifiers::CTRL);
3575        assert_eq!(h.sel_transitions, vec![t1]);
3576        // clicking a clip clears the transition selection
3577        let clip = pos2(h.state.x_at(2.0), lanes.top() + 30.0);
3578        h.press(clip);
3579        h.release(clip);
3580        assert!(h.sel_transitions.is_empty(), "selecting a clip clears the transition selection");
3581        assert!(h.selection.contains(&h.project.tracks[0].clips[0].id));
3582        // a rubber band from empty space across both cuts picks up both transitions
3583        let from = pos2(h.state.x_at(11.0), lanes.top() + 30.0);
3584        h.drag(from, pos2(h.state.x_at(3.4), lanes.top() + 30.0));
3585        assert!(h.sel_transitions.contains(&t1) && h.sel_transitions.contains(&t2), "{:?}", h.sel_transitions);
3586        // deleting a transition drops it from the selection on the next frame
3587        h.project.remove_transition(t1);
3588        h.frame(vec![]);
3589        assert!(!h.sel_transitions.contains(&t1));
3590    }
3591
3592    #[test]
3593    fn headless_transition_edge_drag_changes_duration() {
3594        use crate::model::TransitionKind;
3595        let mut h = Harness::new();
3596        let lanes = h.state.lanes_rect;
3597        h.project.split_at(5.0, None);
3598        let right_id = h.project.tracks[0].clips[1].id;
3599        h.project.add_transition(right_id, TransitionKind::CrossFade, 1.0).unwrap();
3600        h.frame(vec![]);
3601        // band spans 4.5..5.5 on V1; drag the right band edge +40 px (1 s at zoom 40)
3602        let from = pos2(h.state.x_at(5.5) - 2.0, lanes.top() + 30.0);
3603        assert!(h.drag(from, from + vec2(40.0, 0.0)), "transition drag edits");
3604        assert_eq!(h.undos, 1);
3605        let tr = h.project.tracks[0].transitions.iter().find(|t| t.right == right_id).unwrap();
3606        // pointer ends at t = 6.45 → half = 1.45 → duration 2.9
3607        assert!((tr.duration - 2.9).abs() < 0.06, "duration {}", tr.duration);
3608        // clips untouched
3609        assert!((h.project.tracks[0].clips[1].start - 5.0).abs() < 1e-6);
3610    }
3611
3612    #[test]
3613    fn headless_transition_edge_drag_clamps_duration() {
3614        use crate::model::TransitionKind;
3615        let mut h = Harness::new();
3616        let lanes = h.state.lanes_rect;
3617        h.project.split_at(5.0, None);
3618        let right_id = h.project.tracks[0].clips[1].id;
3619        h.project.add_transition(right_id, TransitionKind::CrossFade, 1.0).unwrap();
3620        h.frame(vec![]);
3621        let dur = |h: &Harness| h.project.tracks[0].transitions.iter().find(|t| t.right == right_id).unwrap().duration;
3622        // two 5 s clips: the Transitions panel's 5 s cap binds
3623        let from = pos2(h.state.x_at(5.5) - 2.0, lanes.top() + 30.0);
3624        assert!(h.drag(from, from + vec2(300.0, 0.0)), "transition drag edits");
3625        assert!((dur(&h) - 5.0).abs() < 1e-6, "duration {}", dur(&h));
3626        // shrink the left clip to 1 s: the played window clamps to ±1 s, and the clips cap the drag at
3627        // 2 × the shorter one
3628        h.project.tracks[0].clips[0].trim_start(4.0, f64::INFINITY);
3629        h.frame(vec![]);
3630        let from = pos2(h.state.x_at(6.0) - 2.0, lanes.top() + 30.0);
3631        assert!(h.drag(from, from + vec2(300.0, 0.0)), "second transition drag edits");
3632        assert!((dur(&h) - 2.0).abs() < 1e-6, "duration {}", dur(&h));
3633    }
3634
3635    #[test]
3636    fn headless_transition_drag_survives_project_swap() {
3637        use crate::model::TransitionKind;
3638        let mut h = Harness::new();
3639        let lanes = h.state.lanes_rect;
3640        h.project.split_at(5.0, None);
3641        let right_id = h.project.tracks[0].clips[1].id;
3642        h.project.add_transition(right_id, TransitionKind::CrossFade, 1.0).unwrap();
3643        h.frame(vec![]);
3644        let from = pos2(h.state.x_at(5.5) - 2.0, lanes.top() + 30.0);
3645        h.press(from);
3646        h.frame(vec![Event::PointerMoved(from + vec2(10.0, 0.0))]);
3647        assert!(matches!(h.state.drag, Some(Drag { g: Gesture::TransDur { .. }, .. })), "edge drag started");
3648        // undo / MCP project.open can replace the project mid-drag: the captured track index goes stale
3649        if let Some(Drag { g: Gesture::TransDur { track, .. }, .. }) = h.state.drag.as_mut() {
3650            *track = 9;
3651        }
3652        h.project = Project::new();
3653        h.frame(vec![Event::PointerMoved(from + vec2(40.0, 0.0))]); // used to panic: index out of bounds
3654        h.release(from + vec2(40.0, 0.0));
3655    }
3656
3657    #[test]
3658    fn headless_scrollbar_thumb_drag_and_page() {
3659        let mut h = Harness::new();
3660        h.state.zoom = 200.0; // 10 s project, ~3 s visible → thumb smaller than the bar
3661        h.frame(vec![]);
3662        let lanes = h.state.lanes_rect;
3663        let bar_y = lanes.bottom() + HBAR_H * 0.5;
3664        // thumb starts at the far left: drag it right
3665        let from = pos2(lanes.left() + 30.0, bar_y);
3666        h.drag(from, from + vec2(100.0, 0.0));
3667        let vis_w = (lanes.width() / 200.0) as f64;
3668        let expect = (100.0 / lanes.width()) as f64 * 11.0; // total = duration * 1.1 = 11 s
3669        assert!((h.state.scroll_x - expect).abs() < expect * 0.3, "scroll_x {} vs {expect}", h.state.scroll_x);
3670        // click right of the thumb pages forward by one visible width
3671        let before = h.state.scroll_x;
3672        let pg = pos2(lanes.left() + lanes.width() - 20.0, bar_y);
3673        h.press(pg);
3674        h.release(pg);
3675        h.frame(vec![]);
3676        assert!(h.state.scroll_x > before + vis_w * 0.9, "paged from {before} to {}", h.state.scroll_x);
3677    }
3678
3679    #[test]
3680    fn headless_alt_click_selects_single_clip() {
3681        let mut h = Harness::new();
3682        let lanes = h.state.lanes_rect;
3683        let (vid, aud) = (h.video_clip().id, h.audio_clip().id);
3684        let p = pos2(lanes.left() + 100.0, lanes.top() + 30.0);
3685        h.press(p);
3686        h.release(p);
3687        h.frame(vec![]);
3688        assert_eq!(h.selection, vec![vid, aud], "plain click selects the link group");
3689        h.press_m(p, Modifiers::ALT);
3690        h.release_m(p, Modifiers::ALT);
3691        h.frame(vec![]);
3692        assert_eq!(h.selection, vec![vid], "Alt+click selects only the clicked clip");
3693    }
3694
3695    /// Spacer: a lane drag shifts everything starting at or after the press, forward without limit and
3696    /// backward only as far as the clip in front of the group.
3697    #[test]
3698    fn headless_spacer_opens_and_closes_a_gap() {
3699        let mut h = Harness::new();
3700        let lanes = h.state.lanes_rect;
3701        h.project.tracks[1].clips.clear(); // video only
3702        h.project.tracks[0].clips.clear();
3703        h.project.tracks[0].clips.push(Clip::new(101, ClipKind::Video, "a", 0.0, 5.0));
3704        h.project.tracks[0].clips.push(Clip::new(102, ClipKind::Video, "b", 6.0, 5.0));
3705        h.tool = Tool::Spacer;
3706        h.frame(vec![]);
3707        let start = |h: &Harness, i: usize| h.project.tracks[0].clips[i].start;
3708        // press in the gap at t = 5.5 and drag +80 px (= +2 s at zoom 40)
3709        let from = pos2(h.state.x_at(5.5), lanes.top() + 30.0);
3710        assert!(h.drag(from, from + vec2(80.0, 0.0)), "spacer drag edits");
3711        assert_eq!(h.undos, 1, "one undo per gesture");
3712        assert_eq!(start(&h, 0), 0.0, "clips before the press stay put");
3713        assert!((start(&h, 1) - 8.0).abs() < 0.05, "gap opened to {}", start(&h, 1));
3714        // drag far left: the group stops on the clip in front of it instead of overlapping it
3715        assert!(h.drag(from, from - vec2(400.0, 0.0)), "closing drag edits");
3716        assert_eq!(h.undos, 2);
3717        assert_eq!(start(&h, 0), 0.0);
3718        assert!((start(&h, 1) - 5.0).abs() < 0.05, "gap closed to {}, not past the left clip", start(&h, 1));
3719        // pressing on a clip body spaces from there too, instead of moving that clip
3720        let body = pos2(h.state.x_at(2.0), lanes.top() + 30.0);
3721        assert!(h.drag(body, body + vec2(40.0, 0.0)), "body drag edits");
3722        assert_eq!(start(&h, 0), 0.0, "the pressed clip is not the one that moves");
3723        assert!((start(&h, 1) - 6.0).abs() < 0.05, "clips after the press moved to {}", start(&h, 1));
3724    }
3725
3726    #[test]
3727    fn paste_clips_keeps_offsets_and_takes_fresh_ids() {
3728        use crate::engine::presets::{capture_template, decode_template};
3729        let mut h = Harness::new();
3730        h.project.tracks[1].clips.clear();
3731        h.project.split_at(4.0, None);
3732        let ids: Vec<Id> = h.project.tracks[0].clips.iter().map(|c| c.id).collect();
3733        let links: Vec<Id> = h.project.tracks[0].clips.iter().map(|c| c.link).collect();
3734        h.project.add_track(TrackKind::Video); // V2 = track index 1
3735        let tpl = capture_template("c", &h.project, &ids);
3736
3737        let (clips, assets) = decode_template(&tpl).unwrap();
3738        let new = paste_clips(&mut h.project, clips, assets, 20.0, Some(1));
3739        assert_eq!(new.len(), 2);
3740        assert!(new.iter().all(|id| !ids.contains(id)), "fresh clip ids");
3741        let starts: Vec<f64> = new.iter().map(|&id| h.project.clip(id).unwrap().start).collect();
3742        assert_eq!(starts, vec![20.0, 24.0], "relative offsets survive the paste");
3743        assert!(new.iter().all(|&id| h.project.track_of(id) == Some(1)), "pasted onto the clicked track");
3744        let nl: Vec<Id> = new.iter().map(|&id| h.project.clip(id).unwrap().link).collect();
3745        assert!(nl.iter().all(|l| *l != 0 && !links.contains(l)), "fresh link ids: {nl:?} vs {links:?}");
3746        // no target (Paste In Place): the first track of the kind with room takes it
3747        let (clips, assets) = decode_template(&tpl).unwrap();
3748        let free = paste_clips(&mut h.project, clips, assets, 40.0, None);
3749        assert!(free.iter().all(|&id| h.project.track_of(id) == Some(0)), "V1 is free at 40 s");
3750    }
3751
3752    #[test]
3753    fn waveform_takes_the_clip_label_colour() {
3754        let pal = Palette::new(true, Color32::from_rgb(0, 120, 212));
3755        assert_eq!(wave_color(pal.clip_audio, 0, &pal), pal.waveform, "unlabelled clips keep the palette wave");
3756        let bright = Color32::from_rgb(240, 220, 60);
3757        let w = wave_color(bright, 2, &pal);
3758        assert!(w.intensity() < bright.intensity(), "a bright label darkens its wave so it still reads");
3759        assert!(w.r() > w.b(), "the label's hue survives: {w:?}");
3760        let dark = Color32::from_rgb(30, 40, 120);
3761        assert!(wave_color(dark, 2, &pal).intensity() > dark.intensity(), "a dark label lightens its wave");
3762    }
3763
3764    #[test]
3765    fn audio_clip_menu_swaps_add_mask_for_a_bus() {
3766        let mut p = Project::new();
3767        p.add_bus("Music");
3768        let ctx = egui::Context::default();
3769        let mut texts = |audio: bool| -> Vec<String> {
3770            let (mut act, mut acts, mut edit) = (None, Vec::new(), false);
3771            let full = ctx.run(
3772                RawInput {
3773                    screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1400.0))),
3774                    ..Default::default()
3775                },
3776                |ctx| {
3777                    egui::CentralPanel::default().show(ctx, |ui| {
3778                        clip_menu(ui, 1, false, false, true, audio, &p.labels, &p.buses, &mut act, &mut acts, &mut edit)
3779                    });
3780                },
3781            );
3782            full.shapes
3783                .iter()
3784                .filter_map(|cs| match &cs.shape {
3785                    Shape::Text(t) => Some(t.galley.text().to_string()),
3786                    _ => None,
3787                })
3788                .collect()
3789        };
3790        let v = texts(false);
3791        assert!(v.iter().any(|s| s == "Add Mask"), "video clips keep Add Mask: {v:?}");
3792        assert!(!v.iter().any(|s| s == "Bus"), "video clips get no bus routing");
3793        let a = texts(true);
3794        assert!(!a.iter().any(|s| s == "Add Mask"), "a mask means nothing on audio: {a:?}");
3795        assert!(a.iter().any(|s| s == "Bus"), "audio clips get the bus submenu: {a:?}");
3796    }
3797
3798    #[test]
3799    fn headless_video_track_v_toggle_flips_muted() {
3800        let mut h = Harness::new();
3801        let lanes = h.state.lanes_rect;
3802        // V1 header row: S button right-aligned (18 wide, 4 in), V button 21 pt left of it
3803        let vb = pos2(lanes.left() - 34.0, lanes.top() + h.project.tracks[0].height * 0.5);
3804        assert!(!h.project.tracks[0].muted);
3805        h.press(vb);
3806        let r = h.release(vb);
3807        assert!(r.edited || h.frame(vec![]).edited, "V toggle marks edited");
3808        assert!(h.project.tracks[0].muted, "V toggle flips muted (visibility off)");
3809        assert_eq!(h.undos, 1);
3810    }
3811}