Draw grid lines over clips in the Edit window

The Edit window painted the bar and grid lines on the empty lane and then
the opaque clip bodies on top, so the grid vanished wherever an audio or
MIDI clip sat. Pro Tools draws grid lines over the clips.

- While grid lines are shown, the lines are drawn after the clips (audio,
  MIDI, video and their overlays) and before automation and the selection.
- The lines are a translucent white (Dark) or black (Light), worked out
  from the palette's line and lane colours, so a line on an empty lane looks
  as before and waveforms and notes stay readable under it. Bar lines now
  also stand out where a grid line lands on them; the opaque grid line used
  to cover them.
- With grid lines hidden, the bar lines stay behind the clips as before.

Test: tests/grid_lines.rs renders the demo Edit window in both themes and
checks that the lines over the Kick clip are painted after it only while
grid lines are shown, and that each one looks like the theme's line colour
on an empty lane.
This commit is contained in:
grovebit
2026-10-12 20:43:09 +01:00
parent 6cf896e76c
commit bebca3aa85
2 changed files with 92 additions and 3 deletions
+22 -3
View File
@@ -697,7 +697,10 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect)
let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect()));
painter.rect_filled(lane, 0.0, if selected { t.playlist_alt } else { t.playlist_bg });
let s = app.engine.session().clone();
grid_lines(&painter, &s, tl, lane);
// Shown grid lines go over the clips; otherwise the bar lines stay behind them.
if !s.edit.grid_lines {
grid_lines(&painter, &s, tl, lane);
}
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);
for clip in track.clips() {
@@ -707,6 +710,9 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect)
draw_clip(app, &painter, &s, &track, clip, tl, lane, auto_view.is_some());
}
crate::video_track::draw_lane(app, &painter, &s, &track, tl, lane);
if s.edit.grid_lines {
grid_lines(&painter, &s, tl, lane);
}
if let Some(p) = &auto_view {
draw_automation(&painter, &s, &track, p, tl, lane);
}
@@ -1130,22 +1136,35 @@ fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect) {
let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false };
let bar_px = bar.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
if bar_px > 6.0 {
let col = grid_veil(t.bar_line, t.playlist_bg);
for at in bar.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 600) {
let x = x_of(s, tl, at);
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, t.bar_line));
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col));
}
}
if s.edit.grid_lines {
let step_px = s.edit.grid.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
if step_px > 7.0 {
let col = grid_veil(t.grid_line, t.playlist_bg);
for at in s.edit.grid.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 2000) {
let x = x_of(s, tl, at);
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, t.grid_line));
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col));
}
}
}
}
/// The translucent white or black that turns `bg` into `line`, so grid lines also show over clips.
fn grid_veil(line: Color32, bg: Color32) -> Color32 {
let level = |c: Color32| (f32::from(c.r()) + f32::from(c.g()) + f32::from(c.b())) / 3.0;
let (l, b) = (level(line), level(bg));
if l > b {
Color32::from_white_alpha(((l - b) / (255.0 - b) * 255.0).round() as u8)
} else {
Color32::from_black_alpha(((b - l) / b.max(1.0) * 255.0).round() as u8)
}
}
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);
+70
View File
@@ -0,0 +1,70 @@
use egui::{Color32, FullOutput, Pos2, RawInput, Rect, Shape, pos2, vec2};
use serde_json::json;
use soundcraft_ui_egui::theme::Tokens;
use soundcraft_ui_egui::{Services, SoundApp, edit_window};
fn frame(ctx: &egui::Context, app: &mut SoundApp) -> FullOutput {
let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
out
}
fn over(top: Color32, bg: Color32) -> [i32; 3] {
let keep = 255 - i32::from(top.a());
[(top.r(), bg.r()), (top.g(), bg.g()), (top.b(), bg.b())].map(|(t, b)| i32::from(t) + i32::from(b) * keep / 255)
}
fn near(a: [i32; 3], b: Color32) -> bool {
a.iter().zip([b.r(), b.g(), b.b()]).all(|(x, y)| (x - i32::from(y)).abs() <= 6)
}
#[test]
fn grid_lines_are_drawn_over_clips_while_shown() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
for theme in ["dark", "light"] {
for shown in [true, false] {
app.run("ui.theme", json!({"mode": theme})).unwrap();
app.run("view.grid_lines", json!({"value": shown})).unwrap();
frame(&ctx, &mut app);
let out = frame(&ctx, &mut app);
let t = Tokens::current();
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 (cx0, cx1) = (edit_window::x_of(&s, tl, clip.start), edit_window::x_of(&s, tl, clip.end()));
let body = out
.shapes
.iter()
.position(|c| matches!(&c.shape, Shape::Rect(r) if r.rect.min.x == cx0 && r.rect.max.x == cx1 && r.rect.min.y == lane.min.y + 1.0))
.unwrap();
let lines: Vec<(usize, Color32)> = out
.shapes
.iter()
.enumerate()
.filter_map(|(i, c)| match &c.shape {
Shape::LineSegment { points: [a, b], stroke }
if a.x == b.x && a.x > cx0 + 1.0 && a.x < cx1 - 1.0 && a.y == lane.min.y && b.y == lane.max.y =>
{
Some((i, stroke.color))
}
_ => None,
})
.collect();
assert!(lines.len() > 4, "{theme} {shown}: {} lines", lines.len());
assert!(lines.iter().all(|(i, _)| (*i > body) == shown), "{theme} {shown}");
for (_, col) in &lines {
let look = over(*col, t.playlist_bg);
assert!(near(look, t.grid_line) || near(look, t.bar_line), "{theme} {shown}: {look:?}");
}
}
}
}