1use 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#[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;
55const SUB_LANE_H: f32 = 20.0;
57const EDGE_W: f32 = 6.0;
58const HANDLE_H: f32 = 4.0;
59const SNAP_PX: f32 = 8.0;
60const HBAR_H: f32 = 10.0;
62const VBAR_W: f32 = 10.0;
63const SCROLL_MARGIN: f32 = 16.0;
65const 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;
70const KEY_LANE_MIN: f32 = 28.0;
72const KEY_PAD: f32 = 3.0;
74const GUTTER_H: f32 = 5.0;
76const FLAG_W: f32 = 7.0;
78const 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 pub zoom: f32,
101 pub scroll_x: f64,
103 pub scroll_y: f32,
105 pub header_w: f32,
107 pub lanes_rect: egui::Rect,
109 drag: Option<Drag>,
111 user_panned: bool,
113 band: Option<(Pos2, bool)>,
115 pub selected_marker: Option<Id>,
117 rename: Option<(Id, String)>,
119 pub last_track: Option<usize>,
122 pub sub_h: f32,
124 pub sub_sel: Vec<Id>,
126 sub_band: Option<(f64, bool)>,
128 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
154fn 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 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 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 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 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 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 pub sel_transitions: &'a mut Vec<Id>,
253 pub playhead: &'a mut f64,
254 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 pub thumbs: Option<&'a mut ThumbCache>,
262 pub keep_ranges: &'a [(f64, f64)],
264 pub prerender: &'a [(f64, f64, bool)],
267 pub tool: crate::ui::tools::Tool,
270}
271
272#[derive(Default)]
273pub struct TimelineResponse {
274 pub edited: bool,
276 pub seeked: bool,
278 pub dropped_files: Vec<(PathBuf, f64, Option<usize>)>,
280 pub dropped_other: Vec<(DragPayload, f64, Option<usize>)>,
283 pub actions: Vec<crate::hotkeys::Action>,
285 pub replace_container: Option<(Id, bool)>,
287 pub open_sequence: Option<Id>,
289 pub edit_labels: bool,
291}
292
293struct Drag {
294 origin: Pos2,
296 before: Project,
298 g: Gesture,
299}
300
301enum Gesture {
302 Move { ids: Vec<Id>, orig: Vec<f64>, kind: TrackKind, tr: usize, dt: f64, dtrack: i32, new_track: bool },
306 Trim { ids: Vec<Id>, start: bool, edge: f64, changed: bool },
308 Stretch { id: Id, start: bool, edge: f64, src_len: f64, changed: bool },
311 Volume { id: Id, changed: bool },
313 Fade { id: Id, out: bool, changed: bool },
315 Keys { id: Id, t: f64, prop: Option<usize>, range: (f64, f64), changed: bool },
319 TransDur { track: usize, id: Id, changed: bool },
321 Marker { id: Id, clip: Option<Id>, changed: bool },
323 Spacer { ids: Vec<Id>, dt: f64, room: f64 },
326}
327
328enum 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 Label(u8),
341 LabelToAsset,
343 SetEase(Id, f64, Ease),
345 DelKeys(Id, f64),
347 RemoveTransition(Id),
348 RemoveTransitions(Vec<Id>),
349 AddMarker(f64),
351 SplitAt(f64),
353 RenameMarker(Id, String),
354 DelMarker(Id),
355 MarkerLabel(Id, u8),
356 Bus(Id),
358 ReplaceContainerMedia(Id),
360 ReplaceContainerPair(Id),
362 MakeContainer,
364 UnmakeContainer,
366 RenameContainer(Id, String),
368}
369
370fn 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
379fn 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
391fn 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
405fn 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
417fn 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
423fn 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
446fn 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
486fn 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
503fn 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
513fn 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
524fn 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)), (true, 4) => Some((0.0, 1.0)), (false, 1) => Some((-1.0, 1.0)), (_, _) if i == nb - 1 => Some((0.01, 100.0)), _ => 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
550fn 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
560fn 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#[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
629fn 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
655fn 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
677fn 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; let h = rect.height() - band;
691 if h < 12.0 || !vis.is_positive() {
692 return;
693 }
694 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
716fn 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 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 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 if escape {
882 state.band = None;
883 if let Some(d) = state.drag.take() {
884 *c.project = d.before;
885 }
886 }
887
888 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 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 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 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 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 c.sel_transitions.retain(|id| c.project.tracks.iter().any(|t| t.transitions.iter().any(|x| x.id == *id)));
970 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 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 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 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 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 let is_video = track.kind == TrackKind::Video;
1026 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 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 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 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 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 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 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 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 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 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 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 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 let cid = id.with(clip.id);
1286 let br = ui.interact(vis, cid, Sense::click_and_drag());
1287 if br.clicked() {
1288 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 for tr in &track.transitions {
1409 let Some((left, right)) = track.transition_clips(tr) else { continue };
1410 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if lanes_resp.clicked() && !mods.ctrl {
1893 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 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 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 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 {
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 if let Some((ti, dy)) = resize {
2056 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 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 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 let origin = ui.input(|i| i.pointer.press_origin()).or(pointer).unwrap_or(Pos2::ZERO);
2215 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 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 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 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 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; 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 *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 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 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 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 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 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 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 if let Some(tr) = p.tracks.get_mut(*track) {
2501 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 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 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 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 let mut want = if c.snap { p.snap_frame(dx) } else { dx };
2545 if c.snap {
2546 let now = ids.iter().filter_map(|&id| p.clip(id)).map(|cl| cl.start).fold(f64::INFINITY, f64::min);
2548 let lead = now - *dt; 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 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; 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 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 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 assert_eq!(snap_target(5.1, 0.2, &p, 20.0, &[]), Some(5.0));
2645 assert_eq!(snap_target(5.1, 0.2, &p, 20.0, &[7]), None);
2647 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(); p.add_track(TrackKind::Video); p.add_track(TrackKind::Audio); 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 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); 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, 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 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 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![]); 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 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 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 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 #[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 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 #[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; 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 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 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 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 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 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 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 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 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 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 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))); 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); h.project.tracks[1].height = 200.0;
3075 h.frame(vec![]);
3076 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 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 #[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); 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 let empty = pos2(lanes.left() + 480.0, lanes.top() + 30.0); 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 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); 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 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 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 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 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); assert!(h.drag(from, from + vec2(160.0, 12.0)), "volume drag edits");
3196 let v = &h.audio_clip().volume;
3197 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; 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 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(); 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 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 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 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 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 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 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 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; 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 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 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 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 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 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 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 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 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 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 assert!(ms < 10.0, "1000-clip frame took {ms:.2} ms");
3536 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 #[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 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 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 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 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 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 assert!((tr.duration - 2.9).abs() < 0.06, "duration {}", tr.duration);
3608 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 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 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 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))]); 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; h.frame(vec![]);
3662 let lanes = h.state.lanes_rect;
3663 let bar_y = lanes.bottom() + HBAR_H * 0.5;
3664 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; assert!((h.state.scroll_x - expect).abs() < expect * 0.3, "scroll_x {} vs {expect}", h.state.scroll_x);
3670 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 #[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(); 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 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 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 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); 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 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 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}