From 47c6126d0c09fde8031b20cd5fc73f007f41e576 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 9 Aug 2026 06:22:15 +0800 Subject: [PATCH] feat(gpui): add track selection and transition-resize gestures to timeline W5 timeline supplements: clicking a track header toggles the track in a selected_tracks set and emits TimelineEvent::TrackSelected; clips with in/out transitions get a resize handle at the wedge edge that emits TimelineEvent::TransitionChanged with the clamped new length (delta-scaled by zoom). Both follow the request-only contract. 182 gpui tests pass. --- crates/gpui/src/timeline/timeline_view.rs | 182 +++++++++++++++++++++- 1 file changed, 177 insertions(+), 5 deletions(-) diff --git a/crates/gpui/src/timeline/timeline_view.rs b/crates/gpui/src/timeline/timeline_view.rs index 3bd17d408a..612bc7599c 100644 --- a/crates/gpui/src/timeline/timeline_view.rs +++ b/crates/gpui/src/timeline/timeline_view.rs @@ -157,6 +157,32 @@ pub enum TimelineEvent { /// [`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. @@ -199,6 +225,8 @@ pub struct TimelineView { /// read access; mutate through [`TimelineState`]'s methods to preserve /// invariants. pub state: TimelineState, + /// The set of tracks selected via their headers. + selected_tracks: BTreeSet, focus_handle: FocusHandle, } @@ -223,10 +251,16 @@ impl TimelineView { TimelineView { source, state: TimelineState::new(), + selected_tracks: BTreeSet::new(), focus_handle, } } + /// 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 { @@ -529,6 +563,49 @@ impl TimelineView { } } + /// 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) = { @@ -683,6 +760,18 @@ impl Render for TimelineView { 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; @@ -690,11 +779,34 @@ impl Render for TimelineView { .h(px(height)) .relative() .child( - TrackHeader::new(row.index, row.name.clone(), row.kind) - .locked(row.locked) - .muted(row.muted) - .solo(row.solo) - .visible(row.visible), + 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() @@ -883,6 +995,58 @@ impl Render for TimelineView { .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)) @@ -947,6 +1111,14 @@ struct TrimDrag { 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,