feat(gpui): ruler markers + work-area drag reshape

This commit is contained in:
2026-08-16 16:06:19 +08:00
parent df726fd3e3
commit 6162b2fc50
2 changed files with 312 additions and 3 deletions
+52 -1
View File
@@ -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<RulerMarker>,
}
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<RulerMarker>) -> 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 {
+260 -2
View File
@@ -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<D: TimelineDataSource> TimelineView<D> {
}
}
/// 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<Arc<RwLock<RulerDrag>>>,
cx: &mut Context<Self>,
) {
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<RwLock<RulerDrag>>, cx: &mut Context<Self>) {
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<Self>) {
cx.emit(TimelineEvent::SelectionChanged);
@@ -752,6 +875,18 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
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<RulerMarker> = 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<D: TimelineDataSource> Render for TimelineView<D> {
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<Arc<RwLock<RulerDrag>>>, _window, cx| {
this.update_ruler_drag(event, cx);
},
))
.on_drop(
cx.listener(|this, drag: &Arc<RwLock<RulerDrag>>, _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<D: TimelineDataSource> Render for TimelineView<D> {
}
}
/// 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<FrameRange>,
/// The band as the pointer moved.
new_band: Option<FrameRange>,
/// What the gesture reshapes, decided on the first real movement.
edge: Option<EdgeKind>,
/// 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<FrameRange>,
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 {