From 1720fc7ea1aa73b9de6d2ca4a80c151848218f34 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:45:39 +0100 Subject: [PATCH 1/2] Change clip gain with Ctrl+Shift+Up/Down and a fader on the clip There was no way to change a clip's gain from the UI (#2): clip gain is stored, drawn and settable through `clip.gain`, but no menu item, key or gesture reached it. This adds the two ways Pro Tools offers. Keyboard: Pro Tools nudges the selected clips' gain with Control+Shift+Up/Down, 1 dB a step by default. Two new commands, Nudge Clip Gain Up and Down, do the same through `clip.gain`'s relative `delta_db`, so its -144..+36 dB range holds and each nudge is one undo step. Control+Up/Down extended the edit selection whether or not Shift was held, which swallowed the new keys; it now leaves the Shift variants to the command registry. Fader: in Pro Tools, Clip Gain Info puts a fader icon and the gain value in the bottom-left of each clip, and pressing the icon pops up a small fader. With View > Clip > Clip Gain Info on, or once a clip has gain, audio clips now show the icon and the value, and dragging the icon moves the clip's gain from -144 to +36 dB (Cmd for fine moves), one undo step per drag. The icon is drawn in code. The fader follows the pointer from where it was pressed rather than adding up per-frame deltas, which lose the first few pixels while egui decides between a click and a drag and leave the fader out of step with the pointer once it hits an end (it would stick, then jump). Clip Gain Info no longer depends on Clip Gain Line being shown, since the two are separate views in Pro Tools, and the value moved from the clip's bottom-right to beside the icon. Dragging the icon of a clip when nothing is selected selects that clip first, because `clip.gain` needs a selection. --- crates/engine/src/cmd/clip.rs | 22 +++++++ crates/ui-egui/src/edit_window.rs | 76 +++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/shortcuts.rs | 2 +- crates/ui-egui/src/widgets.rs | 27 +++++++++ crates/ui-egui/tests/clip_gain_fader.rs | 53 +++++++++++++++++ crates/ui-egui/tests/shortcuts.rs | 22 +++++++ 7 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/tests/clip_gain_fader.rs create mode 100644 crates/ui-egui/tests/shortcuts.rs diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 8b00ed19..0c566d70 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -68,6 +68,8 @@ pub fn specs() -> Vec { cmd!("clip.loop", "Loop...", ["Clip"], Some("Cmd+Alt+L"), "{clips?, count?: n, length?: samples}", has_selection, loop_clips), cmd!("clip.rename", "Rename...", ["Clip"], Some("Cmd+Alt+Shift+R"), "{clip?, name}", has_selection, rename), cmd!("clip.gain", "Clip Gain", [], None, "{clips?, db | delta_db}", has_selection, gain), + cmd!("clip.gain_nudge_up", "Nudge Clip Gain Up", [], Some("Ctrl+Shift+Up"), "{clips?}", has_selection, |e, p| nudge_gain(e, p, 1.0)), + cmd!("clip.gain_nudge_down", "Nudge Clip Gain Down", [], Some("Ctrl+Shift+Down"), "{clips?}", has_selection, |e, p| nudge_gain(e, p, -1.0)), cmd!("clip.gain_render", "Render", ["Clip", "Clip Gain"], None, "{clips?}", has_selection, |e, p| { let ids = clip_ids_param(e, p); let mut n = 0; @@ -326,6 +328,11 @@ fn gain(e: &mut Engine, p: &Value) -> Result { Ok(json!({"clips": ids.len()})) } +fn nudge_gain(e: &mut Engine, p: &Value, db: f64) -> Result { + let clips = clip_ids_param(e, p); + gain(e, &json!({"clips": clips, "delta_db": db})) +} + fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result { let ids = clip_ids_param(e, p); let s = e.session(); @@ -365,4 +372,19 @@ mod tests { e.execute("clip.remove_warp", &json!({"clips": [cid]})).unwrap(); assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0); } + + #[test] + fn gain_nudges_one_db_at_a_time_up_to_the_ceiling() { + let mut e = crate::demo::demo_engine(); + let kick = e.session().track_by_name("Kick").unwrap().clips()[0].id; + let gain = |e: &crate::Engine| e.session().find_clip(kick).unwrap().1.gain_db; + e.execute("edit.select", &json!({"clips": [kick.0]})).unwrap(); + e.execute("clip.gain_nudge_up", &json!({})).unwrap(); + e.execute("clip.gain_nudge_up", &json!({})).unwrap(); + e.execute("clip.gain_nudge_down", &json!({})).unwrap(); + assert_eq!(gain(&e), 1.0); + e.execute("clip.gain", &json!({"db": 35.5})).unwrap(); + e.execute("clip.gain_nudge_up", &json!({})).unwrap(); + assert_eq!(gain(&e), 36.0); + } } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 17a54b08..efd0c51e 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1219,9 +1219,12 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track pts.push(pos2(x1, gy(clip.gain_db + clip.gain_env.last().map_or(0.0, |p| p.1)))); painter.add(Shape::line(pts, Stroke::new(1.0, col))); } - if s.edit.flag("view.clip.gain_info") || clip.gain_db.abs() > 0.01 { - painter.text(pos2(r.max.x - 4.0, body_r.max.y - 8.0), Align2::RIGHT_CENTER, format!("{:+.1} dB", clip.gain_db), regular(9.5), col); - } + } + if !dim && let Some(icon) = clip_gain_icon(s, tl, lane, clip) { + let col = Color32::from_rgba_unmultiplied(255, 255, 255, 200); + painter.line_segment([pos2(icon.center().x, icon.min.y), pos2(icon.center().x, icon.max.y)], Stroke::new(1.0, col)); + painter.rect_filled(Rect::from_center_size(pos2(icon.center().x, icon.center().y + 2.0), vec2(icon.width(), 3.0)), 1.0, col); + painter.text(pos2(icon.max.x + 3.0, icon.center().y), Align2::LEFT_CENTER, format!("{:+.1} dB", clip.gain_db), regular(9.5), col); } // Fades. let fade_col = Color32::from_rgba_unmultiplied(255, 255, 255, 90); @@ -1369,6 +1372,31 @@ fn clip_at(track: &Track, at: Samples) -> Option<&Clip> { track.clips().iter().rev().find(|c| c.range().contains(at)) } +pub const CLIP_FADER_TRAVEL: f32 = 200.0; + +/// Bottom-left of an audio clip's visible part, when Clip Gain Info is on or the clip has gain and it fits. +pub fn clip_gain_icon(s: &Session, tl: Rect, lane: Rect, clip: &Clip) -> Option { + let x0 = x_of(s, tl, clip.start).max(lane.min.x); + let x1 = x_of(s, tl, clip.end()).min(lane.max.x); + let shown = matches!(clip.content, ClipContent::Audio { .. }) && (s.edit.flag("view.clip.gain_info") || clip.gain_db.abs() > 0.01); + (shown && x1 - x0 >= 48.0 && lane.height() >= 30.0).then(|| Rect::from_min_size(pos2(x0 + 4.0, lane.max.y - 16.0), vec2(9.0, 13.0))) +} + +fn clip_gain_popup(ctx: &egui::Context, fader_bottom: Pos2, pos: f32) { + let t = Tokens::DARK; + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("clip_gain_fader"))); + let track = Rect::from_min_max(pos2(fader_bottom.x - 2.0, fader_bottom.y - CLIP_FADER_TRAVEL), pos2(fader_bottom.x + 2.0, fader_bottom.y)); + let panel = Rect::from_min_max(pos2(track.min.x - 22.0, track.min.y - 26.0), pos2(track.max.x + 22.0, track.max.y + 8.0)); + p.rect(panel, 3.0, Color32::from_rgb(38, 38, 40), Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside); + p.rect_filled(track, 2.0, t.fader_track); + let zero = track.max.y - CLIP_FADER_TRAVEL * crate::widgets::clip_gain_to_pos(0.0); + p.line_segment([pos2(track.min.x - 8.0, zero), pos2(track.min.x - 2.0, zero)], Stroke::new(1.0, Color32::from_rgb(150, 150, 150))); + let cap = Rect::from_center_size(pos2(track.center().x, track.max.y - CLIP_FADER_TRAVEL * pos), vec2(22.0, 9.0)); + p.rect(cap, 2.0, Color32::from_rgb(210, 210, 210), Stroke::new(1.0, Color32::from_rgb(20, 20, 20)), StrokeKind::Inside); + let db = crate::widgets::clip_gain_from_pos(pos); + p.text(pos2(panel.center().x, panel.min.y + 12.0), Align2::CENTER_CENTER, format!("{db:+.1}"), regular(10.5), t.counter_text); +} + fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) { let id = ui.id().with(("lane", track.id.0)); let resp = ui.interact(lane, id, Sense::click_and_drag()); @@ -1451,7 +1479,27 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, if fade_corner.is_some() && resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); } - if resp.drag_started() + let gain_icon_at = |q: Pos2| { + let c = clip_at(track, sample_at(&s, tl, q.x).max(0)).filter(|_| auto_view.is_none())?; + clip_gain_icon(&s, tl, lane, c).filter(|r| r.contains(q)).map(|_| c.clone()) + }; + if resp.hovered() && gain_icon_at(p).is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + let gain_drag = if resp.drag_started() { gain_icon_at(press) } else { None }; + if let Some(c) = gain_drag { + // `clip.gain` refuses to run with nothing selected. + if s.edit.selected_tracks.is_empty() && s.edit.selected_clips.is_empty() { + let _ = app.engine.execute("edit.select", &json!({"clips": [c.id.0]})); + } + let pos = crate::widgets::clip_gain_to_pos(c.gain_db); + let screen = ui.ctx().content_rect(); + let y = (press.y + CLIP_FADER_TRAVEL * pos).max(screen.min.y + CLIP_FADER_TRAVEL + 30.0).min(screen.max.y - 10.0); + let fader_bottom = pos2(press.x.max(screen.min.x + 26.0), y); + let fine = mods.command || mods.ctrl; + app.gesture = Some(Gesture::ClipGain { clip: c.id, track: track.id, pos, anchor: (press.y, pos), fine, fader_bottom }); + } else if resp.drag_started() && let (Some(fade_in), Some(c)) = (fade_corner, hit.as_ref()) { app.gesture = Some(Gesture::Fade { clip: c.id, track: track.id, fade_in, to: at }); @@ -1501,6 +1549,19 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, } Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at), Some(Gesture::Fade { to, .. }) => *to = at, + Some(Gesture::ClipGain { clip, pos, anchor, fine, .. }) => { + // The fader follows the pointer from where it was pressed; ⌘ re-anchors for fine moves. + if *fine != (mods.command || mods.ctrl) { + *fine = !*fine; + *anchor = (p.y, *pos); + } + let scale = if *fine { 0.1 } else { 1.0 }; + *pos = (anchor.1 + (anchor.0 - p.y) / CLIP_FADER_TRAVEL * scale).clamp(0.0, 1.0); + let db = crate::widgets::clip_gain_from_pos(*pos); + if s.find_clip(*clip).is_some_and(|(_, c)| (c.gain_db - db).abs() > 0.001) { + let _ = app.engine.execute_merged("clip.gain", &json!({"clips": [clip.0], "db": db}), &format!("clip_gain:{}", clip.0)); + } + } Some(Gesture::Scrub { last }) => { *last = at; let _ = app.engine.execute("transport.locate", &json!({"at": at})); @@ -1531,6 +1592,13 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, let _ = app.engine.execute("edit.select", &json!({"tracks": ids, "start": r.start, "end": r.end})); } } + if let Some(Gesture::ClipGain { track: t, pos, fader_bottom, .. }) = &app.gesture + && *t == track.id + && resp.dragged() + { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + clip_gain_popup(ui.ctx(), *fader_bottom, *pos); + } if resp.drag_stopped() { let g = app.gesture.take(); match g { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 18982a07..080c651e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -182,6 +182,7 @@ pub enum Gesture { Fade { clip: soundcraft_model::ClipId, track: TrackId, fade_in: bool, to: Samples }, Scrub { last: Samples }, Fader { track: TrackId, start_db: f32 }, + ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 }, Pencil { track: TrackId, points: Vec<(Samples, f32)> }, ZoomBox { start: f32 }, } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index cc35c907..c4123998 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -154,7 +154,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool { } true } - Key::ArrowUp | Key::ArrowDown if m.ctrl => { + Key::ArrowUp | Key::ArrowDown if m.ctrl && !m.shift => { let id = if key == Key::ArrowUp { "edit.extend_selection_up" } else { "edit.extend_selection_down" }; let _ = app.run(id, json!({})); true diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4566a10c..cfc5fd34 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -172,6 +172,17 @@ pub fn meter_pos(db: f32) -> f32 { } } +/// Clip gain fader taper: the top three quarters span -36..+36 dB evenly, the rest compresses to -144. +pub fn clip_gain_from_pos(pos: f32) -> f32 { + let p = if pos.is_finite() { pos.clamp(0.0, 1.0) } else { 0.0 }; + if p >= 0.25 { -36.0 + (p - 0.25) / 0.75 * 72.0 } else { -36.0 - (1.0 - p / 0.25).powf(1.5) * 108.0 } +} + +pub fn clip_gain_to_pos(db: f32) -> f32 { + let db = if db.is_finite() { db.clamp(-144.0, 36.0) } else { -144.0 }; + if db >= -36.0 { 0.25 + (db + 36.0) / 72.0 * 0.75 } else { (1.0 - ((-36.0 - db) / 108.0).powf(1.0 / 1.5)) * 0.25 } +} + /// A fader: returns the new dB value when dragged. `db` in -144..12. pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id) -> Option { let t = Tokens::DARK; @@ -344,3 +355,19 @@ pub fn multi_meter(ui: &Ui, r: Rect, levels: &[f32], labels: &[String], clip: bo } } } + +#[cfg(test)] +mod tests { + use super::{clip_gain_from_pos, clip_gain_to_pos}; + + #[test] + fn clip_gain_taper_spans_minus_144_to_plus_36_and_round_trips() { + assert_eq!(clip_gain_from_pos(0.0), -144.0); + assert_eq!(clip_gain_from_pos(1.0), 36.0); + assert_eq!(clip_gain_from_pos(f32::NAN), -144.0); + assert_eq!(clip_gain_to_pos(f32::INFINITY), 0.0); + for db in [-144.0, -90.0, -36.0, -12.5, 0.0, 6.0, 36.0] { + assert!((clip_gain_from_pos(clip_gain_to_pos(db)) - db).abs() < 0.01, "{db}"); + } + } +} diff --git a/crates/ui-egui/tests/clip_gain_fader.rs b/crates/ui-egui/tests/clip_gain_fader.rs new file mode 100644 index 00000000..2d456b3e --- /dev/null +++ b/crates/ui-egui/tests/clip_gain_fader.rs @@ -0,0 +1,53 @@ +use egui::{Event, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, edit_window, widgets}; + +fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec) { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } +} + +#[test] +fn the_clip_gain_icon_drags_with_the_pointer_in_one_undo_step() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.engine.execute("view.clip_gain_info", &json!({"value": true})).unwrap(); + let ctx = egui::Context::default(); + frame(&ctx, &mut app, vec![]); + frame(&ctx, &mut app, vec![]); + let s = app.engine.session().clone(); + let kick = s.track_by_name("Kick").unwrap(); + let clip = kick.clips()[0].clone(); + let [x0, y0, x1, y1] = app.edit_layout.timeline; + let tl = Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); + let row = app.edit_layout.rows.iter().find(|(id, _)| *id == kick.id.0).unwrap().1; + let lane = Rect::from_min_max(pos2(tl.min.x, row[1]), pos2(tl.max.x, row[1] + kick.height.points())); + let at = edit_window::clip_gain_icon(&s, tl, lane, &clip).unwrap().center(); + let gain = |app: &SoundApp| app.engine.session().find_clip(clip.id).unwrap().1.gain_db; + let up = |dy: f32| widgets::clip_gain_from_pos(widgets::clip_gain_to_pos(0.0) + dy / edit_window::CLIP_FADER_TRAVEL); + + frame(&ctx, &mut app, vec![Event::PointerMoved(at)]); + frame(&ctx, &mut app, vec![button(at, true)]); + for dy in [3.0, 6.0, 10.0, 40.0] { + frame(&ctx, &mut app, vec![Event::PointerMoved(at - vec2(0.0, dy))]); + } + assert!((gain(&app) - up(40.0)).abs() < 0.01, "{} vs {}", gain(&app), up(40.0)); + // Past the top end and back: the fader waits at +36 dB until the pointer returns. + for dy in [300.0, 120.0, 20.0] { + frame(&ctx, &mut app, vec![Event::PointerMoved(at - vec2(0.0, dy))]); + } + assert!((gain(&app) - up(20.0)).abs() < 0.01, "{} vs {}", gain(&app), up(20.0)); + frame(&ctx, &mut app, vec![button(at - vec2(0.0, 20.0), false)]); + frame(&ctx, &mut app, vec![]); + + app.engine.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(gain(&app), 0.0); +} diff --git a/crates/ui-egui/tests/shortcuts.rs b/crates/ui-egui/tests/shortcuts.rs new file mode 100644 index 00000000..0844e66a --- /dev/null +++ b/crates/ui-egui/tests/shortcuts.rs @@ -0,0 +1,22 @@ +use egui::{Key, Modifiers}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, shortcuts}; + +fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) { + let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear(); +} + +#[test] +fn ctrl_shift_arrows_nudge_the_selected_clip_gain() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id; + app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap(); + let ctx = egui::Context::default(); + ctx.set_os(egui::os::OperatingSystem::Mac); + let ctrl_shift = Modifiers { ctrl: true, shift: true, ..Modifiers::NONE }; + press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); + press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); + press(&ctx, &mut app, Key::ArrowDown, ctrl_shift); + assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0); +} From ff2bd2d3f8e7b267520418eee945dd88ca204cb6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 20:50:08 -0400 Subject: [PATCH 2/2] Clip gain fader popup: use the current theme tokens Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/edit_window.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 5f9ba273..0ea03a22 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1397,7 +1397,7 @@ pub fn clip_gain_icon(s: &Session, tl: Rect, lane: Rect, clip: &Clip) -> Option< } fn clip_gain_popup(ctx: &egui::Context, fader_bottom: Pos2, pos: f32) { - let t = Tokens::DARK; + let t = Tokens::current(); let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("clip_gain_fader"))); let track = Rect::from_min_max(pos2(fader_bottom.x - 2.0, fader_bottom.y - CLIP_FADER_TRAVEL), pos2(fader_bottom.x + 2.0, fader_bottom.y)); let panel = Rect::from_min_max(pos2(track.min.x - 22.0, track.min.y - 26.0), pos2(track.max.x + 22.0, track.max.y + 8.0));