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.
This commit is contained in:
2026-08-09 06:22:15 +08:00
parent d97e3abd20
commit 47c6126d0c
+177 -5
View File
@@ -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<D: TimelineDataSource> {
/// 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>,
focus_handle: FocusHandle,
}
@@ -223,10 +251,16 @@ impl<D: TimelineDataSource> TimelineView<D> {
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<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 {
@@ -529,6 +563,49 @@ impl<D: TimelineDataSource> TimelineView<D> {
}
}
/// 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) = {
@@ -683,6 +760,18 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
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;
@@ -690,11 +779,34 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
.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<D: TimelineDataSource> Render for TimelineView<D> {
.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,