Browse / github.com/storytold/soundcraft / patch
Edit window: butted clips share one seam, and the clip name is a small pill
Edit window: butted clips share one seam, and the clip name is a small pill ## Problem and resulting behavior Two things set the Edit window apart from the incumbent, one at every cut and one on every clip: 1. **A cut is drawn twice.** Two clips that meet exactly (the later one starts where the earlier ends) each draw their own outline, so the cut is two dark lines side by side, with a round corner on each side of it. The incumbent draws a single thin line between them, as thick as a clip's own outline, with square corners. 2. **The clip name is a full-width bar.** A coloured band across the top of the clip holds the name, the rating stars and the lock. It takes a s
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- lucasegen (imported from pull request #974, open)
- Base
60087e0f46032118bf309ed9ea161a9b4a872713- Size
- +520 −87 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
ce64b85c8636a1d755194ac9
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/edit_window.rs crates/ui-egui/src/edit_window/clips.rs crates/ui-egui/src/edit_window/input.rs crates/ui-egui/src/edit_window/tests.rs crates/ui-egui/src/edit_window/tracks.rs crates/ui-egui/src/video_track.rs
@@ -27,9 +27,14 @@ mod universe; #[cfg(test)] use automation::automation_id;+use clips::clip_cuts;+pub(crate) use clips::draw_clip_labels; pub use clips::strip_silence_stroke; #[cfg(test)]-use clips::{clip_name_height, fade_curve_points};+use clips::{+ ClipCut, PILL_BG, PILL_HEIGHT, PILL_INSET, PILL_MIN_WIDTH, PILL_PAD, clip_cut, clip_cut_in, clip_marks, fade_curve_points, marks_pill_left,+ name_pill_width,+}; use drag::drive_drag; #[cfg(test)] use drag::fade_drag_range;@@ -48,8 +48,9 @@ pub(super) fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: R } } -pub(super) 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 }+/// Whether clips show their name (View › Clip › Name) in a lane tall enough to carry it.+pub(super) fn clip_name_shown(s: &Session, lane: Rect) -> bool {+ lane.height() >= 40.0 && s.edit.flag("view.clip.name") } pub(super) fn fade_curve_points(fade: soundcraft_model::Fade, fade_in: bool, a: f32, b: f32, body: Rect, pixels_per_point: f32) -> Vec<Pos2> {@@ -138,51 +139,46 @@ fn grid_veil(line: Color32, bg: Color32) -> Color32 { } } -/// `sorted`: whether `track.clips()` is sorted by start (worked out once per track and frame).+/// `sorted`: whether `track.clips()` is sorted by start (worked out once per track and frame); `cut`: how the clip+/// meets its neighbours in its playlist (see [`clip_cuts`]). pub(super) fn draw_clip( app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, sorted: bool,+ cut: ClipCut, 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);- let r = Rect::from_min_max(pos2(x0, lane.min.y + 1.0), pos2(x1, lane.max.y - 1.0));+ let r = clip_rect(s, tl, lane, clip);+ let (x0, x1) = (r.min.x, r.max.x); let selected = s.edit.selected_clips.contains(&clip.id) || (s.edit.selected_tracks.contains(&track.id) && !s.edit.selection.is_empty() && s.edit.selection.start <= clip.start && s.edit.selection.end >= clip.end()); let color = clip.color.unwrap_or(track.color);- let (mut body, bar, mut wave) = clip_colors(color, selected);+ let (mut body, _, mut wave) = clip_colors(color, selected); if clip.muted || dim { body = body.gamma_multiply(0.45); wave = Color32::from_rgb(70, 70, 70); } 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 = 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 });- let name_clip = nb.intersect(lane);- // Narrower than this, no glyph would show: the name starts 4 points in.- if name_clip.width() > 6.0 {- painter.with_clip_rect(name_clip).text(- pos2(nb.min.x.max(lane.min.x) + 4.0, nb.center().y),- Align2::LEFT_CENTER,- &clip.name,- bold(10.5),- Color32::from_rgb(12, 12, 12),- );- }+ let corners = cut.corners();+ let outline = Stroke::new(1.0, CLIP_OUTLINE);+ if cut.right {+ // The cut's single line is drawn by the next clip: stroke past our right edge and clip it away.+ painter.rect_filled(r, corners, body);+ let past = Rect::from_min_max(r.min, pos2(r.max.x + 2.0, r.max.y));+ painter.with_clip_rect(painter.clip_rect().intersect(r)).rect_stroke(past, corners, outline, StrokeKind::Inside);+ } else {+ painter.rect(r, corners, body, outline, StrokeKind::Inside); }- let body_r = Rect::from_min_max(pos2(r.min.x, r.min.y + name_h), r.max);+ // The body is the whole clip: the name is a small pill drawn over it (see `draw_clip_pills`).+ let body_r = r; let vis = body_r.intersect(lane); // The waveform and fade curve share the same temporary envelope every frame. let (fade_in, fade_out) = clip_preview_fades(clip, app.gesture.as_ref().filter(|_| !dim));@@ -311,9 +307,6 @@ pub(super) fn draw_clip( } // Optional clip overlays (View › Clip). let ov = Color32::from_rgb(16, 16, 16);- if s.edit.flag("view.clip.rating") && clip.rating > 0 && name_h > 0.0 {- painter.text(pos2(r.max.x - 18.0, r.min.y + 7.0), Align2::RIGHT_CENTER, "★".repeat(usize::from(clip.rating.min(5))), regular(9.0), ov);- } if s.edit.flag("view.clip.sync_point") && clip.sync_point > 0 { let x = x_of(s, tl, clip.start + clip.sync_point); painter.add(Shape::convex_polygon(@@ -321,7 +314,7 @@ pub(super) fn draw_clip( Color32::WHITE, Stroke::NONE, ));- painter.line_segment([pos2(x, r.min.y + name_h), pos2(x, r.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 90)));+ painter.line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 90))); } let time_text = if s.edit.flag("view.clip.time.current") { Some(s.format_position(clip.start, s.edit.main_counter))@@ -366,12 +359,179 @@ pub(super) fn draw_clip( } } }- if clip.edit_locked || clip.time_locked {- painter.text(pos2(r.max.x - 10.0, r.min.y + 7.0), Align2::CENTER_CENTER, "🔒", regular(9.0), Color32::WHITE);+ // Video clips get their pills from `video_track::draw_lane`, after their thumbnails.+ if !clip.is_video() {+ draw_clip_labels(painter, s, tl, lane, clip);+ }+ if cut.left {+ // The cut's one line, on top of the waveform so it runs the full height.+ painter.rect_filled(Rect::from_min_size(r.min, vec2(1.0, r.height())), 0.0, CLIP_OUTLINE); } let _ = app; } +/// Where a clip sits in its lane: one point in from the lane's top and bottom, at least one point wide.+pub(super) fn clip_rect(s: &Session, tl: Rect, lane: Rect, clip: &Clip) -> Rect {+ let x0 = x_of(s, tl, clip.start);+ let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0);+ Rect::from_min_max(pos2(x0, lane.min.y + 1.0), pos2(x1, lane.max.y - 1.0))+}++/// The pills of `clip` (name, marks). Drawn right after the clip itself, so clips and overlays drawn later cover them.+pub(crate) fn draw_clip_labels(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect, clip: &Clip) {+ let r = clip_rect(s, tl, lane, clip);+ if r.max.x < lane.min.x || r.min.x > lane.max.x {+ return;+ }+ let show_name = clip_name_shown(s, lane);+ let marks = clip_marks(clip, s.edit.flag("view.clip.rating"));+ draw_clip_pills(painter, lane, r, show_name.then_some(clip.name.as_str()), &marks);+}++/// The marks a clip carries at its top-right: its star rating and a lock when edit- or time-locked.+pub(super) fn clip_marks(clip: &Clip, show_rating: bool) -> String {+ let mut marks = if show_rating && clip.rating > 0 { "★".repeat(usize::from(clip.rating.min(5))) } else { String::new() };+ if clip.edit_locked || clip.time_locked {+ if !marks.is_empty() {+ marks.push(' ');+ }+ marks.push('🔒');+ }+ marks+}++/// Gap between a pill and the clip outline's inner edge (so the pill sits 1 pt inside the outline).+pub(super) const PILL_INSET: f32 = 2.0;+pub(super) const PILL_HEIGHT: f32 = 13.0;+/// Horizontal padding between a pill's edge and its text.+pub(super) const PILL_PAD: f32 = 3.0;+/// The narrowest name pill worth drawing: its padding plus about one letter.+pub(super) const PILL_MIN_WIDTH: f32 = 12.0;+/// Half-transparent black behind white text, readable on the light and the dark clip bodies alike.+pub(super) const PILL_BG: Color32 = Color32::from_black_alpha(125);++/// Width of the name pill: the text plus its padding, capped to the `room` the clip leaves it (the text is then+/// cut cleanly at the pill's edge, with no ellipsis). `None` when there is no room for anything useful.+pub(super) fn name_pill_width(room: f32, text_w: f32) -> Option<f32> {+ if room.is_nan() || room < PILL_MIN_WIDTH {+ return None;+ }+ Some((text_w.max(0.0) + 2.0 * PILL_PAD).min(room))+}++/// Left edge of the marks pill, which sits at the clip's top-right: `None` when the clip is too narrow to hold it+/// (it would veil the clip), where `left` is the leftmost x a pill may use.+pub(super) fn marks_pill_left(left: f32, clip_right: f32, w: f32) -> Option<f32> {+ let x = clip_right - PILL_INSET - w;+ (x >= left).then_some(x)+}++/// The clip's name as a small rounded pill at its top-left, and its marks (rating, lock) as a matching pill at the+/// top-right. The name keeps clear of the marks and floats to the left edge of the lane when the clip starts+/// off-screen, so it stays readable while scrolling.+fn draw_clip_pills(painter: &egui::Painter, lane: Rect, r: Rect, name: Option<&str>, marks: &str) {+ let p = painter.with_clip_rect(painter.clip_rect().intersect(r));+ let pill = |rect: Rect, galley: Arc<egui::Galley>| {+ p.rect_filled(rect, CornerRadius::same(3), PILL_BG);+ let at = pos2(rect.min.x + PILL_PAD, rect.center().y - galley.size().y * 0.5);+ p.with_clip_rect(p.clip_rect().intersect(rect)).galley(at, galley, Color32::WHITE);+ };+ let top = r.min.y + PILL_INSET;+ let left = r.min.x.max(lane.min.x) + PILL_INSET;+ let mut right = r.max.x.min(lane.max.x) - PILL_INSET;+ if !marks.is_empty() {+ let galley = p.layout_no_wrap(marks.to_owned(), regular(9.0), Color32::WHITE);+ let w = galley.size().x + 2.0 * PILL_PAD;+ if let Some(x) = marks_pill_left(left, r.max.x, w) {+ right = right.min(x - PILL_INSET);+ pill(Rect::from_min_size(pos2(x, top), vec2(w, PILL_HEIGHT)), galley);+ }+ }+ // No layout where no pill fits: zoomed out on a large session, most clips are too narrow.+ if let Some(name) = name.filter(|n| !n.is_empty() && right - left >= PILL_MIN_WIDTH) {+ let galley = p.layout_no_wrap(name.to_owned(), bold(10.0), Color32::WHITE);+ if let Some(w) = name_pill_width(right - left, galley.size().x) {+ pill(Rect::from_min_size(pos2(left, top), vec2(w, PILL_HEIGHT)), galley);+ }+ }+}++/// The 1 pt outline around a clip.+const CLIP_OUTLINE: Color32 = Color32::from_rgb(10, 10, 10);++/// How a clip meets its neighbours in the playlist. Two clips butted at a cut share ONE 1 pt line, as in+/// the incumbent: the later clip draws it (its left edge), the earlier one drops its right edge, and both+/// sides of the cut have square corners. A gap or an overlap leaves the clip drawn on its own.+/// Selection only recolours the body, so it never changes which edges are drawn.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]+pub(super) struct ClipCut {+ /// Starts exactly where the clip before it ends.+ pub(super) left: bool,+ /// Ends exactly where the clip after it starts.+ pub(super) right: bool,+}++impl ClipCut {+ /// The body's corners: square on a side that meets another clip, rounded otherwise.+ pub(super) fn corners(self) -> CornerRadius {+ let (nw, ne) = (if self.left { 0 } else { 2 }, if self.right { 0 } else { 2 });+ CornerRadius { nw, ne, sw: nw, se: ne }+ }+}++/// The cut of the clip spanning `start..end`, given the `(start, end)` of every other clip of its playlist, in any+/// order (a playlist's order is its stacking order, not its time order). A side is butted when another clip ends+/// (or starts) exactly there and no third clip covers that point.+pub(super) fn clip_cut(start: Samples, end: Samples, others: impl IntoIterator<Item = (Samples, Samples)>) -> ClipCut {+ // An empty clip has no span to share a line with.+ if end <= start {+ return ClipCut::default();+ }+ let (mut left, mut right, mut left_covered, mut right_covered) = (false, false, false, false);+ for (s, e) in others.into_iter().filter(|(s, e)| e > s) {+ left |= e == start;+ right |= s == end;+ left_covered |= s < start && start < e;+ right_covered |= s < end && end < e;+ }+ ClipCut { left: left && !left_covered, right: right && !right_covered }+}++/// [`clip_cut`] for `clip` among its playlist's `clips`, scanning them all.+pub(super) fn clip_cut_in(clips: &[Clip], clip: &Clip) -> ClipCut {+ clip_cut(clip.start, clip.end(), clips.iter().filter(|c| c.id != clip.id).map(|c| (c.start, c.end())))+}++/// [`clip_cut`] for each clip of a playlist. Sorted by start (the usual case), one pass over the playlist and two+/// binary searches per clip, so large sessions do no per-clip scan every frame; unsorted, a scan per clip.+pub(super) fn clip_cuts(clips: &[Clip]) -> impl Fn(&Clip) -> ClipCut + '_ {+ // reach[k]: the furthest end of the first k clips (empty clips reach nothing).+ let reach: Option<Vec<Samples>> = clips.is_sorted_by_key(|c| c.start).then(|| {+ std::iter::once(Samples::MIN)+ .chain(clips.iter().scan(Samples::MIN, |far, c| {+ if c.end() > c.start {+ *far = (*far).max(c.end());+ }+ Some(*far)+ }))+ .collect()+ });+ move |clip| {+ let Some(reach) = &reach else { return clip_cut_in(clips, clip) };+ let (start, end) = (clip.start, clip.end());+ if end <= start {+ return ClipCut::default();+ }+ let reached = |k: usize| reach.get(k).copied().unwrap_or(Samples::MIN);+ // Butted and uncovered on the left: the clips starting before it reach exactly its start. On the right: the+ // clips starting before its end (itself included) reach exactly its end, and a clip starts there.+ let before = clips.partition_point(|c| c.start < start);+ let upto = clips.partition_point(|c| c.start < end);+ let next = clips.get(upto..).unwrap_or_default().iter().take_while(|c| c.start == end).any(|c| c.end() > c.start);+ ClipCut { left: reached(before) == start, right: next && reached(upto) == end }+ }+}+ /// The earlier clip of a crossfade pair: the one before `clip` in its playlist, overlapping its start. fn crossfade_partner<'a>(track: &'a Track, clip: &Clip) -> Option<&'a Clip> { let (p, i) = track.playlists.iter().find_map(|p| {@@ -1,5 +1,5 @@ use super::automation::{automation_id, breakpoint_at, plugin_range, shown_automation};-use super::clips::{clip_name_height, fade_control_position};+use super::clips::fade_control_position; use super::drag::{drag_snap, fade_curve_drag_value, fade_drag_range, spot_or_run}; use super::layout::{sample_at, x_of}; use super::*;@@ -20,7 +20,7 @@ fn fade_handle_at(s: &Session, tl: Rect, lane: Rect, clip: &Clip, p: Pos2) -> Op if clip.length <= 0 || clip.end() <= clip.start { return None; }- let y = lane.min.y + 1.0 + clip_name_height(s, lane);+ let y = lane.min.y + 1.0; [(true, clip.fade_in), (false, clip.fade_out)] .into_iter() .filter(|(_, fade)| fade.len > 0)@@ -51,7 +51,7 @@ pub(super) fn fade_midpoint(s: &Session, tl: Rect, lane: Rect, clip: &Clip, fade if b - a < 32.0 { return None; }- let body = Rect::from_min_max(pos2(a, lane.min.y + 1.0 + clip_name_height(s, lane)), pos2(b, lane.max.y - 1.0));+ let body = Rect::from_min_max(pos2(a, lane.min.y + 1.0), pos2(b, lane.max.y - 1.0)); let p = fade_control_position(fade, a, b, body); lane.contains(p).then_some(p) }@@ -252,7 +252,7 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l { let fade = if fade_in { c.fade_in } else { c.fade_out }; let anchor = (origin.y, fade.gain(0.5));- let height = (lane.height() - 2.0 - clip_name_height(&s, lane)).max(1.0);+ let height = (lane.height() - 2.0).max(1.0); app.gesture = Some(Gesture::FadeCurve { clip: c.id, fade_in, curvature: fade_curve_drag_value(fade, anchor, height, p.y), anchor, height }); ui.ctx().request_repaint(); } else if resp.drag_started()@@ -967,7 +967,7 @@ fn fade_midpoint_handles_remain_visible_and_can_be_grabbed_again() { h.step(); assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); start_drag(&mut h, control);- let to = control - vec2(0.0, (lane.height() - 2.0 - clip_name_height(&before, lane)) * 0.1);+ let to = control - vec2(0.0, (lane.height() - 2.0) * 0.1); h.hover_at(to); h.step(); assert!(@@ -1482,3 +1482,266 @@ fn alt_click_on_a_breakpoint_deletes_it() { assert_eq!(h.state().engine.session().track(track).unwrap().clips(), clips.as_slice(), "{tool}"); } }++const ALONE: ClipCut = ClipCut { left: false, right: false };++#[test]+fn butted_pair_shares_one_seam() {+ // 0..100 then 100..200: the first drops its right edge, the second draws the seam on its left.+ assert_eq!(clip_cut(0, 100, [(100, 200)]), ClipCut { left: false, right: true });+ assert_eq!(clip_cut(100, 200, [(0, 100)]), ClipCut { left: true, right: false });+}++#[test]+fn middle_clip_of_a_run_is_butted_on_both_sides() {+ let cut = clip_cut(100, 200, [(0, 100), (200, 300)]);+ assert_eq!(cut, ClipCut { left: true, right: true });+ let c = cut.corners();+ assert_eq!((c.nw, c.sw, c.ne, c.se), (0, 0, 0, 0));+}++#[test]+fn gap_overlap_and_lone_clips_keep_their_own_outline() {+ // First and last clips: nothing on the open side.+ assert_eq!(clip_cut(0, 100, []), ALONE);+ // A gap of one sample is not a cut.+ assert_eq!(clip_cut(101, 200, [(0, 100), (201, 300)]), ALONE);+ // Overlaps (crossfades, layers) keep both outlines.+ assert_eq!(clip_cut(90, 200, [(0, 100), (150, 300)]), ALONE);+ assert_eq!(clip_cut(0, 100, [(60, 160)]), ALONE);+ // A third clip lying across the cut hides it: no shared seam.+ assert_eq!(clip_cut(100, 200, [(0, 100), (50, 150)]), ALONE);+ // Empty clips share nothing, and are not neighbours either.+ assert_eq!(clip_cut(100, 100, [(0, 100), (100, 200)]), ALONE);+ assert_eq!(clip_cut(100, 200, [(100, 100)]), ALONE);+}++#[test]+fn sorted_playlists_find_the_same_cuts_without_a_scan() {+ let layouts: [&[(Samples, Samples)]; 8] = [+ &[(0, 100), (100, 100)],+ &[(0, 100), (100, 100), (200, 100)],+ &[(0, 100), (101, 99), (201, 99)],+ &[(0, 100), (90, 110), (150, 150)],+ &[(0, 100), (50, 100), (100, 100)],+ &[(0, 100), (100, 0), (100, 100)],+ &[(0, 1000), (100, 100), (200, 100)],+ &[(0, 100), (100, 50), (100, 100), (200, 100)],+ ];+ for spans in layouts {+ let clips: Vec<Clip> = spans.iter().zip(1..).map(|(&(start, len), id)| test_clip(id, start, len)).collect();+ let cut_of = clip_cuts(&clips);+ for c in &clips {+ assert_eq!(cut_of(c), clip_cut_in(&clips, c), "{spans:?}, clip at {}", c.start);+ }+ }+ // Not vacuous: the middle clip of a run is butted on both sides.+ let run: Vec<Clip> = (0..3).map(|i| test_clip(i + 1, i as Samples * 100, 100)).collect();+ assert_eq!(clip_cuts(&run)(&run[1]), ClipCut { left: true, right: true });+}++#[test]+fn only_butted_sides_lose_their_round_corners() {+ let round = ALONE.corners();+ assert_eq!((round.nw, round.sw, round.ne, round.se), (2, 2, 2, 2));+ let first = ClipCut { left: false, right: true }.corners();+ assert_eq!((first.nw, first.sw, first.ne, first.se), (2, 2, 0, 0));+ let second = ClipCut { left: true, right: false }.corners();+ assert_eq!((second.nw, second.sw, second.ne, second.se), (0, 0, 2, 2));+}++#[test]+fn name_pill_is_as_wide_as_its_text_and_cut_cleanly_on_short_clips() {+ // Room to spare: the text plus its padding on both sides.+ assert_eq!(name_pill_width(200.0, 50.0), Some(50.0 + 2.0 * PILL_PAD));+ // A short clip caps the pill at the room it leaves; the text is then cut at the pill's edge.+ assert_eq!(name_pill_width(30.0, 80.0), Some(30.0));+ assert_eq!(name_pill_width(PILL_MIN_WIDTH, 80.0), Some(PILL_MIN_WIDTH));+ // Too narrow to show anything useful, or nonsense numbers: no pill.+ assert_eq!(name_pill_width(PILL_MIN_WIDTH - 0.1, 80.0), None);+ assert_eq!(name_pill_width(-5.0, 80.0), None);+ assert_eq!(name_pill_width(f32::NAN, 80.0), None);+ // A bogus text width never makes a negative or NaN pill.+ assert_eq!(name_pill_width(100.0, f32::NAN), Some(2.0 * PILL_PAD));+ assert_eq!(name_pill_width(100.0, -40.0), Some(2.0 * PILL_PAD));+}++#[test]+fn marks_list_the_rating_and_the_lock() {+ let mut clip = test_clip(1, 0, 100);+ assert_eq!(clip_marks(&clip, true), "");+ clip.rating = 3;+ assert_eq!(clip_marks(&clip, true), "★★★");+ assert_eq!(clip_marks(&clip, false), "");+ clip.edit_locked = true;+ assert_eq!(clip_marks(&clip, true), "★★★ 🔒");+ assert_eq!(clip_marks(&clip, false), "🔒");+ clip.rating = 200;+ assert_eq!(clip_marks(&clip, true), "★★★★★ 🔒");+}++#[test]+fn the_clip_name_is_a_pill_over_the_waveform_not_a_bar() {+ for show_name in [true, false] {+ 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 s = h.state().engine.session();+ let (xs, xe) = (x_of(s, tl, clip.start), x_of(s, tl, clip.end()));+ let clip_rect = Rect::from_min_max(pos2(xs, lane.min.y), pos2(xe, lane.max.y));+ let shapes = &h.output().shapes;+ let pills: Vec<Rect> = shapes+ .iter()+ .filter_map(|shape| match &shape.shape {+ Shape::Rect(r) if r.fill == PILL_BG && clip_rect.contains_rect(r.rect) => Some(r.rect),+ _ => None,+ })+ .collect();+ if !show_name {+ assert!(pills.is_empty(), "names are off");+ continue;+ }+ assert_eq!(pills.len(), 1, "one pill per clip");+ let pill = pills[0];+ // At the top-left, 1 pt inside the outline, as wide as its text plus padding: far from a full-width bar.+ assert_eq!(pill.min, pos2(xs + PILL_INSET, lane.min.y + 1.0 + PILL_INSET));+ assert_eq!(pill.height(), PILL_HEIGHT);+ let text_w = shapes+ .iter()+ .find_map(|shape| match &shape.shape {+ Shape::Text(t) if t.galley.text() == clip.name && pill.contains(t.pos) => Some(t.galley.size().x),+ _ => None,+ })+ .expect("the name is drawn on the pill");+ assert_eq!(pill.width(), text_w + 2.0 * PILL_PAD);+ assert!(pill.width() < clip_rect.width() * 0.5);+ }+}++#[test]+fn the_rating_stars_follow_the_rating_switch_whatever_the_name_switch_says() {+ for (show_name, show_rating) in [(false, true), (true, true), (true, false), (false, false)] {+ let mut h = smart_demo();+ for (cmd, on) in [("view.clip_name", show_name), ("view.clip_rating", show_rating)] {+ h.state_mut().engine.execute(cmd, &json!({"value": on})).unwrap();+ }+ h.state_mut().engine.session_mut().tracks.iter_mut().flat_map(|t| &mut t.playlists).flat_map(|p| &mut p.clips).for_each(|c| c.rating = 3);+ h.step();+ let stars = h.output().shapes.iter().filter(|c| matches!(&c.shape, Shape::Text(t) if t.galley.text() == "★★★")).count();+ assert_eq!(stars > 0, show_rating, "names {show_name}, rating {show_rating}: {stars} rated clips drew their stars");+ }+}++#[test]+fn alternate_playlist_lanes_draw_only_the_clips_on_screen() {+ let mut h = harness(1);+ let tl = timeline(&h);+ let s = h.state().engine.session();+ let (view, color) = (Range::new(sample_at(s, tl, tl.min.x), sample_at(s, tl, tl.max.x)), s.tracks[0].color);+ let len = (view.end - view.start) / 20;+ // One clip well inside the view, one running past its right edge, two far beyond it.+ let starts = [view.start + 4 * len, view.end - len / 2, view.end + 20 * len, view.end + 2_000 * len];+ let s = h.state_mut().engine.session_mut();+ let mut alt = soundcraft_model::Playlist::new("alt");+ alt.clips = starts.iter().map(|&at| test_clip(s.new_clip_id().0, at, len)).collect();+ let track = s.tracks.first_mut().unwrap();+ (track.view, track.active_playlist) = ("playlists".into(), 0);+ track.playlists.push(alt);+ h.run_steps(3);+ // The main playlist is empty: every clip body in the track's rows belongs to the alternate lane.+ let bodies: Vec<Rect> = h+ .output()+ .shapes+ .iter()+ .filter_map(|c| match &c.shape {+ Shape::Rect(r) if r.fill == clip_colors(color, false).0 => Some(r.rect),+ _ => None,+ })+ .collect();+ assert_eq!(bodies.len(), 2, "only the two clips that show are drawn: {bodies:?}");+ assert!(bodies.iter().all(|r| r.max.x > tl.min.x && r.min.x < tl.max.x), "{bodies:?} are not all on screen");+}++fn test_clip(id: u64, start: Samples, len: Samples) -> Clip {+ Clip::audio(ClipId(id), "c", soundcraft_model::SourceId(0), 0, start, len)+}++#[test]+fn cut_is_found_among_the_playlist_neighbours() {+ // Selection never enters: it recolours the body, so a selected clip gets the same edges as any other.+ let clips = [test_clip(1, 0, 100), test_clip(2, 100, 100), test_clip(3, 200, 100), test_clip(4, 400, 50)];+ let cuts: Vec<ClipCut> = clips.iter().map(|c| clip_cut_in(&clips, c)).collect();+ assert_eq!(cuts, [ClipCut { left: false, right: true }, ClipCut { left: true, right: true }, ClipCut { left: true, right: false }, ALONE]);+}++#[test]+fn cut_does_not_depend_on_the_playlist_order() {+ // Bring to Front / Send to Back reorder a playlist (its order is the stacking order): the seam stays.+ let (a, b) = (test_clip(1, 0, 100), test_clip(2, 100, 100));+ let reordered = [b.clone(), a.clone()];+ assert_eq!(clip_cut_in(&reordered, &a), ClipCut { left: false, right: true });+ assert_eq!(clip_cut_in(&reordered, &b), ClipCut { left: true, right: false });+ let run = [test_clip(3, 200, 100), test_clip(1, 0, 100), test_clip(2, 100, 100)];+ let cuts: Vec<ClipCut> = run.iter().map(|c| clip_cut_in(&run, c)).collect();+ assert_eq!(cuts, [ClipCut { left: true, right: false }, ClipCut { left: false, right: true }, ClipCut { left: true, right: true }]);+}++#[test]+fn layered_clips_share_a_seam_only_where_they_really_meet() {+ // A short clip inside a long one does not hide the seam between the long clip and the next.+ let clips = [test_clip(1, 0, 100), test_clip(2, 10, 10), test_clip(3, 100, 100)];+ assert_eq!(clip_cut_in(&clips, &clips[2]), ClipCut { left: true, right: false });+ assert_eq!(clip_cut_in(&clips, &clips[0]), ClipCut { left: false, right: true });+ assert_eq!(clip_cut_in(&clips, &clips[1]), ALONE);+ // Two clips on one start, and a lone clip.+ let same = [test_clip(1, 0, 100), test_clip(2, 0, 100), test_clip(3, 300, 100)];+ assert_eq!(clip_cut_in(&same, &same[1]), ALONE);+ assert_eq!(clip_cut_in(&same, &same[2]), ALONE);+ assert_eq!(clip_cut_in(&[], &test_clip(9, 0, 100)), ALONE);+}++#[test]+fn marks_pill_is_hidden_when_the_clip_is_too_narrow_for_it() {+ // A 60 pt clip from x=100 (pills start at 102) holds a 30 pt pill at its right end.+ assert_eq!(marks_pill_left(102.0, 160.0, 30.0), Some(128.0));+ // Exactly full: the pill's left edge meets the leftmost x.+ assert_eq!(marks_pill_left(102.0, 134.0, 30.0), Some(102.0));+ // Any narrower and it would veil the clip: hidden.+ assert_eq!(marks_pill_left(102.0, 133.0, 30.0), None);+ assert_eq!(marks_pill_left(102.0, f32::NAN, 30.0), None);+}++#[test]+fn an_overlapping_clip_covers_the_pill_of_the_clip_below() {+ let mut h = smart_demo();+ h.step();+ let tl = timeline(&h);+ let (below, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0);+ // A copy on top (last in the playlist), starting at the same place.+ let s = h.state_mut().engine.session_mut();+ let (track, _) = s.find_clip(below.id).unwrap();+ let id = s.new_clip_id();+ let top = Clip { id, name: "Top".into(), ..below.clone() };+ s.track_mut(track).unwrap().playlist_mut().unwrap().clips.push(top);+ h.step();+ h.step();+ let s = h.state().engine.session();+ let at = pos2(x_of(s, tl, below.start) + PILL_INSET, lane.min.y + 1.0 + PILL_INSET);+ let shapes = &h.output().shapes;+ let pills: Vec<(usize, Rect)> = shapes+ .iter()+ .enumerate()+ .filter_map(|(i, shape)| match &shape.shape {+ Shape::Rect(r) if r.fill == PILL_BG && r.rect.min == at => Some((i, r.rect)),+ _ => None,+ })+ .collect();+ assert_eq!(pills.len(), 2, "both clips draw a pill at the same spot");+ let ((lower, pill), (upper, _)) = (pills[0], pills[1]);+ assert!(+ shapes[lower + 1..upper].iter().any(|shape| matches!(&shape.shape, Shape::Rect(r) if r.fill != PILL_BG && r.rect.contains_rect(pill))),+ "the top clip's body is painted between the two pills, so it hides the lower pill"+ );+}@@ -86,11 +86,12 @@ pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); let auto_view = AutoParam::parse(&track.view); let sorted = track.clips().is_sorted_by_key(|c| c.start);+ let cut_of = clip_cuts(track.clips()); for clip in track.clips() { if !clip.range().overlaps(&view) { continue; }- draw_clip(app, &painter, &s, track, sorted, clip, tl, lane, auto_view.is_some());+ draw_clip(app, &painter, &s, track, sorted, cut_of(clip), clip, tl, lane, auto_view.is_some()); } draw_live_take(app, &painter, &s, track, tl, lane); crate::video_track::draw_lane(app, &painter, &s, track, tl, lane);@@ -139,8 +140,9 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); let sorted = track.clips().is_sorted_by_key(|c| c.start); let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x));+ let cut_of = clip_cuts(&pl.clips); for c in pl.clips.iter().filter(|c| c.range().overlaps(&view)) {- draw_clip(app, &painter, &s, track, sorted, c, tl, lane, false);+ draw_clip(app, &painter, &s, track, sorted, cut_of(c), c, tl, lane, false); } let resp = ui.interact(lane, ui.scope_id().with(("pl_lane", track.id.0, i)), Sense::click()); if resp.clicked()@@ -432,62 +432,65 @@ pub fn draw_lane(app: &mut SoundApp, painter: &Painter, s: &Session, track: &Tra let ctx = painter.ctx().clone(); app.video.poll(&ctx); let on = online(s);- let name_h = if lane.height() >= 40.0 && s.edit.flag("view.clip.name") { 13.0 } else { 0.0 }; let ppp = ctx.pixels_per_point(); for clip in track.clips() { let ClipContent::Video { source, offset } = clip.content else { continue };- let x0 = x_of(s, tl, clip.start);- let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0);- let body = Rect::from_min_max(pos2(x0 + 1.0, lane.min.y + 2.0 + name_h), pos2(x1 - 1.0, lane.max.y - 2.0));- let vis = body.intersect(lane);- if vis.width() < 2.0 || vis.height() < 6.0 {- continue;- }- let p = painter.with_clip_rect(vis);- let Some(v) = s.video(source) else {- p.text(vis.center(), egui::Align2::CENTER_CENTER, "movie missing from session", regular(10.0), Color32::from_rgb(220, 120, 120));- continue;- };- if !on {- p.text(vis.center(), egui::Align2::CENTER_CENTER, "Video Track Offline", regular(10.0), Color32::from_gray(170));- continue;- }- if let Some(why) = &v.offline {- p.text(vis.center(), egui::Align2::CENTER_CENTER, format!("media offline: {why}"), regular(10.0), Color32::from_rgb(220, 120, 120));- continue;- }- let key = MovieKey::new(source, &v.path);- if let Some(Err(e)) = app.video.info(key) {- p.text(vis.center(), egui::Align2::CENTER_CENTER, format!("media offline: {e}"), regular(10.0), Color32::from_rgb(220, 120, 120));- continue;- }- let aspect = app.video.info(key).and_then(|i| i.as_ref().ok()).map_or(v.aspect(), MovieInfo::display_aspect) as f32;- let th = body.height();- let tw = (th * aspect).clamp(8.0, 600.0);- // Pixel height bucket (multiples of 16) so zooming the track does not re-decode constantly.- let hpx = (((th * ppp) / 16.0).ceil() * 16.0).clamp(16.0, 256.0) as u32;- let first = ((vis.min.x - body.min.x) / tw).floor().max(0.0) as i64;- let mut k = first;- loop {- let tx = body.min.x + k as f32 * tw;- if tx > vis.max.x || k > first + 400 {- break;+ 'thumbs: {+ let x0 = x_of(s, tl, clip.start);+ let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0);+ let body = Rect::from_min_max(pos2(x0 + 1.0, lane.min.y + 2.0), pos2(x1 - 1.0, lane.max.y - 2.0));+ let vis = body.intersect(lane);+ if vis.width() < 2.0 || vis.height() < 6.0 {+ break 'thumbs; }- let at = sample_at(s, tl, tx);- let secs = movie_secs(s, clip.start, offset, at.max(clip.start));- let r = Rect::from_min_max(pos2(tx, body.min.y), pos2((tx + tw).min(body.max.x), body.max.y));- match app.video.thumb(&ctx, key, &v.path, secs.max(0.0), hpx) {- Some(tex) => {- let full = Rect::from_min_size(r.min, egui::vec2(tw, th));- let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((r.width() / tw).clamp(0.0, 1.0), 1.0));- p.image(tex.id(), Rect::from_min_max(full.min, pos2(r.max.x, full.max.y)), uv, Color32::WHITE);+ let p = painter.with_clip_rect(vis);+ let Some(v) = s.video(source) else {+ p.text(vis.center(), egui::Align2::CENTER_CENTER, "movie missing from session", regular(10.0), Color32::from_rgb(220, 120, 120));+ break 'thumbs;+ };+ if !on {+ p.text(vis.center(), egui::Align2::CENTER_CENTER, "Video Track Offline", regular(10.0), Color32::from_gray(170));+ break 'thumbs;+ }+ if let Some(why) = &v.offline {+ p.text(vis.center(), egui::Align2::CENTER_CENTER, format!("media offline: {why}"), regular(10.0), Color32::from_rgb(220, 120, 120));+ break 'thumbs;+ }+ let key = MovieKey::new(source, &v.path);+ if let Some(Err(e)) = app.video.info(key) {+ p.text(vis.center(), egui::Align2::CENTER_CENTER, format!("media offline: {e}"), regular(10.0), Color32::from_rgb(220, 120, 120));+ break 'thumbs;+ }+ let aspect = app.video.info(key).and_then(|i| i.as_ref().ok()).map_or(v.aspect(), MovieInfo::display_aspect) as f32;+ let th = body.height();+ let tw = (th * aspect).clamp(8.0, 600.0);+ // Pixel height bucket (multiples of 16) so zooming the track does not re-decode constantly.+ let hpx = (((th * ppp) / 16.0).ceil() * 16.0).clamp(16.0, 256.0) as u32;+ let first = ((vis.min.x - body.min.x) / tw).floor().max(0.0) as i64;+ let mut k = first;+ loop {+ let tx = body.min.x + k as f32 * tw;+ if tx > vis.max.x || k > first + 400 {+ break; }- None => {- p.rect_filled(r.shrink(1.0), 0.0, Color32::from_rgb(24, 24, 26));+ let at = sample_at(s, tl, tx);+ let secs = movie_secs(s, clip.start, offset, at.max(clip.start));+ let r = Rect::from_min_max(pos2(tx, body.min.y), pos2((tx + tw).min(body.max.x), body.max.y));+ match app.video.thumb(&ctx, key, &v.path, secs.max(0.0), hpx) {+ Some(tex) => {+ let full = Rect::from_min_size(r.min, egui::vec2(tw, th));+ let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((r.width() / tw).clamp(0.0, 1.0), 1.0));+ p.image(tex.id(), Rect::from_min_max(full.min, pos2(r.max.x, full.max.y)), uv, Color32::WHITE);+ }+ None => {+ p.rect_filled(r.shrink(1.0), 0.0, Color32::from_rgb(24, 24, 26));+ } }+ p.line_segment([pos2(tx, body.min.y), pos2(tx, body.max.y)], egui::Stroke::new(1.0, Color32::from_black_alpha(160)));+ k += 1; }- p.line_segment([pos2(tx, body.min.y), pos2(tx, body.max.y)], egui::Stroke::new(1.0, Color32::from_black_alpha(160)));- k += 1; }+ // The name pill goes over the thumbnails.+ crate::edit_window::draw_clip_labels(painter, s, tl, lane, clip); } }