From d5e6fa32efe6e8fc7b1797ca03739985072ed9c5 Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:40:05 +0800 Subject: [PATCH] Enable dragging existing fades from their curve endpoints --- crates/ui-egui/src/edit_window.rs | 175 +++++++++++++++++++++++++++--- docs/ui-parity.md | 5 +- 2 files changed, 162 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 3bd269b..3f4247a 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1146,6 +1146,10 @@ fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect) { } } +fn clip_name_height(s: &Session, lane: Rect) -> f32 { + if lane.height() >= 40.0 && s.edit.flag("view.clip.name") { 13.0 } else { 0.0 } +} + fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, clip: &Clip, tl: Rect, lane: Rect, dim: bool) { let x0 = x_of(s, tl, clip.start); let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0); @@ -1163,7 +1167,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } let body = if s.edit.flag("view.clip.transparency") { body.gamma_multiply(0.55) } else { body }; painter.rect(r, CornerRadius::same(2), body, Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside); - let name_h = if lane.height() >= 40.0 && s.edit.flag("view.clip.name") { 13.0 } else { 0.0 }; + let name_h = clip_name_height(s, lane); if name_h > 0.0 { let nb = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + name_h)); painter.rect_filled(nb, CornerRadius { nw: 2, ne: 2, sw: 0, se: 0 }, if clip.muted { Color32::from_rgb(70, 70, 70) } else { bar }); @@ -1439,6 +1443,26 @@ fn clip_at(track: &Track, at: Samples) -> Option<&Clip> { track.clips().iter().rev().find(|c| c.range().contains(at)) } +/// The unity-gain end of each existing fade, at the top of the clip body. +fn fade_handle_at(s: &Session, tl: Rect, lane: Rect, clip: &Clip, p: Pos2) -> Option { + if clip.length <= 0 || clip.end() <= clip.start { + return None; + } + let y = lane.min.y + 1.0 + clip_name_height(s, lane); + [(true, clip.fade_in), (false, clip.fade_out)] + .into_iter() + .filter(|(_, fade)| fade.len > 0) + .filter_map(|(fade_in, fade)| { + let len = fade.len.min(clip.length); + let at = if fade_in { clip.start.saturating_add(len).min(clip.end()) } else { clip.end().saturating_sub(len).max(clip.start) }; + let head = pos2(x_of(s, tl, at), y); + let delta = p - head; + (lane.contains(head) && delta.x.abs() <= 8.0 && delta.y.abs() <= 8.0).then_some((fade_in, delta.length_sq())) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(fade_in, _)| fade_in) +} + 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. @@ -1499,7 +1523,21 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, let at = sample_at(&s, tl, p.x).max(0); let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(p) } else { p }; let at_origin = sample_at(&s, tl, origin.x).max(0); - let hit = clip_at(track, at_origin).cloned(); + let auto_view = AutoParam::parse(&track.view); + let body_hit = clip_at(track, at_origin); + // Check fade endpoints before clip bodies: a full-length fade can end exactly + // at the next clip's start, where `clip_at` would otherwise choose that neighbour. + let fade_handle = if tool == Tool::Smart && auto_view.is_none() { + track + .clips() + .iter() + .rev() + .filter(|c| body_hit.is_none_or(|front| front.id == c.id || front.start == c.end())) + .find_map(|c| fade_handle_at(&s, tl, lane, c, origin).map(|side| (c, side))) + } else { + None + }; + let hit = fade_handle.map(|(c, _)| c).or(body_hit).cloned(); let upper = origin.y < lane.center().y; let near_edge = |c: &Clip| -> Option { let xs = x_of(&s, tl, c.start); @@ -1540,21 +1578,22 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, }; ui.ctx().set_cursor_icon(icon); } - let auto_view = AutoParam::parse(&track.view); let fade_corner = if tool == Tool::Smart { - hit.as_ref().and_then(|c| { - let xs = x_of(&s, tl, c.start); - let xe = x_of(&s, tl, c.end()); - let top = origin.y < lane.min.y + lane.height() * 0.28; - if !top { - None - } else if (origin.x - xs) > 0.0 && (origin.x - xs) < 14.0 { - Some(true) - } else if (xe - origin.x) > 0.0 && (xe - origin.x) < 14.0 { - Some(false) - } else { - None - } + fade_handle.map(|(_, side)| side).or_else(|| { + hit.as_ref().and_then(|c| { + let xs = x_of(&s, tl, c.start); + let xe = x_of(&s, tl, c.end()); + let top = origin.y < lane.min.y + lane.height() * 0.28; + if !top { + None + } else if (origin.x - xs) > 0.0 && (origin.x - xs) < 14.0 { + Some(true) + } else if (xe - origin.x) > 0.0 && (xe - origin.x) < 14.0 { + Some(false) + } else { + None + } + }) }) } else { None @@ -2027,6 +2066,9 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { if let Some(at) = scroll_to { let _ = app.engine.execute("view.scroll", &json!({"to": at})); } + if matches!(app.gesture, Some(Gesture::Fade { .. })) { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); + } } #[cfg(test)] @@ -2508,6 +2550,107 @@ mod tests { } } + #[test] + fn existing_fades_drag_from_their_upper_curve_endpoints() { + for fade_in in [true, false] { + for show_name in [true, false] { + for full_length in [false, true] { + let mut h = smart_demo(); + h.state_mut().engine.execute("view.clip_name", &json!({"value": show_name})).unwrap(); + h.step(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let len = if full_length { clip.length } else { clip.length * 2 / 3 }; + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + let fade = soundcraft_model::Fade { len, shape: soundcraft_model::FadeShape::SCurve }; + if fade_in { + c.fade_in = fade; + } else { + c.fade_out = fade; + } + h.step(); + let s = h.state().engine.session(); + let (xs, xe) = (x_of(s, tl, clip.start), x_of(s, tl, clip.end())); + let curves = fade_curves(&h, Rect::from_min_max(pos2(xs, lane.min.y), pos2(xe, lane.max.y))); + assert_eq!(curves.len(), 1); + let curve = &curves[0].0; + // Take the handle from the painted curve, rather than duplicating hit geometry. + let head = *if fade_in { curve.points.last() } else { curve.points.first() }.unwrap(); + let press = head + vec2(3.0, 2.0); + if fade_in && full_length { + let (tid, _) = s.find_clip(clip.id).unwrap(); + assert_ne!( + clip_at(s.track(tid).unwrap(), sample_at(s, tl, press.x)).unwrap().id, + clip.id, + "the pointer should be over the adjacent clip" + ); + } + let before = s.clone(); + let undo_len = h.state().engine.undo_history().len(); + h.hover_at(press); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe); + let side = if fade_in { "in" } else { "out" }; + if show_name && !full_length { + fade_shot(&mut h, &format!("fade-{side}-endpoint-hover")); + } + h.drag_at(press); + let to = head + vec2(if fade_in { -35.0 } else { 35.0 }, 0.0); + h.hover_at(to); + h.step(); + assert!(matches!(h.state().gesture, Some(Gesture::Fade { clip: id, fade_in: side, .. }) if id == clip.id && side == fade_in)); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe); + assert_eq!(h.state().engine.session(), &before, "pressing the handle must not move the edit selection"); + if show_name && !full_length { + fade_shot(&mut h, &format!("fade-{side}-endpoint-dragging")); + } + h.drop_at(to); + h.step(); + let range = fade_drag_range(&clip, fade_in, sample_at(&before, tl, to.x)).unwrap(); + let mut expected = before.clone(); + let c = expected.find_clip_mut(clip.id).unwrap(); + let fade = soundcraft_model::Fade { len: range.end - range.start, shape: soundcraft_model::FadeShape::SCurve }; + assert!(fade.len < len); + if fade_in { + c.fade_in = fade; + } else { + c.fade_out = fade; + } + assert_eq!(h.state().engine.session(), &expected); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + } + } + } + } + + #[test] + fn existing_fade_handles_preserve_selector_and_other_smart_tool_zones() { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + c.fade_out = soundcraft_model::Fade { len: c.length / 2, shape: soundcraft_model::FadeShape::SCurve }; + h.step(); + let s = h.state().engine.session(); + let rect = Rect::from_min_max(pos2(x_of(s, tl, clip.start), lane.min.y), pos2(x_of(s, tl, clip.end()), lane.max.y)); + let curves = fade_curves(&h, rect); + let head = *curves[0].0.points.first().unwrap(); + h.hover_at(head + vec2(0.0, 16.0)); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Text); + h.state_mut().engine.execute("edit.tool", &json!({"tool": "selector"})).unwrap(); + h.step(); + h.hover_at(head); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Text); + start_drag(&mut h, head); + assert!(matches!(h.state().gesture, Some(Gesture::Select { .. }))); + h.drop_at(head + vec2(20.0, 0.0)); + h.step(); + } + #[test] fn a_fade_drag_handles_its_clip_shrinking_to_one_sample() { for fade_in in [true, false] { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4841827..3e8549c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (fade drag previews match the released curve) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (existing fades can be dragged from their upper endpoints) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -16,7 +16,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Feature | Status | Notes | |---|---|---| | Tools: Zoom, Trim, Selector, Grabber, Scrubber, Pencil, Smart (7) | ✅ present | Trim variants (TCE, loop, scrub trim) and Grabber variants (separation, object) partial; Multitool fade/trim handles don't adjust audio (#5) | -| Smart-tool Fade In / Fade Out drag bounds and preview | ✅ | One thin curve replaces the dragged side's previous curve and matches its appearance after release; bounded to the clip, including full-clip fades | +| Smart-tool Fade In / Fade Out drag bounds and preview | ✅ | One thin curve replaces the dragged side's previous curve and matches its appearance after release; bounded to the clip, including full-clip fades. Hovering the curve's upper endpoint shows the fade cursor and lets the existing fade be dragged | | Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present | | Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples | | Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial | @@ -65,6 +65,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Existing fades have draggable upper endpoints with matching hover/drag cursors, including at adjacent clip boundaries. Readiness estimate unchanged | | 2026-10-13 | minor | Fade drags use the same thin S-curve renderer as committed fades, replacing the dragged side's old curve. Readiness estimate unchanged | | 2026-10-13 | minor | Bounded Smart-tool fade previews and commits; full-clip fades target only the dragged clip and side. Readiness estimate unchanged | | 2026-10-10 | major | Created: tools, modes, shortcuts, windows and meters checklist against Pro Tools 2026.4.1 |