Timeline: click a keyframe diamond to snap the playhead to it

render_layers now records each drawn keyframe diamond's screen rect + exact time in
`keyframe_diamond_hits`; handle_input hit-tests a click against them and sets the
playhead (and seeks the audio controller) to the keyframe's exact time. Uses the
previous frame's rects — diamonds don't move between frames, so the click lands
right — which sidesteps the input-before-render ordering and the drag/scroll Y math.
Works for both raster and vector keyframes.
This commit is contained in:
Skyler Lehmkuhl 2026-06-20 22:37:43 -04:00
parent 2cbaf67583
commit 3188fc8bb6
1 changed files with 35 additions and 0 deletions

View File

@ -251,6 +251,11 @@ pub struct TimelinePane {
/// Vertical scroll offset (in pixels)
viewport_scroll_y: f32,
/// Clickable keyframe-diamond hit targets `(screen_rect, keyframe_time)` collected
/// during the last `render_layers`. Used by `handle_input` (next frame) to snap the
/// playhead exactly to a keyframe when its diamond is clicked.
keyframe_diamond_hits: Vec<(egui::Rect, f64)>,
/// Total duration of the animation
duration: f64,
@ -773,6 +778,7 @@ impl TimelinePane {
pixels_per_second: 100.0,
viewport_start_time: 0.0,
viewport_scroll_y: 0.0,
keyframe_diamond_hits: Vec::new(),
duration: 10.0, // Default 10 seconds
is_scrubbing: false,
is_panning: false,
@ -2730,6 +2736,8 @@ impl TimelinePane {
) -> (Vec<(egui::Rect, uuid::Uuid, f64, f32)>, Vec<AutomationLaneRender>) {
let painter = ui.painter().clone();
let mut pending_lane_renders: Vec<AutomationLaneRender> = Vec::new();
// Rebuilt each frame; consumed by handle_input (next frame) for click-to-seek.
self.keyframe_diamond_hits.clear();
// Collect video clip rects for hover detection (to avoid borrow conflicts)
let mut video_clip_hovers: Vec<(egui::Rect, uuid::Uuid, f64, f32)> = Vec::new();
@ -3972,6 +3980,11 @@ impl TimelinePane {
color,
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".keyframe-diamond"], ui.ctx(), egui::Color32::from_rgb(180, 150, 50))),
));
// Record a (slightly enlarged) clickable area for click-to-seek.
self.keyframe_diamond_hits.push((
egui::Rect::from_center_size(egui::pos2(cx, cy), egui::vec2(13.0, 13.0)),
kf.time,
));
}
}
}
@ -3996,6 +4009,11 @@ impl TimelinePane {
color,
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".keyframe-diamond"], ui.ctx(), egui::Color32::from_rgb(180, 150, 50))),
));
// Record a (slightly enlarged) clickable area for click-to-seek.
self.keyframe_diamond_hits.push((
egui::Rect::from_center_size(egui::pos2(cx, cy), egui::vec2(13.0, 13.0)),
kf.time,
));
}
}
}
@ -4849,6 +4867,23 @@ impl TimelinePane {
}
}
// Click a keyframe diamond → snap the playhead exactly to that keyframe.
// (Hit targets come from the previous frame's render_layers; diamonds don't
// move between frames, so a click lands on the right one.)
if response.clicked() && !alt_held {
if let Some(pos) = response.interact_pointer_pos() {
if let Some(&(_, kf_time)) =
self.keyframe_diamond_hits.iter().find(|(r, _)| r.contains(pos))
{
*playback_time = kf_time;
if let Some(controller_arc) = audio_controller {
let mut controller = controller_arc.lock().unwrap();
controller.seek(kf_time);
}
}
}
}
// Get mouse position relative to content area
let mouse_pos = response.hover_pos().unwrap_or(content_rect.center());
let mouse_x = (mouse_pos.x - content_rect.min.x).max(0.0);