1use 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
30const STRIP_W: f32 = 210.0;
32const CURVE_H: f32 = 96.0;
34const CURVE_DB: f32 = 24.0;
36const 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 pub popped: Vec<(Id, usize)>,
56 pub show_routing: bool,
58 drag: Option<(Id, usize, usize)>,
61}
62
63#[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#[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 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 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 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 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
219struct Strip<'a> {
221 is_main: bool,
222 meter: (f32, f32),
223 feed: &'a str,
225 time: f64,
227}
228
229fn 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
250fn 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; 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 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
382fn 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#[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 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 f.fill_params();
495 ui.horizontal(|ui| {
496 g.note(&ui.checkbox(&mut f.enabled, ""));
497 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#[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 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 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 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 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#[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 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 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 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 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 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 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 assert!(h.click_named("delfx1_0"));
901 assert!(h.project.buses[1].filters.is_empty());
902 }
903
904 #[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 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 assert!(test_rects::get("p1_0_0").is_some(), "the window draws the parameter grid");
919 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 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 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 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}