Marker names: a short duration stays visible beside the name box

When a named marker's duration ended inside the name box, the box's fill covered
the duration's end cap and most of its bar, so the duration could not be seen.
The box now steps past the end cap in that case (and stays at the flag when the
bar is longer than the box, or ends before it).

The new test puts a 0.1 s duration on a named marker, takes the timeline's scale
from a second marker, and checks that the cap and the bar are drawn and that the
box starts after the cap; it fails with the previous drawing. The two render
tests now take a lock, because two tests creating a GPU device at once crash
some Vulkan loaders.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Kristian MyramandClaude Sonnet 5.5 committed 2026-10-19 00:35:59 +02:00
1 parent 4124ec6de7
commit 79dec68d63
2 files changed
+57 -3

No files matched your search

+11 -3
View File
@@ -283,8 +283,8 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp:
let x = tm.x(comp_t);
let col = marker_color(&t, m.label);
let h = (y1 - y0).min(10.0);
if m.duration > Tick(0) {
let xe = tm.x(comp_t + m.duration.seconds());
let end = (m.duration > Tick(0)).then(|| tm.x(comp_t + m.duration.seconds()));
if let Some(xe) = end {
let bar = Rect::from_min_max(pos2(x, y0 + 1.0), pos2(xe, y0 + h - 1.0));
p.rect_filled(bar, 1.0, col.gamma_multiply(if m.protected { 0.35 } else { 0.5 }));
if m.protected {
@@ -301,7 +301,15 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp:
// The name sits in a small outlined box beside the flag, as in After Effects. It is drawn in the theme's field
// colours, so it follows Dark, Darker and Light.
let galley = p.layout_no_wrap(m.comment.lines().next().unwrap_or_default().to_string(), Tokens::ui(10.0), t.text);
let box_rect = Rect::from_min_size(pos2(x + 6.0, y0), vec2(galley.size().x + 8.0, h.max(galley.size().y + 2.0)));
let (box_w, mut box_x) = (galley.size().x + 8.0, x + 6.0);
// A duration that ends under the box would be hidden by its fill: the box steps past the end cap instead.
if let Some(xe) = end
&& xe > box_x
&& xe < box_x + box_w
{
box_x = xe + 3.0;
}
let box_rect = Rect::from_min_size(pos2(box_x, y0), vec2(box_w, h.max(galley.size().y + 2.0)));
p.rect(box_rect, t.radius_sm, t.field_bg, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
p.galley(pos2(box_rect.min.x + 4.0, box_rect.center().y - galley.size().y / 2.0), galley, t.text);
}
+46
View File
@@ -8,12 +8,16 @@ use effectcraft_ui_egui::theme::ThemeKind;
use egui_kittest::Harness;
use serde_json::json;
/// One render at a time: two tests creating a GPU device together crash some Vulkan loaders.
static RENDER: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn close(got: [u8; 4], want: egui::Color32) -> bool {
got[..3].iter().zip(want.to_array()).all(|(a, b)| a.abs_diff(b) <= 3)
}
#[test]
fn the_name_box_follows_the_theme() {
let _one_at_a_time = RENDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Song", "width": 640, "height": 360, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"name": "Bed", "color": "#204060", "width": 640, "height": 360})).unwrap();
@@ -49,3 +53,45 @@ fn the_name_box_follows_the_theme() {
}
assert_ne!(seen[0], seen[1], "the box looks different in Dark and in Light, so it is not a fixed grey");
}
/// A duration shorter than the name box ends where the box would sit. The box steps past the end cap, so the duration
/// stays visible: the cap is drawn, the bar shows left of it, and the box starts after it.
#[test]
fn a_short_duration_stays_visible_beside_the_name_box() {
let _one_at_a_time = RENDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Song", "width": 640, "height": 360, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"name": "Bed", "color": "#204060", "width": 640, "height": 360})).unwrap();
s.execute("markers.set", json!({"new": true, "time": 1.0, "comment": "Verse", "duration": 0.1})).unwrap();
// a second marker half a second later gives the timeline's scale (points per second)
s.execute("markers.set", json!({"new": true, "time": 1.5})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
h.run_steps(5);
let tokens = h.state().tokens;
let centre = |id: &str| {
let r = h.state().auto.find(id).unwrap_or_else(|| panic!("{id} is on screen")).rect;
(r[0] + r[2] / 2.0, r[1] + 1.0, (r[3] - 1.0).min(10.0))
};
let ((xa, y0, flag_h), (xb, _, _)) = (centre("timeline.marker.0"), centre("timeline.marker.1"));
let per_second = (xb - xa) / 0.5;
let xe = xa + 0.1 * per_second;
assert!(xe - xa < 30.0, "the duration is shorter than the name box: {} points", xe - xa);
let image = h.render().expect("the panel renders");
if let Some(dir) = std::env::var_os("MARKER_NAMES_SNAPSHOT") {
image.save(std::path::Path::new(&dir).join("marker-short-duration.png")).unwrap();
}
let scale = image.width() as f32 / 1600.0;
let at = |px: f32, py: f32| image.get_pixel((px * scale) as u32, (py * scale) as u32).0;
let mid = y0 + flag_h / 2.0;
// the end cap: a pixel in the marker's colour (new markers are red; the theme's field fill is neutral) within a point
// of where the duration ends, which the box would otherwise cover
let cap = (-1..=1).map(|d| at(xe + d as f32, mid)).max_by_key(|p| i32::from(p[0]) - i32::from(p[1])).unwrap();
assert!(i32::from(cap[0]) - i32::from(cap[1]) > 40, "the end cap is drawn: {cap:?}");
// the bar shows left of the cap, not the box's fill
let bar = at(xe - 4.0, mid);
assert!(i32::from(bar[0]) - i32::from(bar[1]) > 20, "the bar shows left of the end cap, not the box: {bar:?}");
// the box starts after the cap
let fill = at(xe + 3.0 + 2.5, mid);
assert!(close(fill, tokens.field_bg), "the box begins after the end cap: {fill:?}, field_bg {:?}", tokens.field_bg);
}