Files
oak-gpui/crates/gpui/src/timeline/timeline_view.rs
T

1474 lines
47 KiB
Rust

//! The timeline view: layout, interaction handling, and edit-request events.
//!
//! [`TimelineView`] is the top-level widget. It is generic over the app's
//! [`TimelineDataSource`] implementation and owns a [`TimelineState`] for
//! view-local state (zoom, scroll, playhead, selection).
//!
//! # Layout
//!
//! ```text
//! ┌──────────────────────────────────────────────────────┐
//! │ ruler (timecode ticks, work-area band, markers) │
//! ├─────────┬────────────────────────────────────────────┤
//! │ track │ clip area (per-track rows) │
//! │ headers │ ┌──────┐ ┌─────────┐ │
//! │ V1 │ │ clip │ │ clip │ ┆ playhead │
//! │ V2 │ ┌──────────┐ ┆ │
//! │ A1 │ ┌─────────┐ ┆ │
//! ├─────────┴────────────────────────────────────────────┤
//! │ horizontal scrollbar │
//! └──────────────────────────────────────────────────────┘
//! ```
//!
//! The ruler is painted by [`TimelineRuler`](super::TimelineRuler), headers
//! by [`TrackHeader`](super::TrackHeader), clips by
//! [`ClipElement`](super::ClipElement), and the playhead by
//! [`PlayheadElement`](super::PlayheadElement). The clip area lays out one
//! row per track from the data source, sized and stacked purely from
//! [`TrackData::height`](super::TrackData::height).
//!
//! # Interactions
//!
//! * **Ruler click** — seek: moves the playhead to the pointed frame
//! (through [`TimelineState::set_playhead`]) and emits
//! [`TimelineEvent::PlayheadChanged`].
//! * **Clip drag** — move: horizontal motion shifts the clip in time;
//! vertical motion across a track boundary requests a cross-track move
//! (never onto a locked track). While dragging, the clip snaps to nearby
//! snap points (clip edges, playhead, work-area edges, markers) when
//! [`TimelineState::snap_enabled`]. On mouse-up the widget emits
//! [`TimelineEvent::ClipMoveRequested`]; the host expands the request to
//! the clip's transitive [`ClipData::linked_ids`](super::ClipData::linked_ids)
//! group — and the widget does not change anything itself.
//! * **Clip edge drag** — trim: the ~6 px hit zones at each clip edge start
//! a trim gesture. Emits [`TimelineEvent::ClipTrimRequested`]; the host
//! expands the request to linked clips and adjusts `media_in` for
//! [`TrimEdge::Start`].
//! * **Marquee** — rubber-band selection: dragging on empty clip-area space
//! draws a selection rect; on release the hit clips go through
//! [`TimelineState::select_range`] and [`TimelineEvent::SelectionChanged`]
//! is emitted.
//! * **Scroll wheel** — horizontal pan; **ctrl-scroll / pinch** — zoom
//! anchored at the cursor via [`TimelineState::set_zoom`], emitting
//! [`TimelineEvent::ZoomChanged`].
//! * **Header separator drag** — track height resize, emitting
//! [`TimelineEvent::TrackHeightChanged`].
//!
//! # Edit model (read this before wiring events)
//!
//! The widget **never mutates the model**. Every gesture ends in a
//! [`TimelineEvent`] describing the requested edit. The host applies it
//! through its engine and undo stack — keeping the engine the single source
//! of truth — and then calls `cx.notify()` on the data-source entity, which
//! makes the view re-read and repaint. If the engine rejects the edit
//! (locked track, collision, …), simply don't notify and the gesture has no
//! visible effect.
use std::collections::BTreeSet;
use std::sync::{Arc, RwLock};
use crate::{
AnyElement, App, Context, DragMoveEvent, ElementId, Entity, EventEmitter, FocusHandle,
Focusable, Hsla, MouseButton, MouseDownEvent, PinchEvent, Pixels, Point, Render, ScrollDelta,
ScrollWheelEvent, SharedString, Window, div, hsla, prelude::*, px,
};
use super::{
clip::{ClipContent, ClipDecorator, ClipElement, NoopClipDecorator, TRIM_HANDLE_WIDTH},
data::{ClipData, ClipId, TimelineDataSource, TrackData, TrackKind},
playhead::PlayheadElement,
ruler::{RulerMarker, TimelineRuler},
state::TimelineState,
time::{Frame, FrameRange, SnapKind, SnapPoint, snap},
track_header::TrackHeader,
};
/// Which edge of a clip a trim gesture grabbed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TrimEdge {
/// The clip's left (in) edge. Trimming it changes both the clip's start
/// and its [`ClipData::media_in`](super::ClipData::media_in).
Start,
/// The clip's right (out) edge.
End,
}
/// An edit or view-state change requested by the timeline widget.
///
/// The widget emits these and then **does nothing itself**. The host
/// application applies the request through its engine and undo stack, then
/// calls `cx.notify()` on the data-source entity so the view re-reads.
///
/// # Wiring into Oak
///
/// Each variant maps onto an undoable facade call in `oakengine`:
///
/// * `ClipMoveRequested` → `engine.move_clip(clip, new_track, new_start)`
/// (ripple off), wrapped in an undo command together with the clip's
/// linked group.
/// * `ClipTrimRequested` → `engine.trim_clip(clip, edge, new_frame)`,
/// likewise grouped with linked clips.
/// * `PlayheadChanged` → `engine.set_playhead(frame)` — not undoable.
/// * `SelectionChanged` → update the app's selection state; drives the
/// inspector/effect stack panels.
/// * `TrackHeightChanged` / `ZoomChanged` → persist as per-sequence view
/// state; not undoable.
#[derive(Debug, Clone, PartialEq)]
pub enum TimelineEvent {
/// The user asked to move a clip (and, per the gesture docs on
/// [`TimelineView`], its linked group) to a new position.
///
/// `new_track` is an index into
/// [`TimelineDataSource::track`];
/// `new_start` is the requested [`FrameRange`](super::FrameRange) start
/// after snapping. The engine must validate collisions and track
/// compatibility; the widget only guarantees the source track was not
/// locked.
ClipMoveRequested {
/// The clip the gesture grabbed. The host expands this to the full
/// linked group.
clip: ClipId,
/// Target track index.
new_track: usize,
/// Requested new start frame.
new_start: Frame,
},
/// The user asked to trim one edge of a clip.
///
/// `new_frame` is the requested new position of the grabbed `edge`,
/// after snapping and after clamping to the neighboring clips and to
/// zero minimum length. For [`TrimEdge::Start`] the host must also
/// adjust `media_in` by the same delta.
ClipTrimRequested {
/// The trimmed clip (expand to the linked group, as with moves).
clip: ClipId,
/// Which edge was grabbed.
edge: TrimEdge,
/// Requested new frame position of that edge.
new_frame: Frame,
},
/// The playhead moved, by any means (ruler seek, keyboard, playback
/// ticker). Carries the new position. Not undoable.
PlayheadChanged(Frame),
/// The selection changed. The new set is readable from
/// [`TimelineView::selection`]. Not undoable.
SelectionChanged,
/// A track header was clicked, toggling the track's selection state.
///
/// The widget keeps the selected-track set locally (readable from
/// [`TimelineView::selected_tracks`]); the host may persist it. Not
/// undoable.
TrackSelected {
/// Index of the clicked track.
track: usize,
/// Whether the track is now selected.
selected: bool,
},
/// The user dragged a transition wedge's edge to change its length.
///
/// `new_length` is the requested transition duration in frames, clamped
/// to the clip length and to a one-frame minimum. The host validates
/// against the actual transition implementation and re-notifies.
TransitionChanged {
/// The clip whose transition was grabbed.
clip: ClipId,
/// Which edge the transition sits on.
edge: TrimEdge,
/// Requested new transition length.
new_length: Frame,
},
/// A track's height changed via header-separator drag. The host should
/// write this back so [`TrackData::height`](super::TrackData::height)
/// returns it on the next read.
TrackHeightChanged {
/// Index of the resized track.
track: usize,
/// New row height.
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),
}
/// The video-editing timeline widget.
///
/// Construct with [`TimelineView::new`] over an [`Entity`] of your
/// [`TimelineDataSource`] implementation, place it in your layout like any
/// other view, and subscribe to its [`TimelineEvent`]s to apply edits:
///
/// ```ignore
/// let timeline = cx.new(|cx| TimelineView::new(model.clone(), window, cx));
/// cx.subscribe(&timeline, |this, timeline, event: &TimelineEvent, cx| {
/// match event {
/// TimelineEvent::ClipMoveRequested { clip, new_track, new_start } => {
/// this.engine.move_clip(*clip, *new_track, *new_start, cx); // undoable
/// this.model.update(cx, |_, cx| cx.notify());
/// }
/// // ...
/// }
/// }).detach();
/// ```
///
/// See the module-level docs for the layout, the full interaction list, and
/// the edit-request contract.
pub struct TimelineView<D: TimelineDataSource> {
source: Entity<D>,
/// View-local state (zoom, scroll, playhead, selection). Public for
/// read access; mutate through [`TimelineState`]'s methods to preserve
/// invariants.
pub state: TimelineState,
/// The set of tracks selected via their headers.
selected_tracks: BTreeSet<usize>,
/// Rich clip content (thumbnails / waveforms), replaced by the host.
decorator: std::sync::Arc<std::sync::RwLock<dyn ClipDecorator>>,
focus_handle: FocusHandle,
}
/// Width of the track-headers column, in pixels.
const HEADER_WIDTH: f32 = 160.0;
/// Minimum row height enforced by the height-resize drag, in pixels.
const MIN_TRACK_HEIGHT: f32 = 24.0;
/// Snap engagement threshold, in pixels.
const SNAP_THRESHOLD_PX: f32 = 8.0;
impl<D: TimelineDataSource> TimelineView<D> {
/// Creates a timeline view over `source`.
///
/// Subscribes to `source`'s notifications: after the host applies an
/// edit and calls `cx.notify()` on the source entity, the view re-reads
/// all data and repaints.
pub fn new(source: Entity<D>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
cx.observe(&source, |_this, _source, cx| cx.notify())
.detach();
TimelineView {
source,
state: TimelineState::new(),
selected_tracks: BTreeSet::new(),
decorator: std::sync::Arc::new(std::sync::RwLock::new(NoopClipDecorator)),
focus_handle,
}
}
/// Builder: installs the host's rich-clip decorator (M12 P4 — Oak's
/// waveform decorator). The default is the no-op decorator.
pub fn clip_decorator(
mut self,
decorator: std::sync::Arc<std::sync::RwLock<dyn ClipDecorator>>,
) -> Self {
self.decorator = decorator;
self
}
/// Replaces the clip decorator after construction (the app wires its
/// waveform cache once the engine is up).
pub fn set_clip_decorator(&mut self, decorator: std::sync::Arc<std::sync::RwLock<dyn ClipDecorator>>) {
self.decorator = decorator;
}
/// The set of tracks selected via header clicks.
pub fn selected_tracks(&self) -> &BTreeSet<usize> {
&self.selected_tracks
}
/// Builder: sets the initial zoom (pixels per frame), clamped to
/// [`MIN_ZOOM`](super::MIN_ZOOM)..=[`MAX_ZOOM`](super::MAX_ZOOM).
pub fn zoom(mut self, zoom: f32) -> Self {
self.state.set_zoom(zoom, crate::px(0.));
self
}
/// Builder: enables or disables snapping initially.
pub fn snap_enabled(mut self, enabled: bool) -> Self {
self.state.snap_enabled = enabled;
self
}
/// The data source entity this view was created over.
pub fn source(&self) -> &Entity<D> {
&self.source
}
/// The current selection, in deterministic (id) order.
pub fn selection(&self) -> &BTreeSet<ClipId> {
&self.state.selection
}
/// Seeks the playhead to `frame`, clamped to the sequence, emitting
/// [`TimelineEvent::PlayheadChanged`] if the position changed.
pub fn seek(&mut self, frame: Frame, cx: &mut Context<Self>) {
let seq_len = self.sequence_length(cx);
let old = self.state.playhead;
self.state.set_playhead(frame, seq_len);
if self.state.playhead != old {
cx.emit(TimelineEvent::PlayheadChanged(self.state.playhead));
cx.notify();
}
}
/// The sequence length from the data source.
fn sequence_length(&self, cx: &mut Context<Self>) -> Frame {
self.source.read(cx).sequence_length()
}
/// The current range of the clip with `id`, scanned from the data source.
/// Falls back to an empty range when the clip no longer exists.
fn clip_range(&self, id: ClipId, cx: &mut Context<Self>) -> FrameRange {
let source = self.source.read(cx);
for index in 0..source.track_count() {
if let Some(track) = source.track(index) {
for clip in track.clips() {
if clip.id() == id {
return clip.range();
}
}
}
}
FrameRange::new(Frame::ZERO, Frame::ZERO)
}
/// Snap points gathered fresh from the data source: work-area edges,
/// markers, the playhead, and every enabled clip edge. `dragged` (the
/// clip being moved) is excluded so a clip never snaps to itself.
fn snap_points(&self, dragged: Option<ClipId>, cx: &mut Context<Self>) -> Vec<SnapPoint> {
let source = self.source.read(cx);
let mut points = Vec::new();
if let Some(area) = self.state.work_area {
points.push(SnapPoint {
frame: area.start,
kind: SnapKind::WorkAreaEdge,
});
points.push(SnapPoint {
frame: area.end,
kind: SnapKind::WorkAreaEdge,
});
}
for marker in source.markers() {
points.push(SnapPoint {
frame: marker.frame,
kind: SnapKind::Marker,
});
}
points.push(SnapPoint {
frame: self.state.playhead,
kind: SnapKind::Playhead,
});
for index in 0..source.track_count() {
if let Some(track) = source.track(index) {
for clip in track.clips() {
if clip.is_enabled() && Some(clip.id()) != dragged {
let range = clip.range();
points.push(SnapPoint {
frame: range.start,
kind: SnapKind::ClipStart,
});
points.push(SnapPoint {
frame: range.end,
kind: SnapKind::ClipEnd,
});
}
}
}
}
points
}
/// Index of the track whose row contains screen `y` (relative to the
/// clip area's top). Falls back to the last track when below all rows.
fn track_at_y(&self, y: f32, cx: &mut Context<Self>) -> usize {
let source = self.source.read(cx);
let count = source.track_count();
let mut acc = 0.0;
for index in 0..count {
if let Some(track) = source.track(index) {
acc += track.height().0.max(MIN_TRACK_HEIGHT);
if y < acc {
return index;
}
}
}
count.saturating_sub(1)
}
/// Whether the track at `index` is locked. Out-of-range tracks count as
/// locked so drop requests onto them are rejected.
fn track_locked(&self, index: usize, cx: &mut Context<Self>) -> bool {
self.source
.read(cx)
.track(index)
.map(|track| track.is_locked())
.unwrap_or(true)
}
/// Updates a clip-move drag from the pointer position: computes the new
/// start frame (with snapping) and the track under the cursor.
fn update_clip_drag(
&mut self,
event: &DragMoveEvent<Arc<RwLock<ClipDrag>>>,
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("clip drag lock is not poisoned");
let wrapper_x = self.state.point_at_frame(drag.original_start).0;
let dx = now.x.0 - (wrapper_x + press.x.0);
let mut new_start = Frame(drag.original_start.0 + (dx / self.state.zoom).round() as i64);
let new_track = self.track_at_y(now.y.0, cx);
if self.state.snap_enabled {
if let Some(result) = snap(
new_start,
self.snap_points(Some(drag.clip), cx).into_iter(),
px(SNAP_THRESHOLD_PX),
self.state.zoom,
) {
new_start = result.frame;
}
}
drag.new_start = new_start;
drag.new_track = new_track;
}
/// Updates a trim drag from the pointer position: computes the new
/// position of the grabbed edge, clamped to keep the clip non-empty and
/// inside the sequence, then snapped and re-clamped.
fn update_trim_drag(
&mut self,
event: &DragMoveEvent<Arc<RwLock<TrimDrag>>>,
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 seq_len = self.sequence_length(cx);
let mut drag = drag.write().expect("trim drag lock is not poisoned");
let range = self.clip_range(drag.clip, cx);
let handle_x = match drag.edge {
TrimEdge::Start => now.x.0 - press.x.0,
TrimEdge::End => now.x.0 - press.x.0 + TRIM_HANDLE_WIDTH,
};
let mut new_frame = self.state.frame_at_point(px(handle_x));
let clamp = |frame: Frame| match drag.edge {
TrimEdge::Start => frame.max(Frame::ZERO).min(Frame((range.end.0 - 1).max(0))),
TrimEdge::End => frame
.max(Frame((range.start.0 + 1).min(seq_len.0)))
.min(seq_len),
};
new_frame = clamp(new_frame);
if self.state.snap_enabled {
if let Some(result) = snap(
new_frame,
self.snap_points(Some(drag.clip), cx).into_iter(),
px(SNAP_THRESHOLD_PX),
self.state.zoom,
) {
new_frame = clamp(result.frame);
}
}
drag.new_frame = new_frame;
}
/// Updates a track-height drag from the pointer position.
fn update_height_drag(
&mut self,
event: &DragMoveEvent<Arc<RwLock<HeightDrag>>>,
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("height drag lock is not poisoned");
let dy = now.y.0 - (press.y.0 + drag.separator_y);
drag.new_height = px((drag.start_height.0 + dy).max(MIN_TRACK_HEIGHT));
}
/// Updates a marquee selection from the pointer position: hit-tests the
/// rows intersecting the rubber-band rect and selects the clips inside.
fn update_marquee(
&mut self,
event: &DragMoveEvent<MarqueeDrag>,
rows: &[RowData],
cx: &mut Context<Self>,
) {
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 frame_start = self.state.frame_at_point(px(press.x.0));
let mut frame_end = self.state.frame_at_point(px(now.x.0));
if frame_start > frame_end {
std::mem::swap(&mut frame_start, &mut frame_end);
}
let y0 = press.y.min(now.y).0;
let y1 = press.y.max(now.y).0;
let mut ids = BTreeSet::new();
for row in rows {
if y1 >= row.y && y0 <= row.y + row.height {
for clip in &row.clips {
if clip.enabled
&& clip.range.end.0 > frame_start.0
&& clip.range.start.0 < frame_end.0
{
ids.insert(clip.id);
}
}
}
}
self.state.select_range(ids);
}
/// Emits [`TimelineEvent::ClipMoveRequested`] for a finished clip move,
/// unless the gesture didn't move the clip or a locked track was involved.
fn finish_clip_drag(&mut self, drag: &Arc<RwLock<ClipDrag>>, cx: &mut Context<Self>) {
let (clip, original_start, original_track, new_start, new_track) = {
let drag = drag.read().expect("clip drag lock is not poisoned");
(
drag.clip,
drag.original_start,
drag.original_track,
drag.new_start,
drag.new_track,
)
};
if (new_start, new_track) != (original_start, original_track)
&& !self.track_locked(original_track, cx)
&& !self.track_locked(new_track, cx)
{
cx.emit(TimelineEvent::ClipMoveRequested {
clip,
new_track,
new_start,
});
cx.notify();
}
}
/// Emits [`TimelineEvent::ClipTrimRequested`] for a finished trim.
fn finish_trim_drag(&mut self, drag: &Arc<RwLock<TrimDrag>>, cx: &mut Context<Self>) {
let (clip, edge, original_frame, new_frame) = {
let drag = drag.read().expect("trim drag lock is not poisoned");
(drag.clip, drag.edge, drag.original_frame, drag.new_frame)
};
if new_frame != original_frame {
cx.emit(TimelineEvent::ClipTrimRequested {
clip,
edge,
new_frame,
});
cx.notify();
}
}
/// Apply a transition-resize drag move: scale the requested length by the
/// horizontal mouse delta and clamp to the clip length.
fn update_transition_drag(
&mut self,
event: &DragMoveEvent<Arc<RwLock<TransitionDrag>>>,
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 dx = now.x - press.x;
let clip_len = {
let range = self.clip_range(drag.read().unwrap().clip, cx);
range.end - range.start
};
let mut drag = drag.write().expect("transition drag lock is not poisoned");
let frames_per_pixel = 1.0 / self.state.zoom as f64;
let delta = (dx.0 as f64 * frames_per_pixel).round() as i64;
let requested = drag.original_length.0 + delta;
drag.new_length = Frame(requested.clamp(1, clip_len.0.max(1)));
cx.notify();
}
/// Emits [`TimelineEvent::TransitionChanged`] for a finished resize.
fn finish_transition_drag(
&mut self,
drag: &Arc<RwLock<TransitionDrag>>,
cx: &mut Context<Self>,
) {
let (clip, edge, original_length, new_length) = {
let drag = drag.read().expect("transition drag lock is not poisoned");
(drag.clip, drag.edge, drag.original_length, drag.new_length)
};
if new_length != original_length {
cx.emit(TimelineEvent::TransitionChanged {
clip,
edge,
new_length,
});
cx.notify();
}
}
/// Emits [`TimelineEvent::TrackHeightChanged`] for a finished resize.
fn finish_height_drag(&mut self, drag: &Arc<RwLock<HeightDrag>>, cx: &mut Context<Self>) {
let (track, start_height, new_height) = {
let drag = drag.read().expect("height drag lock is not poisoned");
(drag.track, drag.start_height, drag.new_height)
};
if new_height.0 != start_height.0 {
cx.emit(TimelineEvent::TrackHeightChanged {
track,
height: new_height,
});
cx.notify();
}
}
/// 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);
cx.notify();
}
// --- interaction handlers (private) ---
//
// The gesture logic lives in the render method's inline listeners and
// the `update_*` / `finish_*` helpers above; these signatures are
// retained (with `#[allow(dead_code)]`) as documentation of the
// gesture set the view wires up.
#[allow(dead_code)] // gesture documentation; see the section comment above
fn on_ruler_mouse_down(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
#[allow(dead_code)] // gesture documentation; see the section comment above
fn on_clip_drag_move(&mut self, _clip: ClipId, _window: &mut Window, _cx: &mut Context<Self>) {}
#[allow(dead_code)] // gesture documentation; see the section comment above
fn on_clip_drag_drop(&mut self, _clip: ClipId, _window: &mut Window, _cx: &mut Context<Self>) {}
#[allow(dead_code)] // gesture documentation; see the section comment above
fn on_trim_drag(
&mut self,
_clip: ClipId,
_edge: TrimEdge,
_window: &mut Window,
_cx: &mut Context<Self>,
) {
}
#[allow(dead_code)] // gesture documentation; see the section comment above
fn on_marquee(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
#[allow(dead_code)] // gesture documentation; see the section comment above
fn on_scroll(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
}
impl<D: TimelineDataSource> Focusable for TimelineView<D> {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl<D: TimelineDataSource> EventEmitter<TimelineEvent> for TimelineView<D> {}
impl<D: TimelineDataSource> Render for TimelineView<D> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let state = self.state.clone();
let source = self.source.read(cx);
let frame_rate = source.frame_rate();
let seq_len = source.sequence_length();
// Snapshot the rows: one per track, stacked from the top of the clip
// area, with y positions accumulated from the model's track heights.
let mut rows: Vec<RowData> = Vec::new();
let mut y = 0.0;
for index in 0..source.track_count() {
if let Some(track) = source.track(index) {
let height = track.height().0.max(MIN_TRACK_HEIGHT);
let clips = track
.clips()
.iter()
.map(|clip| {
let color = clip.color().unwrap_or_else(|| kind_color(track.kind()));
let in_transition = clip
.in_transition()
.filter(|duration| duration.0 > 0)
.map(|duration| FrameRange::new(Frame::ZERO, duration));
let out_transition = clip
.out_transition()
.filter(|duration| duration.0 > 0)
.map(|duration| FrameRange::new(Frame::ZERO, duration));
ClipRenderData {
id: clip.id(),
range: clip.range(),
label: clip.label(),
color,
enabled: clip.is_enabled(),
in_transition,
out_transition,
}
})
.collect();
rows.push(RowData {
index,
name: track.name(),
kind: track.kind(),
height,
y,
locked: track.is_locked(),
muted: track.is_muted(),
solo: track.is_solo(),
visible: track.is_visible(),
clips,
});
y += height;
}
}
// The marquee handler needs the row geometry; keep a snapshot for it
// (the clip-area child iterator consumes `rows` below).
let marquee_rows = Arc::new(rows.clone());
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()
.h(px(32.))
.flex_shrink_0()
.child(div().w(px(HEADER_WIDTH)).flex_shrink_0())
.child(
div()
.flex_1()
.h_full()
.id("timeline-ruler")
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, event: &MouseDownEvent, _window, cx| {
// The ruler's left edge aligns with the clip
// area's, i.e. HEADER_WIDTH px from the window's
// left edge (this widget is expected to sit at
// window x = 0).
let frame = this
.state
.frame_at_point(event.position.x - px(HEADER_WIDTH));
this.seek(frame, cx);
}),
)
.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()
.w(px(HEADER_WIDTH))
.flex_shrink_0()
.flex()
.flex_col()
.id("timeline-track-headers")
.on_drag_move(cx.listener(
|this, event: &DragMoveEvent<Arc<RwLock<HeightDrag>>>, _window, cx| {
this.update_height_drag(event, cx);
},
))
.on_drop(
cx.listener(|this, drag: &Arc<RwLock<HeightDrag>>, _window, cx| {
this.finish_height_drag(drag, cx);
}),
)
.on_drag_move(cx.listener(
|this, event: &DragMoveEvent<Arc<RwLock<TransitionDrag>>>, _window, cx| {
this.update_transition_drag(event, cx);
},
))
.on_drop(
cx.listener(|this, drag: &Arc<RwLock<TransitionDrag>>, _window, cx| {
this.finish_transition_drag(drag, cx);
}),
)
.children(rows.iter().map(|row| {
let height = row.height;
let separator_y = row.y + height - TrackHeader::SEPARATOR_HEIGHT;
div()
.h(px(height))
.relative()
.child(
div()
.id(ElementId::named_usize("timeline-track-header", row.index))
.h(px(height - TrackHeader::SEPARATOR_HEIGHT))
.cursor_pointer()
.on_click({
let track = row.index;
cx.listener(move |this, _event: &crate::ClickEvent, _window, cx| {
let selected = if this.selected_tracks.contains(&track) {
this.selected_tracks.remove(&track);
false
} else {
this.selected_tracks.insert(track);
true
};
cx.emit(TimelineEvent::TrackSelected { track, selected });
cx.notify();
})
})
.child(
TrackHeader::new(row.index, row.name.clone(), row.kind)
.locked(row.locked)
.muted(row.muted)
.solo(row.solo)
.visible(row.visible),
),
)
.child(
div()
.absolute()
.left(px(0.))
.right(px(0.))
.bottom(px(0.))
.h(px(TrackHeader::SEPARATOR_HEIGHT))
.id(ElementId::named_usize("timeline-track-resize", row.index))
.cursor_row_resize()
.on_drag(
Arc::new(RwLock::new(HeightDrag {
track: row.index,
start_height: px(height),
new_height: px(height),
separator_y,
})),
drag_ghost,
),
)
}));
let clip_area = div()
.flex_1()
.h_full()
.id("timeline-clip-area")
.relative()
.overflow_hidden()
.flex()
.flex_col()
.on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| {
if event.modifiers.control || event.modifiers.platform {
let anchor = event.position.x - px(HEADER_WIDTH);
let factor = match event.delta {
ScrollDelta::Pixels(delta) => 1.0 + delta.y.0 * 0.01,
ScrollDelta::Lines(delta) => 1.0 + delta.y * 0.01,
}
.clamp(0.5, 2.0);
let old = this.state.zoom;
this.state.set_zoom(old * factor, anchor);
if (this.state.zoom - old).abs() > f32::EPSILON {
cx.emit(TimelineEvent::ZoomChanged(this.state.zoom));
}
} else {
let dx = match event.delta {
ScrollDelta::Pixels(delta) => delta.y.0,
ScrollDelta::Lines(delta) => delta.y * 24.0,
};
this.state.scroll_offset.x = px((this.state.scroll_offset.x.0 + dx).max(0.0));
}
cx.notify();
}))
.on_pinch(cx.listener(|this, event: &PinchEvent, _window, cx| {
let anchor = event.position.x - px(HEADER_WIDTH);
let old = this.state.zoom;
this.state.set_zoom(old * (1.0 + event.delta), anchor);
if (this.state.zoom - old).abs() > f32::EPSILON {
cx.emit(TimelineEvent::ZoomChanged(this.state.zoom));
}
cx.notify();
}))
.on_drag(MarqueeDrag, drag_ghost)
.on_drag_move(cx.listener(
|this, event: &DragMoveEvent<Arc<RwLock<ClipDrag>>>, _window, cx| {
this.update_clip_drag(event, cx);
},
))
.on_drag_move(cx.listener(
|this, event: &DragMoveEvent<Arc<RwLock<TrimDrag>>>, _window, cx| {
this.update_trim_drag(event, cx);
},
))
.on_drag_move({
cx.listener(
move |this, event: &DragMoveEvent<MarqueeDrag>, _window, cx| {
this.update_marquee(event, marquee_rows.as_slice(), cx);
},
)
})
.on_drop(
cx.listener(|this, drag: &Arc<RwLock<ClipDrag>>, _window, cx| {
this.finish_clip_drag(drag, cx);
}),
)
.on_drop(
cx.listener(|this, drag: &Arc<RwLock<TrimDrag>>, _window, cx| {
this.finish_trim_drag(drag, cx);
}),
)
.on_drop(cx.listener(|this, _drag: &MarqueeDrag, _window, cx| {
this.finish_marquee(cx);
}))
.on_drop(
cx.listener(|this, drag: &Arc<RwLock<HeightDrag>>, _window, cx| {
this.finish_height_drag(drag, cx);
}),
)
.children(rows.into_iter().map(move |row| {
let height = row.height;
let row_locked = row.locked;
let kind = row.kind;
let row_index = row.index;
let state = &state;
let decorator = &decorator;
div()
.h(px(height))
.relative()
.children(row.clips.into_iter().map(move |clip| {
let x0 = state.point_at_frame(clip.range.start).0;
let x1 = state.point_at_frame(clip.range.end).0;
let width = (x1 - x0).max(1.0);
let clip_height = (height - TrackHeader::SEPARATOR_HEIGHT).max(1.0);
let mut children: Vec<AnyElement> = vec![
ClipElement::new(
clip.id,
clip.label.clone(),
clip.color,
clip.in_transition,
clip.out_transition,
decorator.clone(),
)
.selected(state.is_selected(clip.id))
.enabled(clip.enabled)
.locked(row_locked)
.content(match kind {
TrackKind::Video => ClipContent::Thumbnails,
TrackKind::Audio => ClipContent::Waveform,
TrackKind::Subtitle => ClipContent::None,
})
.into_any_element(),
];
if !row_locked {
children.push(
div()
.absolute()
.left(px(0.))
.top(px(0.))
.bottom(px(0.))
.w(px(TRIM_HANDLE_WIDTH))
.id(ElementId::named_usize(
"timeline-trim-start",
clip.id.0 as usize,
))
.cursor_ew_resize()
.on_drag(
Arc::new(RwLock::new(TrimDrag {
clip: clip.id,
edge: TrimEdge::Start,
original_frame: clip.range.start,
new_frame: clip.range.start,
})),
drag_ghost,
)
.into_any_element(),
);
children.push(
div()
.absolute()
.right(px(0.))
.top(px(0.))
.bottom(px(0.))
.w(px(TRIM_HANDLE_WIDTH))
.id(ElementId::named_usize(
"timeline-trim-end",
clip.id.0 as usize,
))
.cursor_ew_resize()
.on_drag(
Arc::new(RwLock::new(TrimDrag {
clip: clip.id,
edge: TrimEdge::End,
original_frame: clip.range.end,
new_frame: clip.range.end,
})),
drag_ghost,
)
.into_any_element(),
);
}
// Transition-resize handles sit at the wedges' edges.
if let Some(transition) = clip.in_transition {
children.push(
div()
.absolute()
.left(px(0.))
.top(px(0.))
.bottom(px(0.))
.w(px(TRIM_HANDLE_WIDTH))
.id(ElementId::named_usize(
"timeline-transition-handle",
clip.id.0 as usize,
))
.cursor_ew_resize()
.on_drag(
Arc::new(RwLock::new(TransitionDrag {
clip: clip.id,
edge: TrimEdge::Start,
original_length: transition.end,
new_length: transition.end,
})),
drag_ghost,
)
.into_any_element(),
);
}
if let Some(transition) = clip.out_transition {
children.push(
div()
.absolute()
.right(px(0.))
.top(px(0.))
.bottom(px(0.))
.w(px(TRIM_HANDLE_WIDTH))
.id(ElementId::named_usize(
"timeline-transition-handle",
clip.id.0 as usize,
))
.cursor_ew_resize()
.on_drag(
Arc::new(RwLock::new(TransitionDrag {
clip: clip.id,
edge: TrimEdge::End,
original_length: transition.end,
new_length: transition.end,
})),
drag_ghost,
)
.into_any_element(),
);
}
div()
.absolute()
.left(px(x0))
// Slight vertical inset: the design's clips read
// as rounded bars with a small gap between rows,
// not full-height slabs. The corners are rounded
// in ClipElement's painted quads (content masks
// are rectangular only).
.top(px(2.))
.w(px(width))
.h(px((clip_height - 4.0).max(1.0)))
.overflow_hidden()
.id(ElementId::named_usize("timeline-clip", clip.id.0 as usize))
.on_drag(
Arc::new(RwLock::new(ClipDrag {
clip: clip.id,
original_start: clip.range.start,
original_track: row_index,
new_start: clip.range.start,
new_track: row_index,
})),
drag_ghost,
)
.children(children)
}))
}));
let playhead = div()
.absolute()
.left(px(HEADER_WIDTH + playhead_x))
.top(px(0.))
.bottom(px(0.))
.w(px(1.))
.child(PlayheadElement::new(px(0.), playhead_color()));
div().size_full().flex().flex_col().child(ruler).child(
div()
.flex()
.flex_row()
.flex_1()
.relative()
.child(headers)
.child(clip_area)
.child(playhead),
)
}
}
/// 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 {
clip: ClipId,
original_start: Frame,
original_track: usize,
new_start: Frame,
new_track: usize,
}
/// Shared state for a trim gesture; see [`TrimEdge`].
struct TrimDrag {
clip: ClipId,
edge: TrimEdge,
original_frame: Frame,
new_frame: Frame,
}
/// In-flight payload of a transition-resize drag.
struct TransitionDrag {
clip: ClipId,
edge: TrimEdge,
original_length: Frame,
new_length: Frame,
}
/// Shared state for a track-height resize gesture.
struct HeightDrag {
track: usize,
start_height: Pixels,
new_height: Pixels,
/// The separator strip's top edge, relative to the headers column's top.
separator_y: f32,
}
/// Marker type for a marquee (rubber-band) selection gesture.
struct MarqueeDrag;
/// Layout snapshot of one track row, captured during render.
#[derive(Clone)]
struct RowData {
index: usize,
name: SharedString,
kind: TrackKind,
height: f32,
y: f32,
locked: bool,
muted: bool,
solo: bool,
visible: bool,
clips: Vec<ClipRenderData>,
}
/// Layout snapshot of one clip, captured during render.
#[derive(Clone)]
struct ClipRenderData {
id: ClipId,
range: FrameRange,
label: SharedString,
color: Hsla,
enabled: bool,
in_transition: Option<FrameRange>,
out_transition: Option<FrameRange>,
}
/// The ghost rendered under the cursor during any timeline drag.
struct DragPreview;
impl Render for DragPreview {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().bg(hsla(0.6, 0.7, 0.9, 0.35))
}
}
/// Builds the drag ghost view for a drag of any value type.
fn drag_ghost<T>(
_drag: &T,
_offset: Point<Pixels>,
_window: &mut Window,
cx: &mut App,
) -> Entity<DragPreview> {
cx.new(|_cx| DragPreview)
}
/// The playhead line color.
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 {
TrackKind::Video => hsla(0.58, 0.45, 0.35, 1.0),
TrackKind::Audio => hsla(0.35, 0.45, 0.35, 1.0),
TrackKind::Subtitle => hsla(0.10, 0.45, 0.35, 1.0),
}
}