1use crate::model::{Animated, Clip, Ease, Id, Keyframe, Project};
18use crate::settings::{CurvePreset, MotionPreset};
19use crate::theme::Palette;
20use eframe::egui::{self, pos2, vec2, Align2, Color32, Pos2, Rect, Sense, Shape, Stroke};
21use std::cell::RefCell;
22
23pub(crate) const RULER_H: f32 = 16.0;
24const LIST_W: f32 = 150.0;
25const LIST_MIN: f32 = 90.0;
26const LIST_MAX: f32 = 320.0;
27const SPLIT_W: f32 = 5.0;
29const HIT_PX: f32 = 6.0;
30const T_EPS: f64 = 1e-4;
32
33thread_local! {
34 static AVAILABLE: RefCell<Vec<CurvePreset>> = const { RefCell::new(Vec::new()) };
37 static PENDING: RefCell<Option<CurvePreset>> = const { RefCell::new(None) };
38 static MOTIONS: RefCell<Vec<MotionPreset>> = const { RefCell::new(Vec::new()) };
40 static PENDING_MOTION: RefCell<Option<MotionPreset>> = const { RefCell::new(None) };
41}
42
43pub fn set_available_motions(v: Vec<MotionPreset>) {
45 MOTIONS.with(|m| *m.borrow_mut() = v);
46}
47
48pub fn take_pending_motion_preset() -> Option<MotionPreset> {
50 PENDING_MOTION.with(|p| p.borrow_mut().take())
51}
52
53pub fn set_available_presets(v: Vec<CurvePreset>) {
56 AVAILABLE.with(|a| *a.borrow_mut() = v);
57}
58
59pub fn take_pending_curve_preset() -> Option<CurvePreset> {
61 PENDING.with(|p| p.borrow_mut().take())
62}
63
64#[derive(Clone, Copy, PartialEq)]
65enum Drag {
66 Key {
67 prop: usize,
68 key: usize,
69 },
70 HandleOut {
72 prop: usize,
73 key: usize,
74 },
75 HandleIn {
77 prop: usize,
78 key: usize,
79 },
80 Seek,
81}
82
83pub struct CurvesState {
84 pub active: usize,
86 pub target_bus: Option<Id>,
88 pub seen_mixer_bus: Option<Id>,
91 pub list_w: f32,
93 pub hidden: Vec<usize>,
95 pub scroll_x: f64,
97 pub zoom: f32,
98 pub y_zoom: f64,
100 pub y_pan: f64,
101 pub selected: Vec<(usize, usize)>,
103 pub graph: Rect,
105 pub pps: f32,
106 pub y_lo: f64,
107 pub y_hi: f64,
108 drag: Option<Drag>,
109 drag_y: Vec<(usize, (f64, f64))>,
112 drag_keys: Vec<(usize, Vec<Keyframe>, Vec<usize>)>,
115 drag_from: Option<Pos2>,
117 band: Option<Pos2>,
119 clipboard: Vec<(usize, Keyframe)>,
121 preset_name: String,
122 preset_sel: usize,
123 motion_sel: usize,
124 menu_key: Option<(usize, usize)>,
126}
127
128impl Default for CurvesState {
129 fn default() -> Self {
130 Self {
131 target_bus: None,
132 seen_mixer_bus: None,
133 active: 0,
134 list_w: LIST_W,
135 hidden: Vec::new(),
136 scroll_x: 0.0,
137 zoom: 0.0,
138 y_zoom: 1.0,
139 y_pan: 0.0,
140 selected: Vec::new(),
141 graph: Rect::NOTHING,
142 pps: 0.0,
143 y_lo: 0.0,
144 y_hi: 1.0,
145 drag: None,
146 drag_y: Vec::new(),
147 drag_keys: Vec::new(),
148 drag_from: None,
149 band: None,
150 clipboard: Vec::new(),
151 preset_name: String::new(),
152 preset_sel: 0,
153 motion_sel: 0,
154 menu_key: None,
155 }
156 }
157}
158
159#[derive(Default)]
160pub struct CurvesResponse {
161 pub edited: bool,
162 pub seeked: bool,
163}
164
165fn base_count(c: &Clip) -> usize {
168 if c.is_visual() {
169 6
170 } else {
171 3
172 }
173}
174
175pub(crate) fn prop_count(c: &Clip) -> usize {
176 base_count(c) + c.effects.iter().map(|e| e.params.len()).sum::<usize>()
177}
178
179pub(crate) fn prop_label(c: &Clip, i: usize) -> String {
180 let names: &[&str] = if c.is_visual() {
181 &["Position X", "Position Y", "Scale", "Rotation", "Opacity", "Speed"]
182 } else {
183 &["Volume", "Pan", "Speed"]
184 };
185 if i < names.len() {
186 return names[i].to_string();
187 }
188 let mut i = i - names.len();
189 for e in &c.effects {
190 if i < e.params.len() {
191 let p = e.specs().get(i).map(|s| s.name).unwrap_or("?");
192 return format!("{}: {}", e.kind.name(), p);
193 }
194 i -= e.params.len();
195 }
196 String::new()
197}
198
199pub(crate) fn prop_ref(c: &Clip, i: usize) -> Option<&Animated> {
200 let nb = base_count(c);
201 if i < nb {
202 return Some(if c.is_visual() {
203 match i {
204 0 => &c.x,
205 1 => &c.y,
206 2 => &c.scale,
207 3 => &c.rotation,
208 4 => &c.opacity,
209 _ => &c.speed_curve,
210 }
211 } else {
212 match i {
213 0 => &c.volume,
214 1 => &c.pan,
215 _ => &c.speed_curve,
216 }
217 });
218 }
219 let mut i = i - nb;
220 for e in &c.effects {
221 if i < e.params.len() {
222 return Some(&e.params[i]);
223 }
224 i -= e.params.len();
225 }
226 None
227}
228
229pub(crate) fn prop_mut(c: &mut Clip, i: usize) -> Option<&mut Animated> {
230 let nb = base_count(c);
231 if i < nb {
232 return Some(if c.is_visual() {
233 match i {
234 0 => &mut c.x,
235 1 => &mut c.y,
236 2 => &mut c.scale,
237 3 => &mut c.rotation,
238 4 => &mut c.opacity,
239 _ => &mut c.speed_curve,
240 }
241 } else {
242 match i {
243 0 => &mut c.volume,
244 1 => &mut c.pan,
245 _ => &mut c.speed_curve,
246 }
247 });
248 }
249 let mut i = i - nb;
250 for e in &mut c.effects {
251 if i < e.params.len() {
252 return Some(&mut e.params[i]);
253 }
254 i -= e.params.len();
255 }
256 None
257}
258
259pub(crate) fn y_range(a: &Animated) -> (f64, f64) {
261 let (mut lo, mut hi) = (f64::MAX, f64::MIN);
262 if a.keys.is_empty() {
263 lo = a.value;
264 hi = a.value;
265 }
266 for k in &a.keys {
267 lo = lo.min(k.v);
268 hi = hi.max(k.v);
269 }
270 let pad = ((hi - lo) * 0.1).max(0.5);
271 (lo - pad, hi + pad)
272}
273
274fn zoomed((lo, hi): (f64, f64), zoom: f64, pan: f64) -> (f64, f64) {
277 let span = hi - lo;
278 let c = (lo + hi) * 0.5 + pan * span;
279 let h = span * 0.5 / zoom.max(0.01);
280 (c - h, c + h)
281}
282
283fn move_group(a: &mut Animated, orig: &[Keyframe], sel: &[usize], dt: f64, dv: f64) -> Vec<usize> {
286 let mut v: Vec<(Keyframe, bool)> = orig
287 .iter()
288 .enumerate()
289 .map(|(i, k)| {
290 let m = sel.contains(&i);
291 (Keyframe { t: k.t + if m { dt } else { 0.0 }, v: k.v + if m { dv } else { 0.0 }, ease: k.ease }, m)
292 })
293 .collect();
294 v.sort_by(|a, b| a.0.t.total_cmp(&b.0.t));
295 let moved: Vec<f64> = v.iter().filter(|(_, m)| *m).map(|(k, _)| k.t).collect();
297 v.retain(|(k, m)| *m || !moved.iter().any(|t| (t - k.t).abs() < T_EPS));
298 a.keys = v.iter().map(|(k, _)| *k).collect();
299 v.iter().enumerate().filter(|(_, (_, m))| *m).map(|(i, _)| i).collect()
300}
301
302fn fit_view(state: &mut CurvesState, snap: &[Animated], n_props: usize) {
304 state.y_zoom = 1.0;
305 state.y_pan = 0.0;
306 let (mut lo, mut hi) = (f64::MAX, f64::MIN);
307 for p in 0..n_props {
308 if state.hidden.contains(&p) {
309 continue;
310 }
311 for k in snap.get(p).map(|a| a.keys.as_slice()).unwrap_or(&[]) {
312 lo = lo.min(k.t);
313 hi = hi.max(k.t);
314 }
315 }
316 let w = state.graph.width() as f64;
317 if hi > lo && w > 20.0 {
318 let pad = (hi - lo) * 0.05;
319 state.zoom = (w / (hi - lo + 2.0 * pad)).clamp(2.0, 10_000.0) as f32;
320 state.scroll_x = lo - pad;
321 } else {
322 state.zoom = 0.0; state.scroll_x = 0.0;
324 }
325}
326
327fn prop_color(pal: &Palette, i: usize) -> Color32 {
328 let cycle =
329 [pal.clip_video, pal.clip_audio, pal.clip_image, pal.clip_text, pal.clip_sequence, pal.waveform, pal.keyframe];
330 cycle[i % cycle.len()]
331}
332
333fn remove_key(a: &mut Animated, i: usize) {
335 if i < a.keys.len() {
336 let v = a.keys.remove(i).v;
337 if a.keys.is_empty() {
338 a.value = v;
339 }
340 }
341}
342
343fn flow_pair(project: &Project, selection: &[Id]) -> Option<(Id, Id)> {
345 if selection.len() != 2 {
346 return None;
347 }
348 let a = project.clip(selection[0])?;
349 let b = project.clip(selection[1])?;
350 if (a.end() - b.start).abs() < crate::model::ABUT_EPS {
351 Some((a.id, b.id))
352 } else if (b.end() - a.start).abs() < crate::model::ABUT_EPS {
353 Some((b.id, a.id))
354 } else {
355 None
356 }
357}
358
359fn tick_step(pps: f32) -> f64 {
361 for s in [0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0, 300.0] {
362 if s * pps as f64 >= 70.0 {
363 return s;
364 }
365 }
366 600.0
367}
368
369#[derive(Clone, Copy, PartialEq, Debug)]
372pub(crate) enum Target {
373 Clip(Id),
374 Bus(Id),
375}
376
377const BUS_BASE: usize = 2;
379
380fn t_count(p: &Project, t: Target) -> usize {
381 match t {
382 Target::Clip(id) => p.clip(id).map(prop_count).unwrap_or(0),
383 Target::Bus(id) => {
384 p.bus(id).map(|b| BUS_BASE + b.filters.iter().map(|f| f.params.len()).sum::<usize>()).unwrap_or(0)
385 }
386 }
387}
388
389fn t_ref(p: &Project, t: Target, i: usize) -> Option<&Animated> {
390 match t {
391 Target::Clip(id) => p.clip(id).and_then(|c| prop_ref(c, i)),
392 Target::Bus(id) => {
393 let b = p.bus(id)?;
394 match i {
395 0 => Some(&b.gain),
396 1 => Some(&b.pan),
397 _ => {
398 let mut i = i - BUS_BASE;
399 for f in &b.filters {
400 if i < f.params.len() {
401 return f.params.get(i);
402 }
403 i -= f.params.len();
404 }
405 None
406 }
407 }
408 }
409 }
410}
411
412fn t_mut(p: &mut Project, t: Target, i: usize) -> Option<&mut Animated> {
413 match t {
414 Target::Clip(id) => p.clip_mut(id).and_then(|c| prop_mut(c, i)),
415 Target::Bus(id) => {
416 let b = p.bus_mut(id)?;
417 match i {
418 0 => Some(&mut b.gain),
419 1 => Some(&mut b.pan),
420 _ => {
421 let mut i = i - BUS_BASE;
422 for f in &mut b.filters {
423 if i < f.params.len() {
424 return f.params.get_mut(i);
425 }
426 i -= f.params.len();
427 }
428 None
429 }
430 }
431 }
432 }
433}
434
435fn t_label(p: &Project, t: Target, i: usize) -> String {
436 match t {
437 Target::Clip(id) => p.clip(id).map(|c| prop_label(c, i)).unwrap_or_default(),
438 Target::Bus(id) => {
439 let Some(b) = p.bus(id) else { return String::new() };
440 match i {
441 0 => "Gain".to_string(),
442 1 => "Pan".to_string(),
443 _ => {
444 let mut i = i - BUS_BASE;
445 for f in &b.filters {
446 if i < f.params.len() {
447 let n = f.kind.params().get(i).map(|s| s.name).unwrap_or("?");
448 return format!("{}: {}", f.kind.name(), n);
449 }
450 i -= f.params.len();
451 }
452 String::new()
453 }
454 }
455 }
456 }
457}
458
459fn t_span(p: &Project, t: Target) -> (f64, f64) {
462 match t {
463 Target::Clip(id) => p.clip(id).map(|c| (c.start, c.duration)).unwrap_or((0.0, 1.0)),
464 Target::Bus(_) => (0.0, p.duration().max(crate::model::MIN_CLIP)),
465 }
466}
467
468enum MenuAct {
469 SetEase(Ease),
470 Delete,
471}
472
473fn add_key_at(
476 project: &mut Project,
477 target: Target,
478 state: &mut CurvesState,
479 out: &mut CurvesResponse,
480 undo: &mut dyn FnMut(&Project),
481 t: f64,
482 v: f64,
483) {
484 undo(project);
485 let active = state.active;
486 if let Some(a) = t_mut(project, target, active) {
487 if !a.is_animated() {
488 a.toggle_key(t);
489 }
490 a.set_at(t, v);
491 if let Some(k) = a.key_index_at(t) {
492 state.selected.clear();
493 state.selected.push((active, k));
494 }
495 out.edited = true;
496 }
497}
498
499#[allow(clippy::too_many_arguments)]
500pub fn show(
501 ui: &mut egui::Ui,
502 state: &mut CurvesState,
503 project: &mut Project,
504 selection: &[Id],
505 mixer_bus: Option<Id>,
506 playhead: &mut f64,
507 palette: &Palette,
508 undo: &mut dyn FnMut(&Project),
509) -> CurvesResponse {
510 let mut out = CurvesResponse::default();
511 let pal = *palette;
512 state.target_bus = state.target_bus.filter(|id| project.bus(*id).is_some());
514 if let Some(b) = mixer_bus.filter(|b| project.bus(*b).is_some()) {
517 if state.target_bus != Some(b) && state.seen_mixer_bus != Some(b) {
518 state.target_bus = Some(b);
519 state.active = 0;
520 state.selected.clear();
521 }
522 }
523 state.seen_mixer_bus = mixer_bus;
524 ui.horizontal(|ui| {
525 ui.label("Curves for");
526 let buses: Vec<(Id, String)> = project.buses.iter().map(|b| (b.id, b.name.clone())).collect();
527 let text = match state.target_bus.and_then(|id| buses.iter().find(|(b, _)| *b == id)) {
528 Some((_, n)) => format!("Bus: {n}"),
529 None => "Selected clip".to_string(),
530 };
531 egui::ComboBox::from_id_salt("curve_target").selected_text(text).show_ui(ui, |ui| {
532 ui.selectable_value(&mut state.target_bus, None, "Selected clip");
533 for (id, name) in buses {
534 ui.selectable_value(&mut state.target_bus, Some(id), format!("Bus: {name}"));
535 }
536 });
537 });
538 let target = match state.target_bus {
539 Some(b) => Target::Bus(b),
540 None => match selection.iter().find(|&&id| project.clip(id).is_some()) {
541 Some(&id) => Target::Clip(id),
542 None => {
543 ui.label("Select a clip, or pick a bus above");
544 return out;
545 }
546 },
547 };
548 let id = match target {
549 Target::Clip(id) => id,
550 Target::Bus(_) => 0,
552 };
553 let n_props = t_count(project, target);
554 if n_props == 0 {
555 ui.label("Nothing to animate");
556 return out;
557 }
558 state.active = state.active.min(n_props - 1);
559 {
561 let valid_key = |p: usize, k: usize| t_ref(project, target, p).is_some_and(|a| k < a.keys.len());
562 state.selected.retain(|&(p, k)| valid_key(p, k));
563 if let Some(d) = state.drag {
564 let valid = match d {
565 Drag::Key { prop, key } | Drag::HandleOut { prop, key } | Drag::HandleIn { prop, key } => {
566 valid_key(prop, key)
567 }
568 Drag::Seek => true,
569 };
570 if !valid {
571 state.drag = None;
572 }
573 }
574 }
575
576 let is_clip = matches!(target, Target::Clip(_));
579 ui.add_enabled_ui(is_clip, |ui| {
580 ui.horizontal(|ui| {
581 let motions: Vec<String> = MOTIONS.with(|m| m.borrow().iter().map(|p| p.name.clone()).collect());
582 ui.label("Motion");
583 if motions.is_empty() {
584 ui.weak("(none)");
585 } else {
586 state.motion_sel = state.motion_sel.min(motions.len() - 1);
587 egui::ComboBox::from_id_salt("motion_preset").selected_text(motions[state.motion_sel].clone()).show_ui(
588 ui,
589 |ui| {
590 for (i, n) in motions.iter().enumerate() {
591 ui.selectable_value(&mut state.motion_sel, i, n);
592 }
593 },
594 );
595 let mut apply: Option<bool> = None; if ui.small_button("Apply").on_hover_text("Stretch the preset to this clip's length").clicked() {
597 apply = Some(true);
598 }
599 if ui.small_button("Apply exact").on_hover_text("Keep the preset's own timing").clicked() {
600 apply = Some(false);
601 }
602 let merge = ui
603 .small_button("Merge")
604 .on_hover_text("Add the preset's keys from the playhead on, keeping what is already there")
605 .clicked();
606 if apply.is_some() || merge {
607 let preset = MOTIONS.with(|m| m.borrow().get(state.motion_sel).cloned());
608 if let Some(preset) = preset {
609 undo(project);
610 let ph = *playhead;
611 if let Some(c) = project.clip_mut(id) {
612 match apply {
613 Some(scaled) => crate::engine::presets::apply_motion(&preset, c, scaled),
614 None => {
615 let off = c.local(ph).clamp(0.0, c.duration);
616 crate::engine::presets::merge_motion(&preset, c, off);
617 }
618 }
619 out.edited = true;
620 }
621 }
622 }
623 }
624 let can_save = !state.preset_name.trim().is_empty() && is_clip;
625 if ui
626 .add_enabled(can_save, egui::Button::new("Save motion"))
627 .on_hover_text("Save every animated property of this clip under the name on the left")
628 .clicked()
629 {
630 if let Some(c) = project.clip(id) {
631 PENDING_MOTION.with(|s| {
632 *s.borrow_mut() = Some(crate::engine::presets::capture_motion(state.preset_name.trim(), c))
633 });
634 state.preset_name.clear();
635 }
636 }
637 });
638 });
639
640 let mut want_fit = false;
642 ui.horizontal(|ui| {
643 ui.add(egui::TextEdit::singleline(&mut state.preset_name).hint_text("Preset name").desired_width(90.0));
644 let can_save = !state.preset_name.trim().is_empty();
645 if ui.add_enabled(can_save, egui::Button::new("Save curve preset")).clicked() {
646 if let Some(a) = t_ref(project, target, state.active) {
647 let dur = t_span(project, target).1;
648 if let Some(p) = crate::engine::presets::capture_curve(state.preset_name.trim(), a, dur, false) {
649 PENDING.with(|s| *s.borrow_mut() = Some(p));
650 state.preset_name.clear();
651 }
652 }
653 }
654 let names: Vec<String> = AVAILABLE.with(|a| a.borrow().iter().map(|p| p.name.clone()).collect());
655 if !names.is_empty() {
656 state.preset_sel = state.preset_sel.min(names.len() - 1);
657 egui::ComboBox::from_id_salt("curve_preset").selected_text(names[state.preset_sel].clone()).show_ui(
658 ui,
659 |ui| {
660 for (i, n) in names.iter().enumerate() {
661 ui.selectable_value(&mut state.preset_sel, i, n);
662 }
663 },
664 );
665 let mut apply: Option<bool> = None; if ui.small_button("Apply exact").clicked() {
667 apply = Some(false);
668 }
669 if ui.small_button("Apply scaled").clicked() {
670 apply = Some(true);
671 }
672 if let Some(scaled) = apply {
673 let preset = AVAILABLE.with(|a| a.borrow().get(state.preset_sel).cloned());
674 if let Some(preset) = preset {
675 undo(project);
676 if let Some(c) = project.clip_mut(id) {
677 let dur = c.duration;
678 if let Some(a) = prop_mut(c, state.active) {
679 crate::engine::presets::apply_curve(&preset, a, dur, scaled);
680 out.edited = true;
681 }
682 }
683 }
684 }
685 }
686 let flow = if is_clip { flow_pair(project, selection) } else { None };
687 if ui
688 .add_enabled(flow.is_some(), egui::Button::new("Flow →"))
689 .on_hover_text("Continue the first clip's motion into the second")
690 .on_disabled_hover_text("Select exactly two abutting clips")
691 .clicked()
692 {
693 if let Some((a, b)) = flow {
694 undo(project);
695 out.edited |= project.flow_clips(a, b);
696 }
697 }
698 if ui
699 .small_button("Fit")
700 .on_hover_text("Frame every keyframe (Ctrl+Scroll zooms time, Scroll zooms values)")
701 .clicked()
702 {
703 want_fit = true;
704 }
705 });
706
707 let avail = ui.available_rect_before_wrap();
709 state.list_w = state.list_w.clamp(LIST_MIN, LIST_MAX.min((avail.width() - 40.0).max(LIST_MIN)));
710 let list_rect = Rect::from_min_max(avail.min, pos2(avail.left() + state.list_w, avail.bottom()));
711 let split =
712 Rect::from_min_max(pos2(list_rect.right(), avail.top()), pos2(list_rect.right() + SPLIT_W, avail.bottom()));
713 let graph = Rect::from_min_max(pos2(split.right(), avail.top()), avail.max);
714 ui.allocate_rect(avail, Sense::hover());
715
716 let sr = ui.interact(split, ui.id().with("curve_split"), Sense::drag());
718 if sr.hovered() || sr.dragged() {
719 ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
720 }
721 if sr.dragged() {
722 state.list_w = (state.list_w + sr.drag_delta().x).clamp(LIST_MIN, LIST_MAX);
723 }
724 ui.painter().rect_filled(
725 split.shrink2(vec2(1.0, 0.0)),
726 0,
727 if sr.hovered() || sr.dragged() { pal.accent } else { pal.border },
728 );
729 let snap: Vec<Animated> = (0..n_props).filter_map(|i| t_ref(project, target, i).cloned()).collect();
732 if snap.len() != n_props {
733 ui.label("Nothing to animate");
734 return out;
735 }
736 let labels: Vec<String> = (0..n_props).map(|i| t_label(project, target, i)).collect();
737 let (span_start, span_dur) = t_span(project, target);
738 let dur = span_dur.max(crate::model::MIN_CLIP);
739 if want_fit {
740 fit_view(state, &snap, n_props);
741 }
742
743 let mut list_ui = ui.new_child(egui::UiBuilder::new().max_rect(list_rect));
745 egui::ScrollArea::vertical().id_salt("curve_props").show(&mut list_ui, |ui| {
747 for i in 0..n_props {
748 let a = snap.get(i);
749 ui.horizontal(|ui| {
750 let mut visible = !state.hidden.contains(&i);
751 if ui.checkbox(&mut visible, "").changed() {
752 if visible {
753 state.hidden.retain(|&h| h != i);
754 } else {
755 state.hidden.push(i);
756 }
757 }
758 let (_, sw) = ui.allocate_space(vec2(10.0, 10.0));
759 ui.painter().rect_filled(sw, 2, prop_color(&pal, i));
760 if ui.selectable_label(state.active == i, labels[i].clone()).clicked() {
761 state.active = i;
762 }
763 if a.is_some_and(|a| a.is_animated()) {
765 crate::ui::tools::glyph_label(ui, crate::ui::tools::Glyph::Diamond, pal.text_dim);
766 }
767 });
768 }
769 });
770
771 if graph.width() < 20.0 || graph.height() < RULER_H + 10.0 {
773 return out;
774 }
775 let plot = Rect::from_min_max(pos2(graph.left(), graph.top() + RULER_H), graph.max);
776 let pps = if state.zoom > 0.0 { state.zoom } else { (plot.width() as f64 / dur) as f32 };
777 if state.zoom <= 0.0 {
778 state.scroll_x = 0.0;
779 }
780 let x_at = |t: f64, sx: f64| plot.left() + ((t - sx) * pps as f64) as f32;
781 let t_at = |x: f32, sx: f64| sx + ((x - plot.left()) / pps) as f64;
782 let sx = state.scroll_x;
783 let mut scales: Vec<(f64, f64)> = Vec::with_capacity(n_props);
785 for i in 0..n_props {
786 let auto = snap.get(i).map(y_range).unwrap_or((0.0, 1.0));
787 scales.push(zoomed(auto, state.y_zoom, state.y_pan));
788 }
789 for &(p, ys) in &state.drag_y {
791 if let Some(s) = scales.get_mut(p) {
792 *s = ys;
793 }
794 }
795 let y_at = |v: f64, (lo, hi): (f64, f64)| plot.bottom() - (((v - lo) / (hi - lo)) * plot.height() as f64) as f32;
796 let v_at = |y: f32, (lo, hi): (f64, f64)| lo + (((plot.bottom() - y) / plot.height()) as f64) * (hi - lo);
797 state.graph = graph;
798 state.pps = pps;
799 (state.y_lo, state.y_hi) = scales[state.active];
800
801 let resp = ui.interact(graph, ui.id().with("curve_graph"), Sense::click_and_drag());
802 let pointer = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos()));
803 let press_origin = ui.input(|i| i.pointer.press_origin());
804 let hidden_snap = state.hidden.clone();
805 let selected_snap = state.selected.clone();
806
807 if ui.rect_contains_pointer(graph) {
809 let (delta, zoom, mpos) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.latest_pos()));
810 if zoom != 1.0 {
811 if let Some(m) = mpos {
812 let t_c = t_at(m.x, state.scroll_x);
813 state.zoom = (pps * zoom.clamp(0.5, 2.0)).clamp(2.0, 10_000.0);
814 state.scroll_x = t_c - ((m.x - plot.left()) / state.zoom) as f64;
815 }
816 } else if delta.x != 0.0 {
817 state.zoom = pps; state.scroll_x = (state.scroll_x - (delta.x / pps) as f64).clamp(-dur, dur);
819 } else if delta.y != 0.0 {
820 let f = mpos.map(|m| ((plot.bottom() - m.y) / plot.height()).clamp(0.0, 1.0) as f64).unwrap_or(0.5);
823 let h0 = 0.5 / state.y_zoom;
824 state.y_zoom = (state.y_zoom * (1.0 + delta.y as f64 * 0.01)).clamp(0.05, 100.0);
825 state.y_pan += (h0 - 0.5 / state.y_zoom) * (2.0 * f - 1.0);
826 }
827 }
828
829 if resp.dragged_by(egui::PointerButton::Middle) {
832 let d = resp.drag_delta();
833 state.zoom = pps; state.scroll_x = (state.scroll_x - (d.x / pps) as f64).clamp(-dur, dur);
835 let auto = snap.get(state.active).map(y_range).unwrap_or((0.0, 1.0));
836 let span = (auto.1 - auto.0).max(1e-9);
837 state.y_pan += (d.y as f64 / plot.height() as f64) * (state.y_hi - state.y_lo) / span;
838 }
839
840 let visible = move |i: usize| !hidden_snap.contains(&i);
842 let key_hit = |pos: Pos2| -> Option<(usize, usize)> {
843 let mut best: Option<((usize, usize), f32)> = None;
844 for p in 0..n_props {
845 if !visible(p) {
846 continue;
847 }
848 let Some(a) = snap.get(p) else { continue };
849 for (k, key) in a.keys.iter().enumerate() {
850 let kp = pos2(x_at(key.t, sx), y_at(key.v, scales[p]));
851 let d = kp.distance(pos);
852 if d <= HIT_PX && best.is_none_or(|(_, bd)| d < bd) {
853 best = Some(((p, k), d));
854 }
855 }
856 }
857 best.map(|(pk, _)| pk)
858 };
859 let handle_positions = |p: usize, k: usize| -> Option<(Pos2, Pos2, Pos2, Pos2)> {
861 let a = snap.get(p)?;
862 let (k0, k1) = (a.keys.get(k)?, a.keys.get(k + 1)?);
863 let (x1, y1, x2, y2) = k0.ease.handles();
864 let seg_dx = k1.t - k0.t;
865 let seg_dv = k1.v - k0.v;
866 let p0 = pos2(x_at(k0.t, sx), y_at(k0.v, scales[p]));
867 let p3 = pos2(x_at(k1.t, sx), y_at(k1.v, scales[p]));
868 let h1 = pos2(x_at(k0.t + x1 as f64 * seg_dx, sx), y_at(k0.v + y1 as f64 * seg_dv, scales[p]));
869 let h2 = pos2(x_at(k0.t + x2 as f64 * seg_dx, sx), y_at(k0.v + y2 as f64 * seg_dv, scales[p]));
870 Some((p0, h1, h2, p3))
871 };
872 let shows_handles = |p: usize, k: usize| -> bool {
873 if !visible(p) {
874 return false;
875 }
876 let bez = snap.get(p).and_then(|a| a.keys.get(k)).is_some_and(|key| matches!(key.ease, Ease::Bezier { .. }));
877 bez || selected_snap.contains(&(p, k)) || selected_snap.contains(&(p, k + 1))
878 };
879 let handle_hit = |pos: Pos2| -> Option<Drag> {
880 for p in 0..n_props {
881 let Some(a) = snap.get(p) else { continue };
882 for k in 0..a.keys.len().saturating_sub(1) {
883 if !shows_handles(p, k) {
884 continue;
885 }
886 if let Some((_, h1, h2, _)) = handle_positions(p, k) {
887 if h1.distance(pos) <= HIT_PX {
888 return Some(Drag::HandleOut { prop: p, key: k });
889 }
890 if h2.distance(pos) <= HIT_PX {
891 return Some(Drag::HandleIn { prop: p, key: k });
892 }
893 }
894 }
895 }
896 None
897 };
898
899 if resp.drag_started_by(egui::PointerButton::Primary) {
901 if let Some(pos) = press_origin.or(pointer) {
902 if pos.y < plot.top() {
903 state.drag = Some(Drag::Seek);
904 } else if let Some(h) = handle_hit(pos) {
905 undo(project);
906 state.drag = Some(h);
907 state.drag_y = match h {
908 Drag::HandleOut { prop, .. } | Drag::HandleIn { prop, .. } => {
909 scales.get(prop).map(|&s| (prop, s)).into_iter().collect()
910 }
911 _ => Vec::new(),
912 };
913 } else if let Some((p, k)) = key_hit(pos) {
914 if !state.selected.contains(&(p, k)) {
915 state.selected.clear();
916 state.selected.push((p, k));
917 }
918 undo(project);
919 state.drag = Some(Drag::Key { prop: p, key: k });
920 state.drag_from = Some(pos);
921 let mut props: Vec<usize> = state.selected.iter().map(|s| s.0).collect();
923 props.sort_unstable();
924 props.dedup();
925 state.drag_y = props.iter().filter_map(|&p| scales.get(p).map(|&s| (p, s))).collect();
926 state.drag_keys = props
927 .iter()
928 .filter_map(|&p| {
929 let a = snap.get(p)?;
930 let idx: Vec<usize> = state.selected.iter().filter(|s| s.0 == p).map(|s| s.1).collect();
931 (!idx.is_empty()).then(|| (p, a.keys.clone(), idx))
932 })
933 .collect();
934 } else {
935 state.band = Some(pos); }
937 }
938 }
939 if resp.clicked() {
940 if let Some(pos) = pointer {
941 if pos.y < plot.top() {
942 *playhead = span_start + t_at(pos.x, sx).clamp(0.0, dur);
943 out.seeked = true;
944 } else {
945 let ctrl = ui.input(|i| i.modifiers.ctrl);
946 match key_hit(pos) {
947 Some(pk) if ctrl => {
948 if let Some(i) = state.selected.iter().position(|&s| s == pk) {
949 state.selected.remove(i);
950 } else {
951 state.selected.push(pk);
952 }
953 }
954 Some(pk) => {
955 state.selected.clear();
956 state.selected.push(pk);
957 }
958 None if !ctrl => state.selected.clear(),
959 None => {}
960 }
961 }
962 }
963 }
964 if resp.dragged() {
965 if let (Some(drag), Some(pos)) = (state.drag, pointer) {
966 match drag {
967 Drag::Seek => {
968 *playhead = span_start + t_at(pos.x, sx).clamp(0.0, dur);
969 out.seeked = true;
970 }
971 Drag::Key { prop, key } => {
972 let o = state.drag_from.unwrap_or(pos);
973 let dx = ((pos.x - o.x) / pps) as f64;
974 let dy = (o.y - pos.y.clamp(plot.top(), plot.bottom())) / plot.height();
975 let (mut sel, mut anchor) = (Vec::new(), key);
976 for (p, orig, idx) in state.drag_keys.clone() {
977 let (lo, hi) = idx
979 .iter()
980 .filter_map(|&i| orig.get(i))
981 .fold((f64::MAX, f64::MIN), |(a, b), k| (a.min(k.t), b.max(k.t)));
982 let dt = dx.clamp(-lo, (dur - hi).max(-lo));
983 let dv = dy as f64 * (scales[p].1 - scales[p].0);
984 let Some(a) = t_mut(project, target, p) else { continue };
985 let new = move_group(a, &orig, &idx, dt, dv);
986 if p == prop {
987 if let Some(j) = idx.iter().position(|&i| i == key) {
988 anchor = new.get(j).copied().unwrap_or(key);
989 }
990 }
991 sel.extend(new.into_iter().map(|k| (p, k)));
992 out.edited = true;
993 }
994 state.drag = Some(Drag::Key { prop, key: anchor });
995 state.selected = sel;
996 }
997 Drag::HandleOut { prop, key } | Drag::HandleIn { prop, key } => {
998 let is_out = matches!(drag, Drag::HandleOut { .. });
999 if let Some(a) = t_mut(project, target, prop) {
1000 if key + 1 < a.keys.len() {
1001 let (k0t, k0v) = (a.keys[key].t, a.keys[key].v);
1002 let (k1t, k1v) = (a.keys[key + 1].t, a.keys[key + 1].v);
1003 let seg_dt = (k1t - k0t).max(1e-9);
1004 let seg_dv = if (k1v - k0v).abs() > 1e-9 {
1007 k1v - k0v
1008 } else {
1009 (scales[prop].1 - scales[prop].0) * 0.5
1010 };
1011 let hx = ((t_at(pos.x, sx) - k0t) / seg_dt).clamp(0.0, 1.0) as f32;
1012 let hy = ((v_at(pos.y, scales[prop]) - k0v) / seg_dv) as f32;
1013 let (mut x1, mut y1, mut x2, mut y2) = a.keys[key].ease.handles();
1014 if is_out {
1015 (x1, y1) = (hx, hy);
1016 } else {
1017 (x2, y2) = (hx, hy);
1018 }
1019 a.keys[key].ease = Ease::Bezier { x1, y1, x2, y2 };
1020 out.edited = true;
1021 }
1022 }
1023 }
1024 }
1025 }
1026 }
1027 if resp.drag_stopped() {
1028 if let Some(o) = state.band.take() {
1030 let br = Rect::from_two_pos(o, pointer.unwrap_or(o));
1031 if !ui.input(|i| i.modifiers.ctrl) {
1032 state.selected.clear();
1033 }
1034 for p in (0..n_props).filter(|&p| visible(p)) {
1035 let Some(a) = snap.get(p) else { continue };
1036 for (k, key) in a.keys.iter().enumerate() {
1037 if br.contains(pos2(x_at(key.t, sx), y_at(key.v, scales[p]))) && !state.selected.contains(&(p, k)) {
1038 state.selected.push((p, k));
1039 }
1040 }
1041 }
1042 }
1043 state.drag = None;
1044 state.drag_y.clear();
1045 state.drag_keys.clear();
1046 state.drag_from = None;
1047 }
1048 if resp.double_clicked() {
1049 if let Some(pos) = pointer {
1050 if pos.y >= plot.top() && key_hit(pos).is_none() {
1051 let t = t_at(pos.x, sx).clamp(0.0, dur);
1052 let v = v_at(pos.y, scales[state.active]);
1053 add_key_at(project, target, state, &mut out, undo, t, v);
1054 }
1055 }
1056 }
1057 if !state.selected.is_empty()
1061 && ui.rect_contains_pointer(graph)
1062 && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete))
1063 {
1064 undo(project);
1065 let mut sel = state.selected.clone();
1066 sel.sort_by(|a, b| b.cmp(a)); for (p, k) in sel {
1068 if let Some(a) = t_mut(project, target, p) {
1069 remove_key(a, k);
1070 }
1071 }
1072 state.selected.clear();
1073 out.edited = true;
1074 }
1075
1076 if ui.rect_contains_pointer(graph) {
1078 if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::C)) && !state.selected.is_empty() {
1079 let key_of = |&(p, k): &(usize, usize)| snap.get(p).and_then(|a| a.keys.get(k)).map(|x| (p, *x));
1080 let picked: Vec<(usize, Keyframe)> = state.selected.iter().filter_map(key_of).collect();
1081 let t0 = picked.iter().map(|(_, k)| k.t).fold(f64::MAX, f64::min);
1082 state.clipboard = picked.into_iter().map(|(p, k)| (p, Keyframe { t: k.t - t0, ..k })).collect();
1083 }
1084 if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::V)) && !state.clipboard.is_empty() {
1085 undo(project);
1086 let t0 = (*playhead - span_start).clamp(0.0, dur);
1087 let mut at: Vec<(usize, f64)> = Vec::new();
1088 for (p, key) in state.clipboard.clone() {
1089 let t = (t0 + key.t).clamp(0.0, dur);
1090 if let Some(a) = t_mut(project, target, p) {
1091 if a.key_index_at(t).is_none() {
1092 a.toggle_key(t); }
1094 a.set_at(t, key.v);
1095 a.set_ease_at(t, key.ease);
1096 at.push((p, t));
1097 }
1098 }
1099 state.selected =
1101 at.iter().filter_map(|&(p, t)| t_ref(project, target, p)?.key_index_at(t).map(|k| (p, k))).collect();
1102 out.edited = true;
1103 }
1104 }
1105
1106 if resp.secondary_clicked() {
1108 state.menu_key = pointer.and_then(key_hit);
1109 if state.menu_key.is_none() {
1112 if let Some(pos) = pointer {
1113 if pos.y >= plot.top() {
1114 add_key_at(
1115 project,
1116 target,
1117 state,
1118 &mut out,
1119 undo,
1120 t_at(pos.x, sx).clamp(0.0, dur),
1121 v_at(pos.y, scales[state.active]),
1122 );
1123 }
1124 }
1125 }
1126 }
1127 let mut act: Option<MenuAct> = None;
1128 if let Some((_p, _k)) = state.menu_key {
1129 resp.context_menu(|ui| {
1130 ui.menu_button("Easing", |ui| {
1131 for e in Ease::ALL {
1132 if ui.button(e.name()).clicked() {
1133 act = Some(MenuAct::SetEase(e));
1134 }
1135 }
1136 ui.separator();
1137 for (name, e) in Ease::PRESETS {
1138 if ui.button(name).clicked() {
1139 act = Some(MenuAct::SetEase(e));
1140 }
1141 }
1142 });
1143 if ui.button("Delete").clicked() {
1144 act = Some(MenuAct::Delete);
1145 }
1146 });
1147 }
1148 if let (Some(act), Some((p, k))) = (act, state.menu_key) {
1149 undo(project);
1150 if let Some(a) = t_mut(project, target, p) {
1151 match act {
1152 MenuAct::SetEase(e) if k < a.keys.len() => {
1153 a.keys[k].ease = e;
1154 out.edited = true;
1155 }
1156 MenuAct::Delete => {
1157 remove_key(a, k);
1158 state.selected.retain(|&s| s != (p, k));
1159 out.edited = true;
1160 state.menu_key = None;
1161 }
1162 _ => {}
1163 }
1164 }
1165 }
1166
1167 let painter = ui.painter().with_clip_rect(graph);
1169 painter.rect_filled(graph, 0, pal.panel);
1170 painter.rect_filled(Rect::from_min_max(graph.min, pos2(graph.right(), plot.top())), 0, pal.header);
1171 let font = egui::TextStyle::Small.resolve(ui.style());
1173 let step = tick_step(pps);
1174 let mut t = (t_at(plot.left(), sx) / step).floor() * step;
1175 while t <= t_at(plot.right(), sx) {
1176 if t >= -1e-9 {
1177 let x = x_at(t, sx);
1178 painter.vline(x, egui::Rangef::new(graph.top(), plot.top()), Stroke::new(1.0, pal.border));
1179 painter.text(pos2(x + 2.0, graph.top()), Align2::LEFT_TOP, format!("{t:.2}"), font.clone(), pal.text_dim);
1180 }
1181 t += step;
1182 }
1183 let (lo, hi) = scales[state.active];
1185 painter.text(
1186 pos2(graph.right() - 3.0, graph.top()),
1187 Align2::RIGHT_TOP,
1188 format!("{} {lo:.2} … {hi:.2}", labels[state.active].clone()),
1189 font.clone(),
1190 pal.text_dim,
1191 );
1192 for i in 0..=4 {
1193 let v = lo + (hi - lo) * i as f64 / 4.0;
1194 let y = y_at(v, (lo, hi));
1195 painter.hline(
1196 egui::Rangef::new(plot.left(), plot.right()),
1197 y,
1198 Stroke::new(1.0, pal.border.linear_multiply(0.4)),
1199 );
1200 painter.text(
1201 pos2(plot.left() + 2.0, y - 1.0),
1202 Align2::LEFT_BOTTOM,
1203 format!("{v:.2}"),
1204 font.clone(),
1205 pal.text_dim,
1206 );
1207 }
1208 for tb in [0.0, dur] {
1210 painter.vline(x_at(tb, sx), egui::Rangef::new(plot.top(), plot.bottom()), Stroke::new(1.0, pal.border));
1211 }
1212 let plot_painter = ui.painter().with_clip_rect(plot);
1214 let mut pts: Vec<Pos2> = Vec::with_capacity((plot.width() / 2.0) as usize + 2);
1215 for p in 0..n_props {
1216 if !visible(p) {
1217 continue;
1218 }
1219 let Some(a) = snap.get(p) else { continue };
1220 let color = prop_color(&pal, p);
1221 let x0 = x_at(0.0, sx).max(plot.left());
1222 let x1 = x_at(dur, sx).min(plot.right());
1223 if x1 <= x0 {
1224 continue;
1225 }
1226 if !a.is_animated() {
1227 let y = y_at(a.value, scales[p]);
1228 plot_painter.extend(Shape::dashed_line(&[pos2(x0, y), pos2(x1, y)], Stroke::new(1.0, color), 4.0, 4.0));
1229 continue;
1230 }
1231 pts.clear();
1232 let mut x = x0;
1233 while x <= x1 {
1234 pts.push(pos2(x, y_at(a.at(t_at(x, sx)), scales[p])));
1235 x += 2.0;
1236 }
1237 plot_painter.add(Shape::line(pts.clone(), Stroke::new(1.5, color)));
1238 for k in 0..a.keys.len().saturating_sub(1) {
1240 if !shows_handles(p, k) {
1241 continue;
1242 }
1243 if let Some((p0, h1, h2, p3)) = handle_positions(p, k) {
1244 let hs = Stroke::new(1.0, pal.text_dim);
1245 plot_painter.line_segment([p0, h1], hs);
1246 plot_painter.line_segment([p3, h2], hs);
1247 plot_painter.circle_filled(h1, 3.0, pal.accent);
1248 plot_painter.circle_filled(h2, 3.0, pal.accent);
1249 }
1250 }
1251 for (k, key) in a.keys.iter().enumerate() {
1253 let c = pos2(x_at(key.t, sx), y_at(key.v, scales[p]));
1254 let fill = if state.selected.contains(&(p, k)) { pal.accent } else { color };
1255 plot_painter.add(Shape::convex_polygon(
1256 vec![pos2(c.x, c.y - 4.0), pos2(c.x + 4.0, c.y), pos2(c.x, c.y + 4.0), pos2(c.x - 4.0, c.y)],
1257 fill,
1258 Stroke::new(1.0, pal.text),
1259 ));
1260 }
1261 }
1262 if let Some(o) = state.band {
1264 let br = Rect::from_two_pos(o, pointer.unwrap_or(o)).intersect(plot);
1265 plot_painter.rect_filled(br, 0, pal.accent.gamma_multiply(0.15));
1266 plot_painter.rect_stroke(br, 0, Stroke::new(1.0, pal.accent), egui::StrokeKind::Inside);
1267 }
1268 let ph = *playhead - span_start;
1270 if ph >= 0.0 && ph <= dur {
1271 painter.vline(x_at(ph, sx), egui::Rangef::new(graph.top(), plot.bottom()), Stroke::new(1.0, pal.playhead));
1272 }
1273 out
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279 use crate::model::{Clip, ClipKind, Keyframe};
1280 use eframe::egui::{Event, Modifiers, PointerButton, RawInput};
1281
1282 #[test]
1285 fn a_bus_is_a_curve_target() {
1286 use crate::model::{AudioFilter, FilterKind};
1287 let mut h = Harness::new();
1288 let bus = h.project.main_bus();
1289 h.project.bus_mut(bus).unwrap().filters.push(AudioFilter::new(FilterKind::Gain));
1290 h.project.bus_mut(bus).unwrap().filters[0].fill_params();
1291 let nparams = h.project.bus(bus).unwrap().filters[0].params.len();
1292
1293 let t = Target::Bus(bus);
1294 assert_eq!(t_count(&h.project, t), BUS_BASE + nparams, "gain, pan, then the filter's parameters");
1295 assert_eq!(t_label(&h.project, t, 0), "Gain");
1296 assert_eq!(t_label(&h.project, t, 1), "Pan");
1297 assert!(t_label(&h.project, t, BUS_BASE).starts_with("Gain: "), "filter params name their filter");
1298 assert_eq!(t_span(&h.project, t).0, 0.0);
1300
1301 t_mut(&mut h.project, t, 0).unwrap().set_at(1.0, 0.25);
1303 assert_eq!(h.project.bus(bus).unwrap().gain.at(1.0), 0.25);
1304 t_mut(&mut h.project, t, BUS_BASE).unwrap().set_at(2.0, 6.0);
1305 assert_eq!(h.project.bus(bus).unwrap().filters[0].params[0].at(2.0), 6.0);
1306 assert_eq!(t_ref(&h.project, t, 0).unwrap().at(1.0), 0.25);
1308
1309 h.selection.clear();
1311 h.state.target_bus = Some(bus);
1312 h.frame(vec![]);
1313 h.frame(vec![]);
1314 assert!(h.state.graph.width() > 0.0, "the graph laid out for a bus");
1315 assert!(h.undos == 0, "drawing alone changes nothing");
1316 }
1317
1318 #[test]
1321 fn middle_drag_pans_without_starting_a_primary_gesture() {
1322 let mut h = Harness::new();
1323 let ph_before = h.playhead;
1324 let (sx0, y_pan0) = (h.state.scroll_x, h.state.y_pan);
1325 let from = pos2(300.0, 200.0);
1326 let to = from + vec2(60.0, -40.0);
1327 h.frame(vec![Event::PointerMoved(from)]);
1328 h.frame(vec![Event::PointerButton {
1329 pos: from,
1330 button: PointerButton::Middle,
1331 pressed: true,
1332 modifiers: Modifiers::NONE,
1333 }]);
1334 for i in 1..=4 {
1335 let p = from + (to - from) * (i as f32 / 4.0);
1336 h.frame(vec![Event::PointerMoved(p)]);
1337 }
1338 h.frame(vec![Event::PointerButton {
1339 pos: to,
1340 button: PointerButton::Middle,
1341 pressed: false,
1342 modifiers: Modifiers::NONE,
1343 }]);
1344 assert_ne!(h.state.scroll_x, sx0, "the time axis panned");
1345 assert_ne!(h.state.y_pan, y_pan0, "the value axis panned");
1346 assert_eq!(h.playhead, ph_before, "a middle-drag must not seek the playhead");
1347 assert!(h.state.selected.is_empty(), "a middle-drag must not select or rubber-band keys");
1348 assert_eq!(h.undos, 0, "panning is not a project edit");
1349 }
1350
1351 struct Harness {
1352 ctx: egui::Context,
1353 state: CurvesState,
1354 project: Project,
1355 selection: Vec<Id>,
1356 playhead: f64,
1357 undos: usize,
1358 time: f64,
1359 }
1360
1361 impl Harness {
1362 fn new() -> Self {
1363 let mut project = Project::new();
1364 let mut clip = Clip::new(7, ClipKind::Video, "v", 0.0, 4.0);
1365 clip.scale.keys =
1366 vec![Keyframe { t: 1.0, v: 1.0, ease: Ease::Linear }, Keyframe { t: 3.0, v: 3.0, ease: Ease::Linear }];
1367 project.tracks[0].clips.push(clip);
1368 let mut h = Self {
1369 ctx: egui::Context::default(),
1370 state: CurvesState::default(),
1371 project,
1372 selection: vec![7],
1373 playhead: 0.0,
1374 undos: 0,
1375 time: 0.0,
1376 };
1377 h.frame(vec![]); h
1379 }
1380 fn frame(&mut self, events: Vec<Event>) -> CurvesResponse {
1381 self.time += 0.05;
1382 let input = RawInput {
1383 screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 400.0))),
1384 time: Some(self.time),
1385 events,
1386 ..Default::default()
1387 };
1388 let pal = Palette::new(true, Color32::from_rgb(0, 120, 212));
1389 let Harness { ctx, state, project, selection, playhead, undos, .. } = self;
1390 let mut resp = CurvesResponse::default();
1391 let _ = ctx.run(input, |ctx| {
1392 egui::CentralPanel::default().show(ctx, |ui| {
1393 let mut undo = |_: &Project| *undos += 1;
1394 resp = show(ui, state, project, selection, None, playhead, &pal, &mut undo);
1395 });
1396 });
1397 resp
1398 }
1399 fn press(&mut self, pos: Pos2) {
1400 self.frame(vec![Event::PointerMoved(pos)]);
1401 self.frame(vec![Event::PointerButton {
1402 pos,
1403 button: PointerButton::Primary,
1404 pressed: true,
1405 modifiers: Modifiers::NONE,
1406 }]);
1407 }
1408 fn release(&mut self, pos: Pos2) -> CurvesResponse {
1409 let r = self.frame(vec![Event::PointerButton {
1410 pos,
1411 button: PointerButton::Primary,
1412 pressed: false,
1413 modifiers: Modifiers::NONE,
1414 }]);
1415 self.frame(vec![]);
1416 r
1417 }
1418 fn clip(&self) -> &Clip {
1419 &self.project.tracks[0].clips[0]
1420 }
1421 fn scale_key_pos(&self, k: usize) -> Pos2 {
1423 let plot = Rect::from_min_max(
1424 pos2(self.state.graph.left(), self.state.graph.top() + RULER_H),
1425 self.state.graph.max,
1426 );
1427 let key = &self.clip().scale.keys[k];
1428 let (lo, hi) = y_range(&self.clip().scale);
1429 let x = plot.left() + ((key.t - self.state.scroll_x) * self.state.pps as f64) as f32;
1430 let y = plot.bottom() - (((key.v - lo) / (hi - lo)) * plot.height() as f64) as f32;
1431 pos2(x, y)
1432 }
1433 }
1434
1435 #[test]
1436 fn splitter_drag_resizes_the_property_list() {
1437 let mut h = Harness::new();
1438 let w0 = h.state.list_w;
1439 assert_eq!(w0, LIST_W);
1440 let y = h.state.graph.center().y;
1442 let x = h.state.graph.left() - SPLIT_W / 2.0;
1443 h.press(pos2(x, y));
1444 for i in 1..=4 {
1445 h.frame(vec![Event::PointerMoved(pos2(x + 15.0 * i as f32, y))]);
1446 }
1447 h.release(pos2(x + 60.0, y));
1448 assert!(h.state.list_w > w0 + 40.0, "list grew: {} -> {}", w0, h.state.list_w);
1449 assert!(h.state.graph.left() > w0, "the graph moved right with it");
1450 h.state.list_w = 1000.0;
1452 h.frame(vec![]);
1453 assert!(h.state.list_w <= LIST_MAX);
1454 h.state.list_w = 1.0;
1455 h.frame(vec![]);
1456 assert!(h.state.list_w >= LIST_MIN);
1457 }
1458
1459 #[test]
1460 fn right_click_on_empty_graph_adds_a_key() {
1461 let mut h = Harness::new();
1462 h.state.active = 0; h.frame(vec![]);
1464 assert!(!h.clip().x.is_animated());
1465 let plot = Rect::from_min_max(pos2(h.state.graph.left(), h.state.graph.top() + RULER_H), h.state.graph.max);
1466 let pos = pos2(plot.left() + plot.width() * 0.5, plot.center().y);
1467 h.frame(vec![Event::PointerMoved(pos)]);
1468 h.frame(vec![Event::PointerButton {
1469 pos,
1470 button: PointerButton::Secondary,
1471 pressed: true,
1472 modifiers: Modifiers::NONE,
1473 }]);
1474 let r = h.frame(vec![Event::PointerButton {
1475 pos,
1476 button: PointerButton::Secondary,
1477 pressed: false,
1478 modifiers: Modifiers::NONE,
1479 }]);
1480 assert!(r.edited, "right-click on empty graph edits");
1481 let keys = &h.clip().x.keys;
1482 assert_eq!(keys.len(), 1, "one key added");
1483 assert!(keys[0].t > 1.5 && keys[0].t < 2.5, "at the clicked time: {}", keys[0].t);
1484 assert_eq!(h.undos, 1, "one undo for the gesture");
1485 }
1486
1487 #[test]
1488 fn prop_plumbing_matches_labels() {
1489 let mut c = Clip::new(1, ClipKind::Video, "v", 0.0, 2.0);
1490 c.effects.push(crate::model::Effect::new(crate::model::EffectKind::Blur));
1491 assert_eq!(prop_count(&c), 7);
1492 assert_eq!(prop_label(&c, 0), "Position X");
1493 assert_eq!(prop_label(&c, 2), "Scale");
1494 assert_eq!(prop_label(&c, 5), "Speed");
1495 assert_eq!(prop_label(&c, 6), "Blur: Radius");
1496 c.scale.value = 2.5;
1497 assert_eq!(prop_ref(&c, 2).map(|a| a.value), Some(2.5));
1498 prop_mut(&mut c, 5).map(|a| a.value = 0.5);
1499 assert_eq!(c.speed_curve.value, 0.5);
1500 prop_mut(&mut c, 6).map(|a| a.value = 9.0);
1501 assert_eq!(c.effects[0].params[0].value, 9.0);
1502 let a = Clip::new(2, ClipKind::Audio, "a", 0.0, 2.0);
1503 assert_eq!(prop_count(&a), 3);
1504 assert_eq!(prop_label(&a, 0), "Volume");
1505 assert_eq!(prop_label(&a, 1), "Pan");
1506 assert_eq!(prop_label(&a, 2), "Speed");
1507 }
1508
1509 #[test]
1510 fn drag_key_moves_time_and_value_with_one_undo() {
1511 let mut h = Harness::new();
1512 assert!(h.state.graph.width() > 100.0, "graph not laid out: {:?}", h.state.graph);
1513 let from = h.scale_key_pos(0); let to = from + vec2(60.0, -20.0);
1515 h.press(from);
1516 let mut edited = false;
1517 for i in 1..=4 {
1518 let p = from + (to - from) * (i as f32 / 4.0);
1519 edited |= h.frame(vec![Event::PointerMoved(p)]).edited;
1520 }
1521 edited |= h.release(to).edited;
1522 assert!(edited, "dragging a key should edit");
1523 assert_eq!(h.undos, 1, "one drag = one undo");
1524 let k = &h.clip().scale.keys[0];
1525 let dt = 60.0 / h.state.pps as f64;
1526 assert!((k.t - (1.0 + dt)).abs() < 0.05, "time should follow the drag: {} vs {}", k.t, 1.0 + dt);
1527 assert!(k.v > 1.0, "value should rise when dragging up: {}", k.v);
1528 }
1529
1530 #[test]
1531 fn held_key_drag_does_not_run_away() {
1532 let mut h = Harness::new();
1533 let from = h.scale_key_pos(1); h.press(from);
1535 h.frame(vec![Event::PointerMoved(from + vec2(0.0, -12.0))]);
1536 h.frame(vec![]);
1537 let v = h.clip().scale.keys[1].v;
1538 for _ in 0..30 {
1539 h.frame(vec![]); }
1541 let after = h.clip().scale.keys[1].v;
1542 assert!((after - v).abs() < 1e-6, "value drifted while the pointer was still: {v} -> {after}");
1543 }
1544
1545 #[test]
1546 fn click_selects_and_delete_removes() {
1547 let mut h = Harness::new();
1548 let p = h.scale_key_pos(1);
1549 h.press(p);
1550 h.release(p);
1551 assert_eq!(h.state.selected, vec![(2, 1)]);
1552 let r = h.frame(vec![Event::Key {
1553 key: egui::Key::Delete,
1554 physical_key: None,
1555 pressed: true,
1556 repeat: false,
1557 modifiers: Modifiers::NONE,
1558 }]);
1559 assert!(r.edited);
1560 assert_eq!(h.clip().scale.keys.len(), 1);
1561 assert_eq!(h.undos, 1);
1562 assert!(h.state.selected.is_empty());
1563 }
1564
1565 #[test]
1566 fn ruler_click_seeks() {
1567 let mut h = Harness::new();
1568 let x = h.state.graph.left() + (2.0 * h.state.pps as f64) as f32; let pos = pos2(x, h.state.graph.top() + RULER_H * 0.5);
1570 h.press(pos);
1571 let r = h.release(pos);
1572 assert!(r.seeked, "clicking the ruler should seek");
1573 assert!((h.playhead - 2.0).abs() < 0.05, "playhead {}", h.playhead);
1574 assert_eq!(h.undos, 0, "seeking is not an edit");
1575 }
1576
1577 #[test]
1578 fn double_click_adds_key_for_active_property() {
1579 let mut h = Harness::new();
1580 h.state.active = 2; h.frame(vec![]);
1582 let plot_top = h.state.graph.top() + RULER_H;
1584 let x = h.state.graph.left() + (2.0 * h.state.pps as f64) as f32;
1585 let pos = pos2(x, (plot_top + h.state.graph.bottom()) * 0.5 + 30.0);
1586 h.press(pos);
1587 h.release(pos);
1588 h.press(pos);
1589 let r = h.release(pos);
1590 let _ = r;
1591 assert_eq!(h.clip().scale.keys.len(), 3, "double-click should add a key");
1592 assert!(h.clip().scale.keys.iter().any(|k| (k.t - 2.0).abs() < 0.05));
1593 assert_eq!(h.undos, 1);
1594 }
1595
1596 #[test]
1597 fn secondary_click_targets_key_and_ease_applies_bezier() {
1598 let mut h = Harness::new();
1599 let p = h.scale_key_pos(0);
1600 h.frame(vec![Event::PointerMoved(p)]);
1601 h.frame(vec![Event::PointerButton {
1602 pos: p,
1603 button: PointerButton::Secondary,
1604 pressed: true,
1605 modifiers: Modifiers::NONE,
1606 }]);
1607 h.frame(vec![Event::PointerButton {
1608 pos: p,
1609 button: PointerButton::Secondary,
1610 pressed: false,
1611 modifiers: Modifiers::NONE,
1612 }]);
1613 h.frame(vec![]);
1614 assert_eq!(h.state.menu_key, Some((2, 0)), "right-click should target the key under the pointer");
1615 let mut undos = 0;
1617 let mut undo = |_: &Project| undos += 1;
1618 undo(&h.project);
1619 if let Some(a) = h.project.clip_mut(7).and_then(|c| prop_mut(c, 2)) {
1620 a.keys[0].ease = Ease::PRESETS[0].1;
1621 }
1622 assert!(matches!(h.clip().scale.keys[0].ease, Ease::Bezier { .. }));
1623 }
1624
1625 #[test]
1626 fn handle_drag_converts_to_bezier() {
1627 let mut h = Harness::new();
1628 let kp = h.scale_key_pos(0);
1630 h.press(kp);
1631 h.release(kp);
1632 assert_eq!(h.state.selected, vec![(2, 0)]);
1633 let k1 = h.scale_key_pos(1);
1635 let h1 = kp + (k1 - kp) * 0.33;
1636 let to = h1 + vec2(20.0, 25.0);
1637 h.press(h1);
1638 let mut edited = false;
1639 for i in 1..=3 {
1640 edited |= h.frame(vec![Event::PointerMoved(h1 + (to - h1) * (i as f32 / 3.0))]).edited;
1641 }
1642 h.release(to);
1643 assert!(edited, "dragging a handle should edit");
1644 assert!(
1645 matches!(h.clip().scale.keys[0].ease, Ease::Bezier { .. }),
1646 "handle drag must convert the segment to Bezier: {:?}",
1647 h.clip().scale.keys[0].ease
1648 );
1649 }
1650
1651 #[test]
1652 fn rubber_band_selects_a_group_that_then_drags_together() {
1653 let mut h = Harness::new();
1654 let plot = Rect::from_min_max(pos2(h.state.graph.left(), h.state.graph.top() + RULER_H), h.state.graph.max);
1655 let from = pos2(plot.left() + 4.0, plot.bottom() - 4.0);
1657 let to = pos2(plot.right() - 4.0, plot.top() + 4.0);
1658 h.press(from);
1659 for i in 1..=4 {
1660 h.frame(vec![Event::PointerMoved(from + (to - from) * (i as f32 / 4.0))]);
1661 }
1662 h.release(to);
1663 assert_eq!(h.state.selected, vec![(2, 0), (2, 1)], "the band caught both keys");
1664 assert_eq!(h.undos, 0, "selecting is not an edit");
1665 let k0 = h.scale_key_pos(0);
1667 h.press(k0);
1668 for i in 1..=4 {
1669 h.frame(vec![Event::PointerMoved(k0 + vec2(10.0 * i as f32, 0.0))]);
1670 }
1671 h.release(k0 + vec2(40.0, 0.0));
1672 let dt = 40.0 / h.state.pps as f64;
1673 let keys = &h.clip().scale.keys;
1674 assert_eq!(keys.len(), 2);
1675 assert!((keys[0].t - (1.0 + dt)).abs() < 0.05, "first key followed the drag: {}", keys[0].t);
1676 assert!((keys[1].t - (3.0 + dt)).abs() < 0.05, "second key came with it: {}", keys[1].t);
1677 assert!((keys[0].v - 1.0).abs() < 1e-6 && (keys[1].v - 3.0).abs() < 1e-6, "a flat drag keeps the values");
1678 assert_eq!(h.undos, 1, "one gesture = one undo");
1679 }
1680
1681 #[test]
1682 fn copy_paste_lands_at_the_playhead_keeping_relative_times() {
1683 let mut h = Harness::new();
1684 let ctrl =
1685 |key| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::CTRL };
1686 h.frame(vec![Event::PointerMoved(h.scale_key_pos(0))]); h.state.selected = vec![(2, 0), (2, 1)];
1688 h.frame(vec![ctrl(egui::Key::C)]);
1689 assert_eq!(h.state.clipboard.len(), 2);
1690 assert!((h.state.clipboard[0].1.t).abs() < 1e-9, "times are relative to the earliest key");
1691 h.playhead = 0.5;
1692 let r = h.frame(vec![ctrl(egui::Key::V)]);
1693 assert!(r.edited);
1694 let ts: Vec<f64> = h.clip().scale.keys.iter().map(|k| k.t).collect();
1695 assert_eq!(ts.len(), 4, "pasted alongside the originals: {ts:?}");
1696 assert!((ts[0] - 0.5).abs() < 1e-6 && (ts[2] - 2.5).abs() < 1e-6, "{ts:?}");
1697 assert_eq!(h.state.selected, vec![(2, 0), (2, 2)], "the pasted keys are the selection");
1698 assert_eq!(h.undos, 1);
1699 }
1700
1701 #[test]
1702 fn scroll_zooms_the_value_axis_and_fit_frames_the_keys() {
1703 let mut h = Harness::new();
1704 let span0 = h.state.y_hi - h.state.y_lo;
1705 let mid = pos2(h.state.graph.center().x, h.state.graph.center().y);
1706 h.frame(vec![Event::PointerMoved(mid)]);
1707 h.frame(vec![Event::MouseWheel {
1708 unit: egui::MouseWheelUnit::Point,
1709 delta: vec2(0.0, 4.0),
1710 modifiers: Modifiers::NONE,
1711 }]);
1712 h.frame(vec![]); assert!(h.state.y_zoom > 1.0, "a plain wheel zooms the value axis: {}", h.state.y_zoom);
1714 assert!(h.state.y_hi - h.state.y_lo < span0, "the visible value range shrank");
1715 assert!(h.state.zoom == 0.0, "the time axis is untouched");
1716 let c = h.project.clip(7).unwrap().clone();
1718 let n = prop_count(&c);
1719 let snap: Vec<Animated> = (0..n).filter_map(|i| prop_ref(&c, i).cloned()).collect();
1720 fit_view(&mut h.state, &snap, n);
1721 assert_eq!((h.state.y_zoom, h.state.y_pan), (1.0, 0.0));
1722 assert!((h.state.scroll_x - 0.9).abs() < 1e-6, "framed just before the first key: {}", h.state.scroll_x);
1723 h.frame(vec![]);
1724 let w = h.state.graph.width() as f64;
1725 assert!((h.state.pps as f64 * 2.2 - w).abs() < 1.0, "2.2 s fill the graph: {} px/s of {w}", h.state.pps);
1726 }
1727
1728 #[test]
1729 fn flow_pair_and_button_path() {
1730 let mut p = Project::new();
1731 let mut a = Clip::new(1, ClipKind::Video, "a", 0.0, 2.0);
1732 a.scale.keys =
1733 vec![Keyframe { t: 0.0, v: 1.0, ease: Ease::Linear }, Keyframe { t: 2.0, v: 2.0, ease: Ease::Linear }];
1734 let b = Clip::new(2, ClipKind::Video, "b", 2.0, 2.0);
1735 p.tracks[0].clips.push(a);
1736 p.tracks[0].clips.push(b);
1737 assert_eq!(flow_pair(&p, &[2, 1]), Some((1, 2)), "order independent");
1738 assert_eq!(flow_pair(&p, &[1]), None);
1739 assert!(p.flow_clips(1, 2));
1740 let b = p.clip(2).unwrap();
1741 assert!(b.scale.is_animated(), "flow should animate the second clip");
1742 }
1743
1744 #[test]
1745 fn preset_handoff() {
1746 assert!(take_pending_curve_preset().is_none());
1747 set_available_presets(vec![CurvePreset { name: "p".into(), keys: Vec::new(), absolute: false }]);
1748 AVAILABLE.with(|a| assert_eq!(a.borrow().len(), 1));
1749 PENDING.with(|p| *p.borrow_mut() = Some(CurvePreset { name: "q".into(), keys: Vec::new(), absolute: false }));
1750 assert_eq!(take_pending_curve_preset().map(|p| p.name), Some("q".into()));
1751 set_available_presets(Vec::new());
1752 }
1753
1754 #[test]
1755 fn no_selection_is_harmless() {
1756 let mut h = Harness::new();
1757 h.selection.clear();
1758 let r = h.frame(vec![]);
1759 assert!(!r.edited && !r.seeked);
1760 assert_eq!(h.undos, 0);
1761 }
1762}