diff --git a/crates/ui-egui/src/panels/mixer.rs b/crates/ui-egui/src/panels/mixer.rs index 50083d5d3..1d5046b38 100644 --- a/crates/ui-egui/src/panels/mixer.rs +++ b/crates/ui-egui/src/panels/mixer.rs @@ -10,7 +10,8 @@ //! Automation ids: `mixer..|send.|meter|name>`, popup entries below them (`mixer.A1.mode.Touch`…), the //! effects disclosure `mixer.showEffects`, and the footer transport `mixer.transport.`. -//! The Clip Mixer uses `clipMixer.A1.`. +//! The Clip Mixer uses `clipMixer.A1.`, and `clipMixer.scroll` +//! when its strips, narrowed to fit the panel, still do not fit. //! //! Strips feeding a 5.1 submix or Mix show the 5.1 panner instead of the pan knob: a puck in the //! listening square (`mixer.A1.pan51`, drag; double-click: front centre) with Center % and LFE @@ -41,6 +42,8 @@ const TAPER: [(f64, f32); 10] = /// Labels on the fader scale. const SCALE: [f64; 10] = [15.0, 6.0, 0.0, -6.0, -12.0, -20.0, -30.0, -40.0, -60.0, -96.0]; const STRIP_W: f32 = 98.0; +/// Narrowest Clip Mixer strip: the M / S / keyframe buttons side by side. +const CLIP_STRIP_MIN_W: f32 = 66.0; const SLOT_H: f32 = 15.0; const RED: Color32 = Color32::from_rgb(0xd8, 0x50, 0x3f); const MUTE_ON: Color32 = Color32::from_rgb(0x2d, 0x9d, 0x78); @@ -287,15 +290,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let x0 = body.min.x + 18.0; let total = strips.len() as f32 * (STRIP_W + 2.0); let avail = body.max.x - x0 - 2.0; - let max_off = (total - avail).max(0.0); - let off_id = egui::Id::new("mixer-hscroll"); - let mut off: f32 = ui.data(|d| d.get_temp(off_id)).unwrap_or(0.0); - if ui.rect_contains_pointer(body) { - let sd = ui.input(|i| i.smooth_scroll_delta); - off -= sd.x + if ui.input(|i| i.modifiers.shift) { sd.y } else { 0.0 }; - } - off = off.clamp(0.0, max_off); - let sb_h = if max_off > 0.0 { 12.0 } else { 0.0 }; + let (off, sb_h) = strip_scroll(app, ui, body, x0, total, avail, "mixer"); // strips never get shorter than their controls plus a usable fader: scroll vertically instead let need = 4.0 + if app.ui.mixer_fx_open { 10.0 * SLOT_H + 10.0 } else { 0.0 } + 112.0 + 26.0 + 24.0 + 6.0 + 170.0 + 42.0; let clip = Rect::from_min_max(pos2(x0, body.min.y), pos2(body.max.x - 8.0, body.max.y - sb_h)); @@ -320,20 +315,6 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("mixer.vscroll", knob, "Scroll strips vertically"); } ui.data_mut(|d| d.insert_temp(voff_id, voff)); - if max_off > 0.0 { - let bar = Rect::from_min_max(pos2(x0, body.max.y - 10.0), pos2(body.max.x - 4.0, body.max.y - 2.0)); - ui.painter().rect_filled(bar, 4.0, t.field_bg); - let kw = (bar.width() * avail / total).max(20.0); - let kx = bar.min.x + (bar.width() - kw) * off / max_off; - let knob = Rect::from_min_size(pos2(kx, bar.min.y), vec2(kw, bar.height())); - let kr = ui.interact(knob, egui::Id::new("mixer-hscroll-knob"), Sense::drag()); - ui.painter().rect_filled(knob, 4.0, if kr.dragged() { t.accent } else { t.fader_knob }); - if kr.dragged() { - off = (off + kr.drag_delta().x * total / bar.width()).clamp(0.0, max_off); - } - app.auto.add("mixer.scroll", knob, "Scroll strips"); - } - ui.data_mut(|d| d.insert_temp(off_id, off)); let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(clip)); cui.set_clip_rect(clip.intersect(ui.clip_rect())); for (i, id) in strips.iter().enumerate() { @@ -356,6 +337,36 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { run_actions(app, &ui.ctx().clone(), acts); } +/// Horizontal scrolling of a row of strips `total` wide shown in `avail` from `x0`: the wheel (and +/// Shift + wheel) over `body`, and a knob along its bottom edge (`.scroll`). Returns the offset +/// and the height the scrollbar takes (0 when everything fits). +fn strip_scroll(app: &mut FilmcraftApp, ui: &egui::Ui, body: Rect, x0: f32, total: f32, avail: f32, key: &str) -> (f32, f32) { + let t = app.tokens; + let max_off = (total - avail).max(0.0); + let off_id = egui::Id::new(format!("{key}-hscroll")); + let mut off: f32 = ui.data(|d| d.get_temp(off_id)).unwrap_or(0.0); + if ui.rect_contains_pointer(body) { + let sd = ui.input(|i| i.smooth_scroll_delta); + off -= sd.x + if ui.input(|i| i.modifiers.shift) { sd.y } else { 0.0 }; + } + off = off.clamp(0.0, max_off); + if max_off > 0.0 { + let bar = Rect::from_min_max(pos2(x0, body.max.y - 10.0), pos2(body.max.x - 4.0, body.max.y - 2.0)); + ui.painter().rect_filled(bar, 4.0, t.field_bg); + let kw = (bar.width() * avail / total).max(20.0); + let kx = bar.min.x + (bar.width() - kw) * off / max_off; + let knob = Rect::from_min_size(pos2(kx, bar.min.y), vec2(kw, bar.height())); + let kr = ui.interact(knob, egui::Id::new(format!("{key}-hscroll-knob")), Sense::drag()); + ui.painter().rect_filled(knob, 4.0, if kr.dragged() { t.accent } else { t.fader_knob }); + if kr.dragged() { + off = (off + kr.drag_delta().x * total / bar.width()).clamp(0.0, max_off); + } + app.auto.add(&format!("{key}.scroll"), knob, "Scroll strips"); + } + ui.data_mut(|d| d.insert_temp(off_id, off)); + (off, if max_off > 0.0 { 12.0 } else { 0.0 }) +} + fn run_actions(app: &mut FilmcraftApp, ctx: &egui::Context, acts: Vec<(String, Value)>) { for (id, p) in acts { let r = if filmcraft_engine::find_command(&id).is_some() { @@ -903,12 +914,23 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut acts: Vec<(String, Value)> = Vec::new(); let kf_id = egui::Id::new("clip-mixer-keyframes"); let mut write_kf: HashMap = ui.data(|d| d.get_temp(kf_id)).unwrap_or_default(); + // Like Premiere, the strips narrow so that every track fits the panel; below the narrowest + // usable strip the row scrolls instead, so no track is out of reach (#1922). + let x0 = rect.min.x + 6.0; + let avail = rect.max.x - x0 - 2.0; + let n = seq.audio_tracks.len(); + let sw = (avail / n.max(1) as f32 - 2.0).clamp(CLIP_STRIP_MIN_W, STRIP_W); + let (off, sb_h) = strip_scroll(app, ui, rect, x0, n as f32 * (sw + 2.0), avail, "clipMixer"); + let view = Rect::from_min_max(pos2(x0, rect.min.y), pos2(rect.max.x - 2.0, rect.max.y - sb_h)); + let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(view)); + cui.set_clip_rect(view.intersect(ui.clip_rect())); + let ui = &mut cui; for (i, tr) in seq.audio_tracks.iter().enumerate() { - let x = rect.min.x + 6.0 + i as f32 * (STRIP_W + 2.0); - if x + STRIP_W > rect.max.x { - break; + let x = x0 + i as f32 * (sw + 2.0) - off; + if x + sw < view.min.x || x > view.max.x { + continue; } - let sr = Rect::from_min_max(pos2(x, rect.min.y + 4.0), pos2(x + STRIP_W, rect.max.y - 4.0)); + let sr = Rect::from_min_max(pos2(x, rect.min.y + 4.0), pos2(x + sw, view.max.y - 4.0)); let label = format!("A{}", i + 1); let ap = format!("clipMixer.{label}"); ui.painter().rect_filled(sr, 3.0, t.control_surface); @@ -974,7 +996,8 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }); // M S ◇ let y = sr.min.y + 72.0; - let b = |k: f32| Rect::from_min_size(pos2(sr.center().x - 33.0 + k * 24.0, y), vec2(18.0, 16.0)); + let step = ((sr.width() - 6.0 - 18.0) / 2.0).min(24.0); + let b = |k: f32| Rect::from_min_size(pos2(sr.center().x - step - 9.0 + k * step, y), vec2(18.0, 16.0)); let mr = crate::widgets::letter_toggle(ui, b(0.0), "M", tr.muted, MUTE_ON, &t, egui::Id::new((&ap, "M"))); app.auto.add(&format!("{ap}.mute"), b(0.0), "Mute Track"); if mr.clicked() { @@ -1038,8 +1061,9 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if vresp.drag_stopped() { release(&mut acts, "volume"); } - small_text(ui, pos2(sr.min.x + 6.0, sr.max.y - 10.0), Align2::LEFT_CENTER, &label, 12.0, t.text); - small_text(ui, pos2(sr.min.x + 30.0, sr.max.y - 10.0), Align2::LEFT_CENTER, &tr.name, 12.0, t.text); + let name = ui.painter().with_clip_rect(sr.intersect(ui.clip_rect())); + name.text(pos2(sr.min.x + 6.0, sr.max.y - 10.0), Align2::LEFT_CENTER, &label, Tokens::ui(12.0), t.text); + name.text(pos2(sr.min.x + 30.0, sr.max.y - 10.0), Align2::LEFT_CENTER, &tr.name, Tokens::ui(12.0), t.text); } ui.data_mut(|d| d.insert_temp(kf_id, write_kf)); run_actions(app, &ui.ctx().clone(), acts); diff --git a/crates/ui-egui/tests/mixer_ui.rs b/crates/ui-egui/tests/mixer_ui.rs index 573de8e31..603578392 100644 --- a/crates/ui-egui/tests/mixer_ui.rs +++ b/crates/ui-egui/tests/mixer_ui.rs @@ -276,6 +276,86 @@ fn clip_mixer_strips_follow_the_playhead() { d.snapshot("mixer-clip-mixer", Some("clipMixer.")); } +/// Horizontal extent `[left, right]` of each Clip Mixer strip `A1 … An` (its controls), `None` for +/// a strip that is not drawn. +fn clip_strip_spans(d: &mut Driver, n: usize) -> Vec> { + let v = d.ok("ui.elements", json!({"prefix": "clipMixer.A"})); + let elements = v.as_array().unwrap(); + (1..=n) + .map(|i| { + let prefix = format!("clipMixer.A{i}."); + elements + .iter() + .filter(|e| e["id"].as_str().is_some_and(|id| id.starts_with(&prefix))) + .map(|e| { + let x = e["rect"][0].as_f64().unwrap() as f32; + [x, x + e["rect"][2].as_f64().unwrap() as f32] + }) + .reduce(|a, b| [a[0].min(b[0]), a[1].max(b[1])]) + }) + .collect() +} + +/// #1922: like Premiere, the Audio Clip Mixer narrows its strips so that every track fits the +/// panel, and below the narrowest usable strip it scrolls: no track is ever out of reach. +#[test] +fn clip_mixer_strips_narrow_to_fit_the_panel() { + let mut d = Driver::demo(); + d.ok("ui.panel.show", json!({"panel": "AudioClipMixer"})); + d.frames(3); + let panel = d.element("panel.AudioClipMixer").expect("clip mixer panel"); + let (left, right) = (panel[0], panel[0] + panel[2]); + let have = d.exec("sequence.inspect", json!({}))["audio"].as_array().unwrap().len(); + let n = 8; + d.exec("sequence.addTracks", json!({"video": 0, "audio": n - have})); + d.frames(3); + assert!(n as f32 * 100.0 > panel[2], "{n} full-width strips would fit the {} px panel", panel[2]); + let spans = clip_strip_spans(&mut d, n); + for (i, s) in spans.iter().enumerate() { + let s = s.unwrap_or_else(|| panic!("A{} is not shown in the {} px panel: {spans:?}", i + 1, panel[2])); + assert!(s[0] >= left && s[1] <= right, "A{} {s:?} outside the panel {left}…{right}", i + 1); + } + for w in spans.windows(2) { + let (a, b) = (w[0].unwrap(), w[1].unwrap()); + assert!(a[1] < b[0], "strips overlap: {a:?} {b:?}"); + } + // the narrowed strip still lays its buttons out side by side + for i in 1..=n { + let [m, s, k] = ["mute", "solo", "keyframe"].map(|c| d.element(&format!("clipMixer.A{i}.{c}")).unwrap()); + assert!(m[0] + m[2] <= s[0] && s[0] + s[2] <= k[0], "A{i} buttons overlap: {m:?} {s:?} {k:?}"); + } + assert!(d.element("clipMixer.scroll").is_none(), "no scrollbar while every strip fits"); + d.snapshot("mixer-clip-mixer-narrow", Some("clipMixer.")); + + // too many tracks even for the narrowest strips: the row scrolls to the last one + let n = 30; + d.exec("sequence.addTracks", json!({"video": 0, "audio": 22})); + d.frames(3); + let spans = clip_strip_spans(&mut d, n); + assert!(spans[n - 1].is_none(), "A{n} cannot fit before scrolling: {spans:?}"); + let shown: Vec<[f32; 2]> = spans.iter().flatten().copied().collect(); + for w in shown.windows(2) { + assert!(w[0][1] < w[1][0], "strips overlap: {:?} {:?}", w[0], w[1]); + } + let knob = d.element("clipMixer.scroll").expect("a scrollbar when the strips cannot all fit"); + let mut p = egui::pos2(knob[0] + knob[2] / 2.0, knob[1] + knob[3] / 2.0); + let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e); + push(&mut d, egui::Event::PointerMoved(p)); + d.frames(1); + push(&mut d, egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + d.frames(1); + for _ in 0..6 { + p.x += panel[2] / 4.0; + push(&mut d, egui::Event::PointerMoved(p)); + d.frames(1); + } + push(&mut d, egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + d.frames(2); + let last = clip_strip_spans(&mut d, n)[n - 1].unwrap_or_else(|| panic!("A{n} is still out of reach after scrolling")); + assert!(last[0] >= left && last[1] <= right, "A{n} {last:?} outside the panel {left}…{right}"); + d.snapshot("mixer-clip-mixer-scrolled", Some("clipMixer.")); +} + #[test] fn clip_mixer_db_value_can_be_typed() { let mut d = Driver::demo();