simple_editor\ui/
mixer_ui.rs

1//! Mixer pane: the routing list (which track feeds which bus) on top, one channel strip per bus below
2//! (Main last). Everything in a strip scrolls vertically, so a long filter chain stays reachable.
3//!
4//! Strip: name (editable), what feeds it, a stereo peak meter fed by `BusGraph::meter`, a gain fader
5//! (dB), pan knob, Mute (speaker glyph) / Solo / Mono, the output-bus combo, and the filter chain —
6//! each filter as a header row (enable, name, up/down, X) with its parameters below (DragValue per
7//! `FilterKind::params()` + a diamond keyframe toggle); EQ / pass filters also draw a response plot over a
8//! labelled log-frequency grid with one draggable handle per band. "+ Filter" adds from
9//! `FilterKind::ALL`, "+ Bus" creates a bus, X deletes one (never Main; its users fall back to Main).
10//! Routing: every audio track is a row — click it to send it to the selected bus, or pick from its own
11//! combo (`Track.bus`); the selected clip gets an override combo (`Clip.bus`, "(track)" = inherit).
12//! Undo once per gesture; returns true when the project changed.
13//!
14//! Every parameter (bus gain/pan and each filter param) is an `Animated` read and written at the
15//! playhead: `set_at` moves the constant while the parameter is unkeyed and upserts a keyframe once it
16//! is animated, exactly like a clip property.
17//!
18//! Edits go into a per-frame clone of `Project.buses` and are written back at the end of the frame, so
19//! `undo` can snapshot the project before the first change of a gesture (the effects/inspector pattern).
20
21use crate::engine::mixer_fx::{db_to_lin, filter_bands, filter_response_db, lin_to_db, BusGraph, EQ_BANDS};
22use crate::model::{Animated, AudioFilter, Bus, FilterKind, Id, Project, TrackKind};
23use crate::theme::Palette;
24use crate::ui::tools::{glyph_text_button, icon_button, Dir, Glyph};
25use crate::ui::Gesture;
26use eframe::egui::{
27    self, pos2, vec2, Align2, Button, ComboBox, DragValue, FontId, Grid, Rect, Sense, Stroke, TextEdit,
28};
29
30/// Channel-strip width in points.
31const STRIP_W: f32 = 210.0;
32/// Response-curve plot height.
33const CURVE_H: f32 = 96.0;
34/// Full-scale range of the EQ plot, in dB.
35const CURVE_DB: f32 = 24.0;
36/// Grid lines of the EQ plot: a third of a decade apart, labelled.
37const GRID_HZ: [(f32, &str); 10] = [
38    (31.0, "31"),
39    (62.0, "62"),
40    (125.0, "125"),
41    (250.0, "250"),
42    (500.0, "500"),
43    (1000.0, "1k"),
44    (2000.0, "2k"),
45    (4000.0, "4k"),
46    (8000.0, "8k"),
47    (16000.0, "16k"),
48];
49
50#[derive(Default)]
51pub struct MixerState {
52    pub selected_bus: Option<Id>,
53    /// Filters the user pulled out into their own window, as (bus, filter index). Kept in the pane's
54    /// state so the windows survive a repaint, and dropped when the filter goes.
55    pub popped: Vec<(Id, usize)>,
56    /// Show the clip/track routing section.
57    pub show_routing: bool,
58    /// Band being dragged on a response plot, as (bus, filter index, band index). Latched for the whole
59    /// drag: with five bands the nearest one changes under the pointer and the grab would hop.
60    drag: Option<(Id, usize, usize)>,
61}
62
63/// Test-only registry of widget rects so headless tests can click real widgets without pixel-guessing.
64#[cfg(test)]
65pub(crate) mod test_rects {
66    use eframe::egui::Rect;
67    use std::cell::RefCell;
68    thread_local! {
69        static RECTS: RefCell<Vec<(String, Rect)>> = const { RefCell::new(Vec::new()) };
70    }
71    pub fn clear() {
72        RECTS.with(|r| r.borrow_mut().clear());
73    }
74    pub fn push(name: String, rect: Rect) {
75        RECTS.with(|r| r.borrow_mut().push((name, rect)));
76    }
77    pub fn get(name: &str) -> Option<Rect> {
78        RECTS.with(|r| r.borrow().iter().rev().find(|(n, _)| n == name).map(|(_, rect)| *rect))
79    }
80}
81
82/// Structural edits that need `Project`'s own helpers, applied after `undo` has snapshotted.
83#[derive(Default)]
84struct Edits {
85    add_bus: bool,
86    remove: Option<Id>,
87    track_bus: Option<(usize, Id)>,
88    clip_bus: Option<(Id, Id)>,
89}
90
91#[allow(clippy::too_many_arguments)]
92pub fn show(
93    ui: &mut egui::Ui,
94    state: &mut MixerState,
95    project: &mut Project,
96    selection: &[Id],
97    buses: &BusGraph,
98    time: f64,
99    palette: &Palette,
100    undo: &mut dyn FnMut(&Project),
101) -> bool {
102    #[cfg(test)]
103    test_rects::clear();
104    let mut g = Gesture::default();
105    let mut ed = Edits::default();
106    // ponytail: clone the whole bus list every frame — a handful of buses with a few filters each is
107    // nothing next to a repaint. Upgrade path if it ever shows up: edit in place and snapshot lazily.
108    // Main is the one bus that always exists: without it the pane is empty on a fresh project and there
109    // is nothing to route to. Creating it is not a user edit, so it takes no undo entry.
110    project.main_bus();
111    let mut list = project.buses.clone();
112    let names: Vec<(Id, String)> = list.iter().map(|b| (b.id, b.name.clone())).collect();
113    let main = list.first().map(|b| b.id).unwrap_or(0);
114    let feeds = feeds(project, &list, main);
115
116    ui.horizontal(|ui| {
117        let r = crate::ui::tools::glyph_text_button(ui, Glyph::Headphone, "+ Bus");
118        #[cfg(test)]
119        test_rects::push("add_bus".into(), r.rect);
120        if r.clicked() {
121            ed.add_bus = true;
122            g.click();
123        }
124        ui.checkbox(&mut state.show_routing, "Routing");
125    });
126    // routing above the strips: the strips claim the rest of the pane for their own scrollbars
127    if state.show_routing {
128        ui.separator();
129        routing(ui, project, selection, &names, main, state, &mut g, &mut ed);
130    }
131    ui.separator();
132
133    // Main first: it is the master, and every other bus is read as feeding into it.
134    let order: Vec<usize> = (0..list.len()).collect();
135    egui::ScrollArea::horizontal().id_salt("mixer_strips").show(ui, |ui| {
136        ui.horizontal_top(|ui| {
137            for (n, &i) in order.iter().enumerate() {
138                let is_main = list[i].id == main;
139                let id = list[i].id;
140                let meter = buses.meter(id);
141                let sel = state.selected_bus == Some(id);
142                let feed = feeds.iter().find(|(b, _)| *b == id).map(|(_, s)| s.clone()).unwrap_or_default();
143                ui.push_id(("strip", id), |ui| {
144                    let size = vec2(STRIP_W, ui.available_height());
145                    ui.allocate_ui_with_layout(size, egui::Layout::top_down(egui::Align::Min), |ui| {
146                        ui.set_width(STRIP_W);
147                        let stroke = if sel { Stroke::new(1.0, palette.accent) } else { Stroke::NONE };
148                        egui::Frame::new().stroke(stroke).inner_margin(2.0).show(ui, |ui| {
149                            egui::ScrollArea::vertical()
150                                .id_salt(("strip_scroll", id))
151                                .auto_shrink([false, false])
152                                .show(ui, |ui| {
153                                    let s = Strip { is_main, meter, feed: &feed, time };
154                                    strip(ui, &mut list[i], s, &names, palette, state, &mut g, &mut ed, n);
155                                });
156                        });
157                    });
158                });
159                ui.separator();
160            }
161        });
162    });
163
164    // floating filters: the same body as the strip, over the same clone, so an edit here lands in the
165    // write-back below exactly like an edit made in the strip
166    let mut still: Vec<(Id, usize)> = Vec::new();
167    for (bus_id, i) in state.popped.clone() {
168        let Some(bi) = list.iter().position(|b| b.id == bus_id) else { continue };
169        let Some(kind) = list[bi].filters.get(i).map(|f| f.kind.name()) else { continue };
170        let name = list[bi].name.clone();
171        let mut open = true;
172        egui::Window::new(format!("{name} - {kind}"))
173            .id(egui::Id::new(("popfx", bus_id, i)))
174            .open(&mut open)
175            .resizable(true)
176            .default_width(280.0)
177            .show(ui.ctx(), |ui| {
178                if let Some(f) = list[bi].filters.get_mut(i) {
179                    f.fill_params();
180                    filter_body(ui, f, bus_id, i, time, palette, state, &mut g, bi);
181                }
182            });
183        if open {
184            still.push((bus_id, i));
185        }
186    }
187    state.popped = still;
188
189    if g.start {
190        undo(project);
191    }
192    if g.changed {
193        project.buses = list;
194        if ed.add_bus {
195            let n = project.buses.len();
196            let id = project.add_bus(format!("Bus {n}"));
197            state.selected_bus = Some(id);
198        }
199        if let Some(id) = ed.remove {
200            project.remove_bus(id);
201            if state.selected_bus == Some(id) {
202                state.selected_bus = None;
203            }
204        }
205        if let Some((ti, b)) = ed.track_bus {
206            if let Some(t) = project.tracks.get_mut(ti) {
207                t.bus = b;
208            }
209        }
210        if let Some((cid, b)) = ed.clip_bus {
211            if let Some(c) = project.clip_mut(cid) {
212                c.bus = b;
213            }
214        }
215    }
216    g.changed
217}
218
219/// What one strip needs to know about its bus beyond the `Bus` itself.
220struct Strip<'a> {
221    is_main: bool,
222    meter: (f32, f32),
223    /// Tracks and buses that sum into this one, comma-joined.
224    feed: &'a str,
225    /// Playhead, in timeline seconds: every parameter is read and written there.
226    time: f64,
227}
228
229/// Who sums into each bus: the audio tracks routed to it and the buses that send here. Mirrors
230/// `mixer_fx::resolve` — a dangling send lands in Main.
231/// ponytail: track-level routing only. A single clip's `Clip.bus` override is visible in the routing
232/// list instead; walk `Project::bus_of` over every clip here if that turns out to be confusing.
233fn feeds(project: &Project, list: &[Bus], main: Id) -> Vec<(Id, String)> {
234    let live = |id: Id| if id != 0 && list.iter().any(|o| o.id == id) { id } else { main };
235    list.iter()
236        .map(|b| {
237            let mut v: Vec<String> = project
238                .audio_tracks()
239                .into_iter()
240                .filter(|&ti| live(project.tracks[ti].bus) == b.id)
241                .map(|ti| project.tracks[ti].name.clone())
242                .collect();
243            let sends = list.iter().filter(|o| o.id != main && o.id != b.id && live(o.output) == b.id);
244            v.extend(sends.map(|o| o.name.clone()));
245            (b.id, v.join(", "))
246        })
247        .collect()
248}
249
250/// Diamond keyframe toggle for one parameter at the playhead; right-click clears every key. `at` only has
251/// to be unique inside the strip — the mixer edits a fresh clone of the bus list every frame, so an
252/// id derived from the `Animated`'s address (`crate::ui::key_buttons`) would not survive a click.
253fn key_button(ui: &mut egui::Ui, a: &mut Animated, t: f64, palette: &Palette, g: &mut Gesture, at: (Id, usize, usize)) {
254    let tip = if a.is_animated() {
255        format!("{} keyframes — right-click to clear", a.keys.len())
256    } else {
257        "Toggle keyframe at playhead".to_string()
258    };
259    let r = icon_button(ui, palette, ui.id().with(("kf", at)), Glyph::Diamond, &tip, a.has_key_at(t));
260    #[cfg(test)]
261    test_rects::push(format!("kf{}_{}_{}", at.0, at.1, at.2), r.rect);
262    if r.clicked() {
263        a.toggle_key(t);
264        g.click();
265    }
266    if a.is_animated() {
267        r.context_menu(|ui| {
268            if ui.button("Remove all keyframes").clicked() {
269                a.clear_keys(t);
270                g.click();
271                ui.close();
272            }
273        });
274    }
275}
276
277#[allow(clippy::too_many_arguments)]
278fn strip(
279    ui: &mut egui::Ui,
280    bus: &mut Bus,
281    s: Strip,
282    names: &[(Id, String)],
283    palette: &Palette,
284    state: &mut MixerState,
285    g: &mut Gesture,
286    ed: &mut Edits,
287    n: usize,
288) {
289    let _ = n; // only the test rect registry uses the strip index
290    ui.horizontal(|ui| {
291        let r = ui.add(TextEdit::singleline(&mut bus.name).desired_width(STRIP_W - 46.0));
292        g.note(&r);
293        if r.clicked() {
294            state.selected_bus = Some(bus.id);
295        }
296        let del = ui.add_enabled_ui(!s.is_main, crate::ui::markers_ui::x_button).inner;
297        #[cfg(test)]
298        test_rects::push(format!("del_bus{n}"), del.rect);
299        if del.clicked() {
300            ed.remove = Some(bus.id);
301            g.click();
302        }
303    });
304
305    let feed = if s.feed.is_empty() { "nothing routed here" } else { s.feed };
306    let r = ui
307        .add(
308            egui::Label::new(egui::RichText::new(feed).small().color(palette.text_dim))
309                .truncate()
310                .sense(Sense::click()),
311        )
312        .on_hover_text(if s.feed.is_empty() { "No track or bus feeds this bus" } else { s.feed });
313    #[cfg(test)]
314    test_rects::push(format!("feed{n}"), r.rect);
315    if r.clicked() {
316        state.selected_bus = Some(bus.id);
317    }
318
319    meter(ui, s.meter, palette);
320
321    ui.horizontal(|ui| {
322        let mut db = lin_to_db(bus.gain.at(s.time) as f32).max(-60.0);
323        let r = ui.add(DragValue::new(&mut db).range(-60.0..=12.0).speed(0.2).suffix(" dB").fixed_decimals(1));
324        #[cfg(test)]
325        test_rects::push(format!("gain{n}"), r.rect);
326        if r.changed() {
327            bus.gain.set_at(s.time, db_to_lin(db) as f64);
328        }
329        g.note(&r);
330        key_button(ui, &mut bus.gain, s.time, palette, g, (bus.id, usize::MAX, 0));
331        let mut pan = bus.pan.at(s.time);
332        let r = ui.add(DragValue::new(&mut pan).range(-1.0..=1.0).speed(0.01).prefix("pan ").fixed_decimals(2));
333        if r.changed() {
334            bus.pan.set_at(s.time, pan);
335        }
336        g.note(&r);
337        key_button(ui, &mut bus.pan, s.time, palette, g, (bus.id, usize::MAX, 1));
338    });
339
340    ui.horizontal(|ui| {
341        let icon = if bus.muted { Glyph::SpeakerOff } else { Glyph::SpeakerOn };
342        let r = icon_button(ui, palette, ui.id().with(("mute", bus.id)), icon, "Mute", bus.muted);
343        #[cfg(test)]
344        test_rects::push(format!("m{n}"), r.rect);
345        if r.clicked() {
346            bus.muted = !bus.muted;
347            g.click();
348        }
349        for (label, flag, hint) in [("S", &mut bus.solo, "Solo"), ("Mono", &mut bus.mono, "Fold to mono")] {
350            let r = ui.add(Button::new(label).small().selected(*flag)).on_hover_text(hint);
351            #[cfg(test)]
352            test_rects::push(format!("{}{n}", label.to_lowercase()), r.rect);
353            if r.clicked() {
354                *flag = !*flag;
355                g.click();
356            }
357        }
358    });
359
360    if !s.is_main {
361        ui.horizontal(|ui| {
362            ui.label("→");
363            let cur = names.iter().find(|(id, _)| *id == bus.output).map(|(_, n)| n.as_str()).unwrap_or("Main");
364            ComboBox::from_id_salt(("out", bus.id)).selected_text(cur).width(STRIP_W - 34.0).show_ui(ui, |ui| {
365                for (id, name) in names {
366                    // no self-send; a longer loop is broken back to Main by the graph anyway
367                    if *id == bus.id {
368                        continue;
369                    }
370                    if ui.selectable_label(bus.output == *id, name).clicked() && bus.output != *id {
371                        bus.output = *id;
372                        g.click();
373                    }
374                }
375            });
376        });
377    }
378
379    filters(ui, bus, s.time, palette, state, g, n);
380}
381
382/// Stereo peak meter, -60 dB … +6 dB left to right.
383fn meter(ui: &mut egui::Ui, lr: (f32, f32), palette: &Palette) {
384    let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 11.0), Sense::hover());
385    let p = ui.painter();
386    p.rect_filled(rect, 0.0, palette.bg);
387    for (i, v) in [lr.0, lr.1].into_iter().enumerate() {
388        let db = lin_to_db(v).clamp(-60.0, 6.0);
389        let f = ((db + 60.0) / 66.0).clamp(0.0, 1.0);
390        if f <= 0.0 {
391            continue;
392        }
393        let y = rect.top() + 1.0 + i as f32 * 5.0;
394        let bar = Rect::from_min_size(pos2(rect.left(), y), vec2(rect.width() * f, 4.0));
395        p.rect_filled(bar, 0.0, if db > -1.0 { palette.playhead } else { palette.waveform });
396    }
397    p.rect_stroke(rect, 0.0, Stroke::new(1.0, palette.border), egui::StrokeKind::Inside);
398}
399
400#[allow(clippy::too_many_arguments)]
401/// One filter's editable half: its response curve and its parameter grid. Shared by the strip and by
402/// the pop-out window, so a floating filter is the same control surface, not a second implementation.
403#[allow(clippy::too_many_arguments)]
404fn filter_body(
405    ui: &mut egui::Ui,
406    f: &mut AudioFilter,
407    bus_id: Id,
408    i: usize,
409    time: f64,
410    palette: &Palette,
411    state: &mut MixerState,
412    g: &mut Gesture,
413    n: usize,
414) {
415    if curve(ui, f, (bus_id, i), time, palette, state, g, n) {
416        g.changed = true;
417    }
418    let specs = f.kind.params();
419    // the EQ's parameters are stored in the order the old 3-band EQ used; read them by band
420    let order: Vec<usize> = if f.kind == FilterKind::Eq {
421        EQ_BANDS.iter().flat_map(|&(_, gi, fi, qi)| [fi, gi, qi]).collect()
422    } else {
423        (0..specs.len()).collect()
424    };
425    Grid::new(("fx", bus_id, i)).num_columns(3).spacing(vec2(4.0, 2.0)).show(ui, |ui| {
426        for j in order {
427            let (Some(spec), Some(a)) = (specs.get(j), f.params.get_mut(j)) else { continue };
428            ui.label(spec.name);
429            let mut v = a.at(time);
430            let r = match spec.name {
431                "Ping-pong" => {
432                    let mut on = v >= 0.5;
433                    let r = ui.checkbox(&mut on, "");
434                    v = if on { 1.0 } else { 0.0 };
435                    r
436                }
437                "Type" => {
438                    let opts = ["White", "Pink", "Tone"];
439                    let cur = opts.get(v.round().clamp(0.0, 2.0) as usize).copied().unwrap_or("White");
440                    let mut picked = None;
441                    let mut r = ComboBox::from_id_salt(("ty", bus_id, i))
442                        .selected_text(cur)
443                        .width(84.0)
444                        .show_ui(ui, |ui| {
445                            for (k, o) in opts.iter().enumerate() {
446                                if ui.selectable_label(cur == *o, *o).clicked() {
447                                    picked = Some(k as f64);
448                                }
449                            }
450                        })
451                        .response;
452                    if let Some(k) = picked {
453                        v = k;
454                        r.mark_changed();
455                    }
456                    r
457                }
458                _ => ui.add(
459                    DragValue::new(&mut v)
460                        .range(spec.min..=spec.max)
461                        .clamp_existing_to_range(false)
462                        .speed((spec.max - spec.min) / 200.0),
463                ),
464            };
465            #[cfg(test)]
466            test_rects::push(format!("p{n}_{i}_{j}"), r.rect);
467            if r.changed() {
468                a.set_at(time, v);
469            }
470            g.note(&r);
471            key_button(ui, a, time, palette, g, (bus_id, i, j));
472            ui.end_row();
473        }
474    });
475}
476
477fn filters(
478    ui: &mut egui::Ui,
479    bus: &mut Bus,
480    time: f64,
481    palette: &Palette,
482    state: &mut MixerState,
483    g: &mut Gesture,
484    n: usize,
485) {
486    let _ = n;
487    let bus_id = bus.id;
488    let count = bus.filters.len();
489    let mut remove: Option<usize> = None;
490    let mut swap: Option<(usize, usize)> = None;
491    let mut pop: Option<usize> = None;
492    for (i, f) in bus.filters.iter_mut().enumerate() {
493        // a project saved before the kind grew a parameter is short: top it up from the spec table
494        f.fill_params();
495        ui.horizontal(|ui| {
496            g.note(&ui.checkbox(&mut f.enabled, ""));
497            // on the left: the right-hand group is already full at STRIP_W and a fourth button there
498            // gets clipped out of the strip
499            let po = glyph_text_button(ui, Glyph::PopOut, "").on_hover_text("Open in its own window");
500            #[cfg(test)]
501            test_rects::push(format!("popfx{n}_{i}"), po.rect);
502            if po.clicked() {
503                pop = Some(i);
504            }
505            ui.label(f.kind.name());
506            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
507                let del = crate::ui::markers_ui::x_button(ui).on_hover_text("Remove this filter");
508                #[cfg(test)]
509                test_rects::push(format!("delfx{n}_{i}"), del.rect);
510                if del.clicked() {
511                    remove = Some(i);
512                }
513                let down = |ui: &mut egui::Ui| glyph_text_button(ui, Glyph::Tri(Dir::Down), "");
514                if ui.add_enabled_ui(i + 1 < count, down).inner.on_hover_text("Move down").clicked() {
515                    swap = Some((i, i + 1));
516                }
517                let up = |ui: &mut egui::Ui| glyph_text_button(ui, Glyph::Tri(Dir::Up), "");
518                if ui.add_enabled_ui(i > 0, up).inner.on_hover_text("Move up").clicked() {
519                    swap = Some((i, i - 1));
520                }
521            });
522        });
523        filter_body(ui, f, bus_id, i, time, palette, state, g, n);
524    }
525    if let Some((a, b)) = swap {
526        bus.filters.swap(a, b);
527        g.click();
528    }
529    if let Some(i) = pop {
530        if !state.popped.contains(&(bus_id, i)) {
531            state.popped.push((bus_id, i));
532        }
533    }
534    if let Some(i) = remove {
535        bus.filters.remove(i);
536        g.click();
537    }
538    let mut add: Option<FilterKind> = None;
539    ComboBox::from_id_salt(("addfx", bus.id)).selected_text("+ Filter").width(STRIP_W - 16.0).show_ui(ui, |ui| {
540        for kind in FilterKind::ALL {
541            if ui.selectable_label(false, kind.name()).clicked() {
542                add = Some(kind);
543            }
544        }
545    });
546    if let Some(kind) = add {
547        bus.filters.push(AudioFilter::new(kind));
548        g.click();
549    }
550}
551
552/// EQ / pass-filter response plot: a labelled log-frequency + dB grid, the summed curve on top, and one
553/// draggable handle per band (X = frequency, Y = gain). The grabbed band is latched for the whole drag.
554/// Returns true when the drag changed a parameter.
555#[allow(clippy::too_many_arguments)]
556fn curve(
557    ui: &mut egui::Ui,
558    f: &mut AudioFilter,
559    key: (Id, usize),
560    time: f64,
561    palette: &Palette,
562    state: &mut MixerState,
563    g: &mut Gesture,
564    n: usize,
565) -> bool {
566    let _ = n;
567    // (gain param, freq param) per band, in the order `filter_bands` reports them
568    let map: Vec<(Option<usize>, usize)> = match f.kind {
569        FilterKind::Eq => EQ_BANDS.iter().map(|&(_, gi, fi, _)| (Some(gi), fi)).collect(),
570        FilterKind::HighPass | FilterKind::LowPass => vec![(None, 0)],
571        _ => return false,
572    };
573    let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), CURVE_H), Sense::click_and_drag());
574    #[cfg(test)]
575    test_rects::push(format!("curve{n}_{}", key.1), rect);
576    // the frequency labels live in a strip along the bottom; the response only uses the rest
577    let label_h = 11.0;
578    let plot = Rect::from_min_max(rect.min, pos2(rect.right(), rect.bottom() - label_h));
579    let y_of = |db: f32| plot.center().y - db.clamp(-CURVE_DB, CURVE_DB) / CURVE_DB * plot.height() * 0.5;
580    let x_of = |hz: f32| rect.left() + (hz.max(20.0) / 20.0).log10() / 3.0 * rect.width();
581    let hz_of = |x: f32| 20.0 * 10f32.powf(((x - rect.left()) / rect.width().max(1.0)).clamp(0.0, 1.0) * 3.0);
582    let bands = filter_bands(f, time);
583    let handle = |i: usize| pos2(x_of(bands[i].1), y_of(bands[i].3));
584
585    let p = ui.painter();
586    p.rect_filled(rect, 0.0, palette.bg);
587    let faint = palette.border.gamma_multiply(0.8);
588    let font = FontId::proportional(8.0);
589    for db in [-18.0, -12.0, -6.0, 6.0, 12.0, 18.0] {
590        p.line_segment([pos2(plot.left(), y_of(db)), pos2(plot.right(), y_of(db))], Stroke::new(1.0, faint));
591    }
592    for (hz, name) in GRID_HZ {
593        let x = x_of(hz);
594        p.line_segment([pos2(x, plot.top()), pos2(x, plot.bottom())], Stroke::new(1.0, faint));
595        // keep the outermost labels inside the plot instead of half-clipped
596        let tx = x.clamp(rect.left() + 9.0, rect.right() - 9.0);
597        p.text(pos2(tx, rect.bottom() - label_h), Align2::CENTER_TOP, name, font.clone(), palette.text_dim);
598    }
599    let zero = Stroke::new(1.0, palette.text_dim.gamma_multiply(0.6));
600    p.line_segment([pos2(plot.left(), y_of(0.0)), pos2(plot.right(), y_of(0.0))], zero);
601    for db in [12.0f32, -12.0] {
602        let t = if db > 0.0 { "+12" } else { "-12" };
603        p.text(pos2(plot.left() + 2.0, y_of(db)), Align2::LEFT_CENTER, t, font.clone(), palette.text_dim);
604    }
605    let steps = (rect.width() as usize / 3).clamp(16, 128);
606    let pts: Vec<_> = (0..=steps)
607        .map(|i| {
608            let x = rect.left() + rect.width() * i as f32 / steps as f32;
609            pos2(x, y_of(filter_response_db(f, time, hz_of(x))))
610        })
611        .collect();
612    p.add(egui::Shape::line(pts, Stroke::new(1.5, palette.accent)));
613    let hot = state.drag.filter(|d| (d.0, d.1) == key).map(|d| d.2);
614    for i in 0..bands.len() {
615        let c = handle(i);
616        let on = hot == Some(i) || resp.hover_pos().is_some_and(|q| (q - c).length() < 8.0);
617        p.circle_filled(c, if on { 4.5 } else { 3.0 }, palette.keyframe);
618        if on {
619            p.circle_stroke(c, 6.5, Stroke::new(1.0, palette.accent));
620        }
621    }
622    p.rect_stroke(rect, 0.0, Stroke::new(1.0, palette.border), egui::StrokeKind::Inside);
623
624    let mut changed = false;
625    if resp.drag_started() {
626        g.start = true;
627        // grab the handle nearest the pointer and keep it for the rest of the drag
628        if let Some(pos) = resp.interact_pointer_pos() {
629            let pick =
630                (0..bands.len()).min_by(|&a, &b| (handle(a) - pos).length().total_cmp(&(handle(b) - pos).length()));
631            state.drag = pick.map(|i| (key.0, key.1, i));
632        }
633    }
634    if resp.dragged() {
635        if let (Some(bi), Some(pos)) =
636            (state.drag.filter(|d| (d.0, d.1) == key).map(|d| d.2), resp.interact_pointer_pos())
637        {
638            if let Some(&(gi, fi)) = map.get(bi) {
639                let specs = f.kind.params();
640                if let (Some(spec), Some(a)) = (specs.get(fi), f.params.get_mut(fi)) {
641                    a.set_at(time, (hz_of(pos.x) as f64).clamp(spec.min, spec.max));
642                    changed = true;
643                }
644                if let Some(gi) = gi {
645                    let db = (plot.center().y - pos.y) / (plot.height() * 0.5) * CURVE_DB;
646                    if let (Some(spec), Some(a)) = (specs.get(gi), f.params.get_mut(gi)) {
647                        a.set_at(time, (db as f64).clamp(spec.min, spec.max));
648                        changed = true;
649                    }
650                }
651            }
652        }
653    }
654    if resp.drag_stopped() {
655        state.drag = None;
656    }
657    changed
658}
659
660/// One row per audio track — click a track to send it to the selected bus, or pick from its own combo —
661/// plus the `Clip.bus` override of the selected clip.
662#[allow(clippy::too_many_arguments)]
663fn routing(
664    ui: &mut egui::Ui,
665    project: &Project,
666    selection: &[Id],
667    names: &[(Id, String)],
668    main: Id,
669    state: &mut MixerState,
670    g: &mut Gesture,
671    ed: &mut Edits,
672) {
673    if names.is_empty() {
674        ui.label("Add a bus first");
675        return;
676    }
677    let label = |id: Id| {
678        names.iter().find(|(i, _)| *i == id).map(|(_, n)| n.as_str()).unwrap_or(if id == 0 { "Main" } else { "?" })
679    };
680    let target = state.selected_bus.filter(|id| names.iter().any(|(i, _)| i == id));
681    let clip = selection.iter().copied().find(|&id| project.clip(id).is_some());
682
683    ui.label(match target {
684        Some(id) => format!("Click a track to send it to “{}”", label(id)),
685        None => "Click a bus strip's name to pick where tracks go".to_string(),
686    });
687    egui::ScrollArea::vertical().id_salt("mixer_routing_scroll").max_height(96.0).show(ui, |ui| {
688        Grid::new("mixer_routing").num_columns(2).show(ui, |ui| {
689            for (row, ti) in project.audio_tracks().into_iter().enumerate() {
690                let Some(track) = project.tracks.get(ti) else { continue };
691                let cur = if track.bus == 0 { main } else { track.bus };
692                let hit = target.is_some_and(|id| id != cur);
693                let r = ui
694                    .add(Button::new(&track.name).selected(clip.is_some_and(|c| project.track_of(c) == Some(ti))))
695                    .on_hover_text(match target {
696                        Some(id) if id != cur => format!("Send this track to “{}”", label(id)),
697                        Some(_) => "Already on the selected bus".to_string(),
698                        None => "Select a bus first (click its name in a strip)".to_string(),
699                    });
700                #[cfg(test)]
701                test_rects::push(format!("track{row}"), r.rect);
702                let _ = row;
703                if r.clicked() && hit {
704                    ed.track_bus = Some((ti, target.unwrap_or(main)));
705                    g.click();
706                }
707                ComboBox::from_id_salt(("track_bus", ti)).selected_text(label(cur)).width(140.0).show_ui(ui, |ui| {
708                    for (id, name) in names {
709                        if ui.selectable_label(cur == *id, name).clicked() && cur != *id {
710                            ed.track_bus = Some((ti, *id));
711                            g.click();
712                        }
713                    }
714                });
715                ui.end_row();
716            }
717
718            if let Some(c) = clip.and_then(|id| project.clip(id)) {
719                let audio = project
720                    .track_of(c.id)
721                    .and_then(|ti| project.tracks.get(ti))
722                    .is_some_and(|t| t.kind == TrackKind::Audio);
723                if audio || c.kind == crate::model::ClipKind::Sequence {
724                    ui.label(format!("Clip “{}”", c.name));
725                    let cur = if c.bus == 0 { "(track)".to_string() } else { label(c.bus).to_string() };
726                    ComboBox::from_id_salt("clip_bus").selected_text(cur).width(140.0).show_ui(ui, |ui| {
727                        if ui.selectable_label(c.bus == 0, "(track)").clicked() && c.bus != 0 {
728                            ed.clip_bus = Some((c.id, 0));
729                            g.click();
730                        }
731                        for (id, name) in names {
732                            if ui.selectable_label(c.bus == *id, name).clicked() && c.bus != *id {
733                                ed.clip_bus = Some((c.id, *id));
734                                g.click();
735                            }
736                        }
737                    });
738                    ui.end_row();
739                }
740            }
741        });
742    });
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748    use crate::model::{Clip, ClipKind};
749    use eframe::egui::{Color32, Event, Modifiers, PointerButton, Pos2, RawInput};
750
751    struct Harness {
752        ctx: egui::Context,
753        project: Project,
754        state: MixerState,
755        graph: BusGraph,
756        selection: Vec<Id>,
757        undos: usize,
758        time: f64,
759        /// Timeline playhead handed to `show` — every parameter is read and written there.
760        playhead: f64,
761    }
762
763    impl Harness {
764        fn new() -> Self {
765            let mut project = Project::new();
766            let ai = project.audio_tracks()[0];
767            project.tracks[ai].clips.push(Clip::new(7, ClipKind::Audio, "a", 0.0, 4.0));
768            project.main_bus();
769            let music = project.add_bus("Music");
770            project.tracks[ai].bus = music;
771            let mut graph = BusGraph::new();
772            graph.sync(&project);
773            Self {
774                ctx: egui::Context::default(),
775                project,
776                state: MixerState { show_routing: true, ..Default::default() },
777                graph,
778                selection: vec![7],
779                undos: 0,
780                time: 0.0,
781                playhead: 0.0,
782            }
783        }
784        fn frame(&mut self, events: Vec<Event>) -> bool {
785            self.time += 0.05;
786            let input = RawInput {
787                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(700.0, 900.0))),
788                time: Some(self.time),
789                events,
790                ..Default::default()
791            };
792            let pal = Palette::new(true, Color32::WHITE);
793            let Harness { ctx, project, state, graph, selection, undos, playhead, .. } = self;
794            let playhead = *playhead;
795            let mut edited = false;
796            let _ = ctx.run(input, |ctx| {
797                egui::CentralPanel::default().show(ctx, |ui| {
798                    let mut undo = |_: &Project| *undos += 1;
799                    edited |= show(ui, state, project, selection, graph, playhead, &pal, &mut undo);
800                });
801            });
802            edited
803        }
804        fn click(&mut self, pos: Pos2) -> bool {
805            self.frame(vec![Event::PointerMoved(pos)]);
806            let mut e = self.frame(vec![Event::PointerButton {
807                pos,
808                button: PointerButton::Primary,
809                pressed: true,
810                modifiers: Modifiers::default(),
811            }]);
812            e |= self.frame(vec![Event::PointerButton {
813                pos,
814                button: PointerButton::Primary,
815                pressed: false,
816                modifiers: Modifiers::default(),
817            }]);
818            e |= self.frame(vec![]);
819            e
820        }
821        fn click_named(&mut self, name: &str) -> bool {
822            self.frame(vec![]);
823            let rect = test_rects::get(name).unwrap_or_else(|| panic!("no widget {name}"));
824            self.click(rect.center())
825        }
826        /// Press at `from`, move there in four steps, release. True if any frame reported an edit.
827        fn drag(&mut self, from: Pos2, delta: egui::Vec2) -> bool {
828            self.frame(vec![Event::PointerMoved(from)]);
829            self.frame(vec![Event::PointerButton {
830                pos: from,
831                button: PointerButton::Primary,
832                pressed: true,
833                modifiers: Modifiers::NONE,
834            }]);
835            let mut edited = false;
836            for i in 1..=4 {
837                edited |= self.frame(vec![Event::PointerMoved(from + delta * (i as f32 / 4.0))]);
838            }
839            edited |= self.frame(vec![Event::PointerButton {
840                pos: from + delta,
841                button: PointerButton::Primary,
842                pressed: false,
843                modifiers: Modifiers::NONE,
844            }]);
845            edited | self.frame(vec![])
846        }
847    }
848
849    #[test]
850    fn draws_two_buses_without_changing_anything() {
851        let mut h = Harness::new();
852        assert!(!h.frame(vec![]), "drawing must not report an edit");
853        assert!(!h.frame(vec![]));
854        assert_eq!(h.undos, 0);
855        assert_eq!(h.project.buses.len(), 2);
856        // both strips are there (Main draws first, so it is index 0)
857        assert!(test_rects::get("m0").is_some() && test_rects::get("m1").is_some());
858    }
859
860    #[test]
861    fn add_and_remove_a_bus() {
862        let mut h = Harness::new();
863        assert!(h.click_named("add_bus"));
864        assert_eq!(h.project.buses.len(), 3);
865        assert_eq!(h.undos, 1);
866        // strip 1 is the first non-Main bus; deleting it drops back to two
867        assert!(h.click_named("del_bus1"));
868        assert_eq!(h.project.buses.len(), 2);
869    }
870
871    #[test]
872    fn mute_solo_mono_toggle() {
873        let mut h = Harness::new();
874        assert!(h.click_named("m1"));
875        assert!(h.project.buses[1].muted, "strip 1 is the Music bus");
876        assert!(h.click_named("s1"));
877        assert!(h.project.buses[1].solo);
878        assert!(h.click_named("mono1"));
879        assert!(h.project.buses[1].mono);
880        assert_eq!(h.undos, 3);
881        // Main can never be deleted
882        let del_main = test_rects::get("del_bus0").expect("Main delete button");
883        h.click(del_main.center());
884        assert_eq!(h.project.buses.len(), 2);
885    }
886
887    #[test]
888    fn add_a_filter_and_edit_a_param() {
889        let mut h = Harness::new();
890        let music = h.project.buses[1].id;
891        h.project.bus_mut(music).unwrap().filters.push(AudioFilter::new(FilterKind::Gain));
892        h.frame(vec![]);
893        // the Gain filter has one param row; drag it
894        let rect = test_rects::get("p1_0_0").expect("gain param");
895        let changed = h.drag(rect.center(), vec2(40.0, 0.0));
896        assert!(changed, "dragging the DragValue edits the project");
897        assert!(h.project.buses[1].filters[0].params[0].value > 0.0);
898        assert!(h.undos >= 1);
899        // and it can be removed again
900        assert!(h.click_named("delfx1_0"));
901        assert!(h.project.buses[1].filters.is_empty());
902    }
903
904    /// A filter can be pulled out into its own window, and edits there land on the project the same way
905    /// the strip's do.
906    #[test]
907    fn a_filter_pops_out_into_its_own_window() {
908        let mut h = Harness::new();
909        let music = h.project.buses[1].id;
910        h.project.bus_mut(music).unwrap().filters.push(AudioFilter::new(FilterKind::Gain));
911        h.frame(vec![]);
912        // click() reports whether the project changed, and popping a filter out is not a project edit
913        let r = test_rects::get("popfx1_0").expect("the pop-out button is registered");
914        h.click(r.center());
915        assert_eq!(h.state.popped, vec![(music, 0)], "the filter is floating");
916        h.frame(vec![]);
917        // the floating copy draws the same parameter row, so the id is registered twice this frame
918        assert!(test_rects::get("p1_0_0").is_some(), "the window draws the parameter grid");
919        // and it stops floating when the filter is deleted underneath it
920        h.project.bus_mut(music).unwrap().filters.clear();
921        h.frame(vec![]);
922        assert!(h.state.popped.is_empty(), "a removed filter takes its window with it");
923    }
924
925    #[test]
926    fn eq_curve_drag_moves_a_band() {
927        let mut h = Harness::new();
928        let music = h.project.buses[1].id;
929        h.project.bus_mut(music).unwrap().filters.push(AudioFilter::new(FilterKind::Eq));
930        h.frame(vec![]);
931        // grab the low shelf's handle: x is its frequency on the log axis, y its 0 dB gain
932        let plot = test_rects::get("curve1_0").expect("response plot");
933        let x = plot.left() + (120.0f32 / 20.0).log10() / 3.0 * plot.width();
934        let handle = pos2(x, plot.center().y);
935        assert!(h.drag(handle, vec2(0.0, -20.0)), "dragging a handle edits its band");
936        let f = &h.project.buses[1].filters[0];
937        assert!(f.params[0].value > 3.0, "the low shelf was boosted: {}", f.params[0].value);
938        assert!((f.params[1].value - 120.0).abs() < 20.0, "its frequency stayed put: {}", f.params[1].value);
939        assert_eq!(f.params[9].value, 0.0, "the neighbouring band is untouched");
940        assert!(h.undos >= 1);
941    }
942
943    #[test]
944    fn filter_params_are_keyframed_at_the_playhead() {
945        let mut h = Harness::new();
946        let music = h.project.buses[1].id;
947        h.project.bus_mut(music).unwrap().filters.push(AudioFilter::new(FilterKind::Gain));
948        h.frame(vec![]);
949        assert!(h.click_named(&format!("kf{music}_0_0")), "◆ keyframes the parameter");
950        assert_eq!(h.project.buses[1].filters[0].params[0].keys.len(), 1);
951        // at another playhead the drag upserts a key instead of moving the constant
952        h.playhead = 2.0;
953        let rect = test_rects::get("p1_0_0").expect("gain param");
954        assert!(h.drag(rect.center(), vec2(40.0, 0.0)));
955        let a = &h.project.buses[1].filters[0].params[0];
956        assert_eq!(a.keys.len(), 2, "{:?}", a.keys);
957        assert!(a.at(2.0) > a.at(0.0) + 1.0, "the 2 s key holds the dragged value: {:?}", a.keys);
958    }
959
960    #[test]
961    fn clicking_a_track_row_sends_it_to_the_selected_bus() {
962        let mut h = Harness::new();
963        let main = h.project.buses[0].id;
964        let ai = h.project.audio_tracks()[0];
965        h.state.selected_bus = Some(main);
966        assert!(h.click_named("track0"), "the track row reroutes to the selected bus");
967        assert_eq!(h.project.tracks[ai].bus, main);
968        assert_eq!(h.undos, 1);
969    }
970
971    #[test]
972    fn feeds_names_what_sums_into_each_bus() {
973        let h = Harness::new();
974        let (main, music) = (h.project.buses[0].id, h.project.buses[1].id);
975        let f = feeds(&h.project, &h.project.buses, main);
976        let of = |id| f.iter().find(|(b, _)| *b == id).map(|(_, s)| s.clone()).unwrap();
977        let track = h.project.tracks[h.project.audio_tracks()[0]].name.clone();
978        assert_eq!(of(music), track, "the track routed to Music");
979        assert_eq!(of(main), "Music", "and Music itself sends into Main");
980    }
981
982    #[test]
983    fn routing_combos_reach_the_track_and_clip() {
984        let mut h = Harness::new();
985        h.frame(vec![]);
986        let ai = h.project.audio_tracks()[0];
987        let music = h.project.buses[1].id;
988        assert_eq!(h.project.tracks[ai].bus, music, "the harness routes the track to Music");
989        assert_eq!(h.project.clip(7).unwrap().bus, 0, "the clip inherits by default");
990        // the routing grid is drawn (show_routing = true) without panicking or editing
991        assert!(!h.frame(vec![]));
992    }
993
994    #[test]
995    fn works_with_no_buses_at_all() {
996        let mut h = Harness::new();
997        h.project.buses.clear();
998        h.project.tracks.iter_mut().for_each(|t| t.bus = 0);
999        assert!(!h.frame(vec![]));
1000        assert!(h.click_named("add_bus"), "the first click creates Main + a bus");
1001        assert_eq!(h.project.buses.len(), 2);
1002    }
1003}