simple_editor\ui/
curves.rs

1//! Curve (graph) editor for the FIRST selected clip: x = clip-local time over [0, duration], y = value.
2//! Left: a list of the clip's keyframeable properties (`Clip::props_mut` names + "Volume"/"Pan"/"Speed" +
3//! "<effect>: <param>" for effect params) with a colour swatch and a checkbox to show/hide each curve
4//! (auto-scaled per property; the selected property's scale drives the y axis labels). Right: the graph —
5//! curves drawn with the eased segments (sample `Animated::at` every ~2 px), keys as diamonds (accent when
6//! selected), playhead line (click on the ruler strip seeks → `seeked`), constant (unkeyed) properties
7//! shown as a flat dashed line. Interaction: drag a key horizontally/vertically (time clamped to the clip,
8//! vertical changes the value; undo once at drag start) — the whole selection moves with it, click a key
9//! to select it, Ctrl+click adds/removes one, dragging empty graph space rubber-bands a group (like the
10//! timeline's clip band), Delete removes the selection and Ctrl+C / Ctrl+V copy it and paste it at the
11//! playhead keeping the relative times, double-click on empty graph area adds a key for the active
12//! property at that (t, v), right-click a key → easing menu (Ease::ALL) + Delete; Ctrl+Scroll zooms the
13//! time axis, Shift+Scroll pans, plain Scroll zooms the value axis around the pointer and "Fit" frames
14//! every key. Bezier velocity handles: a Bezier segment (or any segment whose left key is
15//! selected) shows its two handles; dragging one converts the segment to `Ease::Bezier`.
16
17use 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;
27/// Grab width of the splitter between the property list and the graph.
28const SPLIT_W: f32 = 5.0;
29const HIT_PX: f32 = 6.0;
30/// Two keys closer than this in time are the same key (model::KEY_EPS, which is private).
31const T_EPS: f64 = 1e-4;
32
33thread_local! {
34    // ponytail: thread_local hand-off because show() can't reach Settings. The app calls
35    // set_available_presets() each frame (or when they change) and polls take_pending_curve_preset().
36    static AVAILABLE: RefCell<Vec<CurvePreset>> = const { RefCell::new(Vec::new()) };
37    static PENDING: RefCell<Option<CurvePreset>> = const { RefCell::new(None) };
38    // motion presets (all properties of a clip at once): built-ins + Settings.motion_presets
39    static MOTIONS: RefCell<Vec<MotionPreset>> = const { RefCell::new(Vec::new()) };
40    static PENDING_MOTION: RefCell<Option<MotionPreset>> = const { RefCell::new(None) };
41}
42
43/// The app hands the applicable motion presets (built-ins + saved) to the panel.
44pub fn set_available_motions(v: Vec<MotionPreset>) {
45    MOTIONS.with(|m| *m.borrow_mut() = v);
46}
47
48/// A motion preset captured via "Save motion" waiting for the app to store it in Settings.
49pub fn take_pending_motion_preset() -> Option<MotionPreset> {
50    PENDING_MOTION.with(|p| p.borrow_mut().take())
51}
52
53/// The app hands the current Settings.curve_presets to the panel with this (cheap: only on change or
54/// every frame — the panel just reads names + the selected preset).
55pub fn set_available_presets(v: Vec<CurvePreset>) {
56    AVAILABLE.with(|a| *a.borrow_mut() = v);
57}
58
59/// A curve preset captured via "Save curve preset…" waiting for the app to store it in Settings.
60pub 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    /// Out-handle of the segment starting at `key` of `prop`.
71    HandleOut {
72        prop: usize,
73        key: usize,
74    },
75    /// In-handle of that segment (drawn near key + 1).
76    HandleIn {
77        prop: usize,
78        key: usize,
79    },
80    Seek,
81}
82
83pub struct CurvesState {
84    /// Index of the active property in the panel's property list.
85    pub active: usize,
86    /// The bus whose automation the pane is editing; None = follow the clip selection.
87    pub target_bus: Option<Id>,
88    /// Last mixer selection this pane reacted to, so a bus picked there switches the target once and the
89    /// user can still choose something else here afterwards.
90    pub seen_mixer_bus: Option<Id>,
91    /// Width of the property list in points (drag the splitter; remembered across frames).
92    pub list_w: f32,
93    /// Hidden curves (property indices).
94    pub hidden: Vec<usize>,
95    /// Time zoom/pan of the graph (seconds at the left edge, px per second; 0 = fit the clip).
96    pub scroll_x: f64,
97    pub zoom: f32,
98    /// Value-axis view on top of each property's auto range: 1 = the whole range, pan in range units.
99    pub y_zoom: f64,
100    pub y_pan: f64,
101    /// Selected keys as (property index, key index).
102    pub selected: Vec<(usize, usize)>,
103    /// Last frame's graph rect / effective px-per-second / active y scale (hit-testing + tests).
104    pub graph: Rect,
105    pub pps: f32,
106    pub y_lo: f64,
107    pub y_hi: f64,
108    drag: Option<Drag>,
109    /// y scales latched per property for the running key/handle drag: the per-frame auto scale is derived
110    /// from the very values the drag writes, so a live scale would feed the dragged value back into itself.
111    drag_y: Vec<(usize, (f64, f64))>,
112    /// The selected keys as they were when the drag started, per property: (prop, keys, selected indices).
113    /// The whole gesture is re-derived from this snapshot, so nothing accumulates rounding.
114    drag_keys: Vec<(usize, Vec<Keyframe>, Vec<usize>)>,
115    /// Pointer position at drag start — the group moves by the delta from it.
116    drag_from: Option<Pos2>,
117    /// Rubber-band press origin while a band select is in progress.
118    band: Option<Pos2>,
119    /// Copied keys as (property, key) with times relative to the earliest of them.
120    clipboard: Vec<(usize, Keyframe)>,
121    preset_name: String,
122    preset_sel: usize,
123    motion_sel: usize,
124    /// Key under the open context menu.
125    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
165// ---------- property plumbing (same order as the panel list) ----------
166
167fn 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
259/// Auto y scale for one property: key values (or the constant) padded by 10 %.
260pub(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
274/// The value-axis view applied to a property's auto range (`pan` in units of that range, so one pair of
275/// numbers drives every property's lane whatever its units are).
276fn 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
283/// Re-lay `orig` with the keys at `sel` shifted by (dt, dv) and write it back; returns their new indices.
284/// A moved key landing on an unmoved one replaces it, exactly like `Animated::move_key`.
285fn 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    // ponytail: O(n²) collision scan — a property with thousands of keys would notice, nothing else will
296    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
302/// Frame every key of the visible properties on the time axis and reset the value view.
303fn 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; // one key or none to frame: back to fitting the whole clip
323        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
333/// Remove key `i` keeping `Animated::toggle_key`'s "last key becomes the constant" behaviour.
334fn 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
343/// Two selected clips that abut on the timeline, as (left, right).
344fn 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
359/// "Nice" tick step so labels sit >= 70 px apart.
360fn 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/// What the curve editor is pointed at. A bus is just another list of `Animated` properties over a time
370/// span, which is all this editor ever needed from a clip — so buses edit exactly like clips here.
371#[derive(Clone, Copy, PartialEq, Debug)]
372pub(crate) enum Target {
373    Clip(Id),
374    Bus(Id),
375}
376
377/// A bus's properties: its own gain and pan, then every filter parameter in chain order.
378const 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
459/// (start, duration) of the target on the timeline. A bus spans the whole project: its automation is
460/// absolute time, not clip-local.
461fn 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
473/// Add (or move) a key of the active property at clip-local time `t` and value `v`, selecting it.
474/// Used by both the double-click and the right-click-on-empty-graph gestures.
475fn 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    // a bus the user picked wins over the clip selection; a bus that has since gone falls back
513    state.target_bus = state.target_bus.filter(|id| project.bus(*id).is_some());
514    // the mixer's selection drives this pane: picking a bus over there points the curves at it, which is
515    // what "buses and their filters should be selectable" means from the user's side
516    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        // clip-only controls are disabled for a bus; this id is never dereferenced in that case
551        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    // drop stale key selections (undo/effect removal may have invalidated them)
560    {
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    // ---- motion presets (every animated property of the clip at once) ----
577    // motion presets and Flow act on a CLIP's own transform, so a bus target has nothing for them to do
578    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; // Some(scaled)
596                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    // ---- presets + flow row ----
641    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; // Some(scaled)
666            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    // ---- layout: property list | graph ----
708    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    // splitter: drag to resize the property list so long "<Effect>: <Param>" names stay readable
717    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    // one read-only snapshot of every property, so the draw pass never re-borrows the project — and so a
730    // bus and a clip look identical from here down
731    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    // property list (child ui so labels wrap/scroll naturally)
744    let mut list_ui = ui.new_child(egui::UiBuilder::new().max_rect(list_rect));
745    // scrolled: a clip with a few multi-param effects overflows the short bottom-tile pane
746    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                // a keyframe diamond marks the properties that actually carry keys
764                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    // ---- graph mapping ----
772    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    // per-property y scales
784    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    // freeze the dragged properties' scales for the whole gesture (see CurvesState::drag_y)
790    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    // ---- scroll / zoom ----
808    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; // panning fixes the zoom so the fit doesn't snap back
818            state.scroll_x = (state.scroll_x - (delta.x / pps) as f64).clamp(-dur, dur);
819        } else if delta.y != 0.0 {
820            // plain wheel zooms the value axis, keeping the value under the pointer put:
821            // u = centre + half * (2f - 1) must not move, so the centre takes up the slack
822            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    // middle-drag pans both axes, like the node editor's canvas; drag_delta() is per-frame, so this is
830    // additive across the gesture the same way the wheel-pan branch above is
831    if resp.dragged_by(egui::PointerButton::Middle) {
832        let d = resp.drag_delta();
833        state.zoom = pps; // panning fixes the zoom so a still-fitting view doesn't snap back
834        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    // hit-test helpers over visible props (snapshots keep the closures borrow-free)
841    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    // handles are shown for bezier segments and segments whose left key is selected
860    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    // ---- interaction ----
900    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                // snapshot every property holding a selected key: the whole selection moves as one
922                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); // empty graph space: rubber-band a group, like the timeline's clips
936            }
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                        // clamp the shift so the group keeps its shape when it hits the clip's edges
978                        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                            // ponytail: flat segments use half the visible scale as the value span so the
1005                            // handle still drags — exact bezier y is meaningless when v0 == v1.
1006                            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        // rubber band: everything inside it joins the selection (Ctrl keeps what was already selected)
1029        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    // Delete removes the selected keys (only while the pointer is over the graph, so the timeline's
1058    // Delete keeps working elsewhere)
1059    // consuming (not just peeking) so the app's global Delete cannot also fire and remove the clip
1060    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)); // per-prop descending key index → removals don't shift later ones
1067        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    // ---- copy / paste the selected keys (consumed, and only over the graph: Ctrl+V elsewhere is not ours) ----
1077    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); // makes an unkeyed property animated too
1093                    }
1094                    a.set_at(t, key.v);
1095                    a.set_ease_at(t, key.ease);
1096                    at.push((p, t));
1097                }
1098            }
1099            // indices only settle once every paste is in
1100            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    // ---- context menu on a key / right-click adds a key ----
1107    if resp.secondary_clicked() {
1108        state.menu_key = pointer.and_then(key_hit);
1109        // empty graph area: right-click adds a key for the active property right there (same as
1110        // double-click, which stays as the second way in)
1111        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    // ---- painting ----
1168    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    // time ticks in the ruler
1172    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    // y grid for the active scale, with the lane's range spelled out in the ruler
1184    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    // clip bounds
1209    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    // curves
1213    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        // bezier handles
1239        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        // keys
1252        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    // rubber band in progress
1263    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    // playhead
1269    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    /// A bus is a curve target like any clip: its gain, its pan and every filter parameter show up as
1283    /// properties, and editing one writes straight back onto the bus.
1284    #[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        // a bus spans the whole project, not a clip
1299        assert_eq!(t_span(&h.project, t).0, 0.0);
1300
1301        // writing through the target lands on the bus itself
1302        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        // and reading sees the same values
1307        assert_eq!(t_ref(&h.project, t, 0).unwrap().at(1.0), 0.25);
1308
1309        // the pane draws the bus with no clip selected at all
1310        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    /// Middle-drag pans the graph on both axes, the same gesture the node editor's canvas already has.
1319    /// It must not also trigger a seek, a key drag or a rubber band — those are primary-button gestures.
1320    #[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![]); // layout pass: fills state.graph / pps
1378            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        /// Screen position of scale key `k` (prop index 2) using the state's stored mapping.
1422        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        // grab the splitter strip just right of the list and drag it right
1441        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        // and it clamps
1451        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; // Position X — not animated yet
1463        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); // key at t = 1, v = 1
1514        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); // key at t = 3, v = 3
1534        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![]); // no pointer events at all — the drag is still held
1540        }
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; // t = 2 (fit → scroll 0)
1569        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; // Scale
1581        h.frame(vec![]);
1582        // empty spot at t = 2 near the middle of the plot
1583        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        // the menu's easing entries route through MenuAct::SetEase — apply one the same way
1616        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        // select key 0 so its segment shows handles
1629        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        // linear ease → out handle sits at 1/3 of the segment
1634        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        // band across the whole plot from an empty bottom-left corner
1656        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        // now drag one of them: the other comes along, keeping the gap between them
1666        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))]); // the shortcuts only fire over the graph
1687        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![]); // y_lo/y_hi are published before the scroll is read, so look one frame later
1713        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        // Fit: the keys span 1..3 s of a 4 s clip, so time zooms in and the value view resets
1717        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}