diff --git a/crates/gpui/src/timeline/ruler.rs b/crates/gpui/src/timeline/ruler.rs index f8b804a519..c8e94cd02a 100644 --- a/crates/gpui/src/timeline/ruler.rs +++ b/crates/gpui/src/timeline/ruler.rs @@ -19,7 +19,7 @@ //! shorter and unlabeled. use crate::{ - App, Bounds, Font, SharedString, TextAlign, TextRun, Window, canvas, fill, hsla, point, + App, Bounds, Font, Hsla, SharedString, TextAlign, TextRun, Window, canvas, fill, hsla, point, prelude::*, px, size, }; @@ -28,6 +28,15 @@ use super::{ time::{Frame, FrameRange, FrameRate, TimeDisplay}, }; +/// A marker to paint on the ruler, at a sequence frame with its color. +#[derive(Debug, Clone)] +pub struct RulerMarker { + /// The marker's frame position. + pub frame: Frame, + /// The marker's color (the theme accent is the fallback). + pub color: Hsla, +} + /// The sequence ruler rendered above the tracks. /// /// Cheap to construct — all fields are plain data copied out of the view @@ -42,6 +51,8 @@ pub struct TimelineRuler { frame_rate: FrameRate, sequence_length: Frame, display: TimeDisplay, + /// Markers to paint (from the data source), in ascending frame order. + markers: Vec, } impl TimelineRuler { @@ -61,6 +72,7 @@ impl TimelineRuler { frame_rate, sequence_length, display: TimeDisplay::default(), + markers: Vec::new(), } } @@ -71,6 +83,13 @@ impl TimelineRuler { self } + /// Builder: the markers to paint (sequence markers from the data + /// source). Drawn as small diamonds below the tick baseline. + pub fn markers(mut self, markers: Vec) -> Self { + self.markers = markers; + self + } + /// The tick step (in frames) the ruler would choose at the given zoom. /// /// Exposed for tests and for snapping the playhead-drag indicator to the @@ -142,6 +161,8 @@ struct RulerContent { /// Local x-extents `(left, right)` of the work-area band, if a work /// area is set. work_area: Option<(f32, f32)>, + /// Marker positions `(x, color)` for the visible markers. + markers: Vec<(f32, Hsla)>, } impl RenderOnce for TimelineRuler { @@ -197,6 +218,15 @@ impl RenderOnce for TimelineRuler { } } + // Markers within the visible span, in ascending frame order + // (the data source provides them sorted). + let markers = self + .markers + .iter() + .filter(|m| m.frame >= first && m.frame <= last) + .map(|m| (state.point_at_frame(m.frame).0, m.color)) + .collect(); + RulerContent { ticks, work_area: work_area.map(|range| { @@ -205,6 +235,7 @@ impl RenderOnce for TimelineRuler { state.point_at_frame(range.end).0, ) }), + markers, } }, move |bounds, content, window, cx| { @@ -235,6 +266,26 @@ impl RenderOnce for TimelineRuler { } } + // Markers as small diamonds hanging below the tick baseline. + let marker_half: f32 = 3.0; + for (x, color) in &content.markers { + let cx = bounds.left() + px(*x); + let cy = bounds.bottom() - px(4.0); + let mut path = crate::path_builder::PathBuilder::fill(); + path.add_polygon( + &[ + point(cx, cy - px(marker_half)), + point(cx + px(marker_half), cy), + point(cx, cy + px(marker_half)), + point(cx - px(marker_half), cy), + ], + true, + ); + if let Ok(path) = path.build() { + window.paint_path(path, *color); + } + } + // Baseline along the bottom of the ruler. window.paint_quad(fill( Bounds { diff --git a/crates/gpui/src/timeline/timeline_view.rs b/crates/gpui/src/timeline/timeline_view.rs index a8540f68f8..8ec3053159 100644 --- a/crates/gpui/src/timeline/timeline_view.rs +++ b/crates/gpui/src/timeline/timeline_view.rs @@ -77,7 +77,7 @@ use super::{ clip::{ClipContent, ClipDecorator, ClipElement, NoopClipDecorator, TRIM_HANDLE_WIDTH}, data::{ClipData, ClipId, TimelineDataSource, TrackData, TrackKind}, playhead::PlayheadElement, - ruler::TimelineRuler, + ruler::{RulerMarker, TimelineRuler}, state::TimelineState, time::{Frame, FrameRange, SnapKind, SnapPoint, snap}, track_header::TrackHeader, @@ -193,6 +193,31 @@ pub enum TimelineEvent { height: Pixels, }, + /// The user is dragging on the ruler, reshaping the work area. `start` / + /// `end` are the NEW in/out frames; the widget already updated its local + /// band (`[`TimelineState::work_area`]`), and the host should apply the + /// new range **live** (not undoable), mirroring Olive's drag behavior. + WorkAreaPreview { + /// The new work-area in point. + start: Frame, + /// The new work-area out point. + end: Frame, + }, + + /// The ruler work-area drag finished. The host commits the new range as + /// ONE undoable entry whose "old" side is `old_start`/`old_end` (the + /// range at drag start, before any live preview was applied). + WorkAreaCommitted { + /// The committed work-area in point. + start: Frame, + /// The committed work-area out point. + end: Frame, + /// The in point at drag start (the undo side of the command). + old_start: Frame, + /// The out point at drag start (the undo side of the command). + old_end: Frame, + }, + /// The zoom (pixels per frame) changed via ctrl-scroll or pinch. /// Persist as view state if desired. ZoomChanged(f32), @@ -645,6 +670,104 @@ impl TimelineView { } } + /// How close (in pixels) a press must be to a work-area band edge for the + /// drag to resize that edge instead of reshaping the whole band. + const WORK_AREA_EDGE_THRESHOLD_PX: f32 = 6.0; + + /// Updates a ruler work-area drag from the pointer position. + /// + /// The first drag that passes the click/drag threshold (see + /// [`RulerDrag`]) reshapes the work area: pressing near an existing band + /// edge moves that edge, any other press replaces the whole band with the + /// range spanned from the press point. The widget updates its local band + /// immediately and emits [`TimelineEvent::WorkAreaPreview`] so the host + /// can apply the range live (not undoable). A plain click never reaches + /// here — it only seeks (the ruler's `on_mouse_down`). + fn update_ruler_drag( + &mut self, + event: &DragMoveEvent>>, + cx: &mut Context, + ) { + let drag = Arc::clone(event.drag(cx)); + let press = cx + .active_drag + .as_ref() + .map(|drag| drag.cursor_offset) + .unwrap_or_default(); + let now = event.event.position - event.bounds.origin; + let mut drag = drag.write().expect("ruler drag lock is not poisoned"); + let dx = (now.x.0 - press.x.0).abs(); + if dx < 3.0 { + // Still within the click threshold; keep seeking-only behavior. + return; + } + if !drag.resizing { + // First real drag movement: pick what the gesture reshapes. A + // press within `WORK_AREA_EDGE_THRESHOLD_PX` of an existing band + // edge moves that edge; otherwise the whole band is replaced. + let edge = match self.state.work_area { + Some(band) => { + let in_edge = self.state.point_at_frame(band.start).0; + let out_edge = self.state.point_at_frame(band.end).0; + if (press.x.0 - in_edge).abs() <= Self::WORK_AREA_EDGE_THRESHOLD_PX { + EdgeKind::In + } else if (press.x.0 - out_edge).abs() <= Self::WORK_AREA_EDGE_THRESHOLD_PX { + EdgeKind::Out + } else { + EdgeKind::Whole + } + } + None => EdgeKind::Whole, + }; + drag.edge = Some(edge); + drag.old_band = self.state.work_area; + drag.resizing = true; + } + let Some(edge) = drag.edge else { + return; + }; + let seq_len = self.sequence_length(cx).0.max(1); + let frame = self.state.frame_at_point(now.x).0.clamp(0, seq_len); + let press_frame = self.state.frame_at_point(press.x).0.clamp(0, seq_len); + let band = reshape_work_area(drag.old_band, Frame(press_frame), Frame(frame), edge, seq_len); + self.state.work_area = Some(band); + drag.new_band = Some(band); + cx.emit(TimelineEvent::WorkAreaPreview { + start: band.start, + end: band.end, + }); + cx.notify(); + } + + /// Emits [`TimelineEvent::WorkAreaCommitted`] for a finished ruler drag: + /// the host commits the new range as ONE undoable entry whose old side is + /// the band at drag start (`0..0` when the drag created a new band). + fn finish_ruler_drag(&mut self, drag: &Arc>, cx: &mut Context) { + let (resizing, old_band, new_band) = { + let drag = drag.read().expect("ruler drag lock is not poisoned"); + (drag.resizing, drag.old_band, drag.new_band) + }; + if !resizing { + return; + } + let Some(new) = new_band else { + return; + }; + // A drag that created a new band (no old one) reports the empty + // `0..0` range as its old side; the host's undoable commit restores + // disabled + the empty range on undo. + let old = old_band.unwrap_or(FrameRange::new(Frame::ZERO, Frame::ZERO)); + if old != new { + cx.emit(TimelineEvent::WorkAreaCommitted { + start: new.start, + end: new.end, + old_start: old.start, + old_end: old.end, + }); + cx.notify(); + } + } + /// Emits [`TimelineEvent::SelectionChanged`] for a finished marquee. fn finish_marquee(&mut self, cx: &mut Context) { cx.emit(TimelineEvent::SelectionChanged); @@ -752,6 +875,18 @@ impl Render for TimelineView { let playhead_x = state.point_at_frame(state.playhead).0; let decorator = self.decorator.clone(); + // Sequence markers → ruler paint data (the data source provides them + // sorted; the ruler colors them, falling back to the accent). + let marker_color = marker_accent_color(); + let markers: Vec = source + .markers() + .into_iter() + .map(|m| RulerMarker { + frame: m.frame, + color: m.color.unwrap_or(marker_color), + }) + .collect(); + let ruler = div() .flex() .flex_row() @@ -776,7 +911,29 @@ impl Render for TimelineView { this.seek(frame, cx); }), ) - .child(TimelineRuler::new(state.clone(), frame_rate, seq_len)), + .on_drag( + Arc::new(RwLock::new(RulerDrag { + old_band: None, + new_band: None, + edge: None, + resizing: false, + })), + drag_ghost, + ) + .on_drag_move(cx.listener( + |this, event: &DragMoveEvent>>, _window, cx| { + this.update_ruler_drag(event, cx); + }, + )) + .on_drop( + cx.listener(|this, drag: &Arc>, _window, cx| { + this.finish_ruler_drag(drag, cx); + }), + ) + .child( + TimelineRuler::new(state.clone(), frame_rate, seq_len) + .markers(markers), + ), ); let headers = div() @@ -1116,6 +1273,32 @@ impl Render for TimelineView { } } +/// Which edge of a work-area band a ruler drag reshapes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EdgeKind { + /// The band's in (left) edge. + In, + /// The band's out (right) edge. + Out, + /// The whole band, replaced by the press-to-cursor range. + Whole, +} + +/// Shared state for a ruler work-area drag. The drag starts on every ruler +/// press (alongside the seek), but only starts reshaping once the pointer +/// moves past the click threshold. +struct RulerDrag { + /// The work-area band at drag start; `None` when the drag creates a new + /// band. + old_band: Option, + /// The band as the pointer moved. + new_band: Option, + /// What the gesture reshapes, decided on the first real movement. + edge: Option, + /// Whether the pointer has passed the click threshold. + resizing: bool, +} + /// Shared state for a clip-move gesture (the clip wrapper starts it, the /// clip area updates and finishes it). struct ClipDrag { @@ -1205,6 +1388,81 @@ fn playhead_color() -> Hsla { hsla(0.0, 0.0, 0.9, 0.9) } +/// Reshapes the work-area band for a ruler drag move. +/// +/// * `band` — the band at drag start (`None` = the drag creates a new one). +/// * `press_frame` / `current_frame` — the clamped press and pointer frames. +/// * `edge` — what the gesture reshapes (see [`EdgeKind`]). +/// +/// `In`/`Out` keep at least one frame of band length; `Whole` replaces the +/// band with the press-to-pointer range, always `[start, end)` with +/// `end > start`. +fn reshape_work_area( + band: Option, + press_frame: Frame, + current_frame: Frame, + edge: EdgeKind, + seq_len: i64, +) -> FrameRange { + let mut band = band.unwrap_or_else(|| FrameRange::new(Frame::ZERO, Frame(seq_len))); + match edge { + EdgeKind::In => band.start = Frame(current_frame.0.min((band.end.0 - 1).max(0))), + EdgeKind::Out => band.end = Frame(current_frame.0.max(band.start.0 + 1)), + EdgeKind::Whole => { + band = if press_frame.0 <= current_frame.0 { + FrameRange::new( + press_frame, + Frame(current_frame.0.max(press_frame.0 + 1)), + ) + } else { + FrameRange::new(current_frame, Frame(press_frame.0.max(current_frame.0 + 1))) + }; + } + } + band +} + +/// The default marker color (used when a marker carries no explicit color): +/// the theme-agnostic amber accent, distinct from the playhead and the +/// work-area band. +fn marker_accent_color() -> Hsla { + hsla(0.10, 0.85, 0.55, 1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reshape_work_area_moves_edges_within_bounds() { + let band = FrameRange::new(Frame(20), Frame(80)); + // In edge: clamped so the band never collapses or inverts. + let reshaped = reshape_work_area(Some(band), Frame(20), Frame(50), EdgeKind::In, 1000); + assert_eq!(reshaped, FrameRange::new(Frame(50), Frame(80))); + let reshaped = reshape_work_area(Some(band), Frame(20), Frame(95), EdgeKind::In, 1000); + assert_eq!(reshaped, FrameRange::new(Frame(79), Frame(80))); + // Out edge. + let reshaped = reshape_work_area(Some(band), Frame(80), Frame(40), EdgeKind::Out, 1000); + assert_eq!(reshaped, FrameRange::new(Frame(20), Frame(40))); + let reshaped = reshape_work_area(Some(band), Frame(80), Frame(10), EdgeKind::Out, 1000); + assert_eq!(reshaped, FrameRange::new(Frame(20), Frame(21))); + } + + #[test] + fn reshape_work_area_whole_replaces_or_creates_the_band() { + // Forward drag: press-to-cursor range. + let reshaped = reshape_work_area(Some(FrameRange::new(Frame(0), Frame(10))), Frame(30), Frame(70), EdgeKind::Whole, 1000); + assert_eq!(reshaped, FrameRange::new(Frame(30), Frame(70))); + // Backward drag: the range is normalized (start <= end). + let reshaped = reshape_work_area(Some(FrameRange::new(Frame(0), Frame(10))), Frame(70), Frame(30), EdgeKind::Whole, 1000); + assert_eq!(reshaped, FrameRange::new(Frame(30), Frame(70))); + // Creating a band from nothing uses the virtual 0..length band as the + // base for edge moves. + let reshaped = reshape_work_area(None, Frame(0), Frame(40), EdgeKind::Out, 1000); + assert_eq!(reshaped, FrameRange::new(Frame(0), Frame(40))); + } +} + /// The default body color for clips on a track of `kind`. fn kind_color(kind: TrackKind) -> Hsla { match kind {