//! 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 { source: Entity, /// 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, /// Rich clip content (thumbnails / waveforms), replaced by the host. decorator: std::sync::Arc>, 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 TimelineView { /// 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, _window: &mut Window, cx: &mut Context) -> 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>, ) -> 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>) { self.decorator = decorator; } /// The set of tracks selected via header clicks. pub fn selected_tracks(&self) -> &BTreeSet { &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 { &self.source } /// The current selection, in deterministic (id) order. pub fn selection(&self) -> &BTreeSet { &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) { 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) -> 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) -> 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, cx: &mut Context) -> Vec { 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) -> 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) -> 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>>, 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("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>>, 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 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>>, 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("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, rows: &[RowData], cx: &mut Context, ) { 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>, cx: &mut Context) { 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>, cx: &mut Context) { 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>>, 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 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>, cx: &mut Context, ) { 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>, cx: &mut Context) { 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>>, 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); 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) {} #[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) {} #[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) {} #[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, ) { } #[allow(dead_code)] // gesture documentation; see the section comment above fn on_marquee(&mut self, _window: &mut Window, _cx: &mut Context) {} #[allow(dead_code)] // gesture documentation; see the section comment above fn on_scroll(&mut self, _window: &mut Window, _cx: &mut Context) {} } impl Focusable for TimelineView { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } impl EventEmitter for TimelineView {} impl Render for TimelineView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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 = 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 = 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>>, _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() .w(px(HEADER_WIDTH)) .flex_shrink_0() .flex() .flex_col() .id("timeline-track-headers") .on_drag_move(cx.listener( |this, event: &DragMoveEvent>>, _window, cx| { this.update_height_drag(event, cx); }, )) .on_drop( cx.listener(|this, drag: &Arc>, _window, cx| { this.finish_height_drag(drag, cx); }), ) .on_drag_move(cx.listener( |this, event: &DragMoveEvent>>, _window, cx| { this.update_transition_drag(event, cx); }, )) .on_drop( cx.listener(|this, drag: &Arc>, _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>>, _window, cx| { this.update_clip_drag(event, cx); }, )) .on_drag_move(cx.listener( |this, event: &DragMoveEvent>>, _window, cx| { this.update_trim_drag(event, cx); }, )) .on_drag_move({ cx.listener( move |this, event: &DragMoveEvent, _window, cx| { this.update_marquee(event, marquee_rows.as_slice(), cx); }, ) }) .on_drop( cx.listener(|this, drag: &Arc>, _window, cx| { this.finish_clip_drag(drag, cx); }), ) .on_drop( cx.listener(|this, drag: &Arc>, _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>, _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 = 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, /// 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 { 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, } /// 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, out_transition: Option, } /// 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) -> 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( _drag: &T, _offset: Point, _window: &mut Window, cx: &mut App, ) -> Entity { 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, 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), } }