From 3325521a85a829fccd5d2da76a00392cad3a57f0 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Thu, 20 Aug 2026 23:45:22 +0800 Subject: [PATCH] feat(effect_stack,timeline,node_graph): library drag-drop, params view caching, clip click select - effect_stack: the params view is cached per effect (recreation per render killed in-progress slider drags and clicks); new LibraryEffectDrag payload + AddTypeRequested event let an effect dragged from the app's effect library drop onto the stack at the indicator position - timeline: left-press selects clips (plain press selects an unselected clip, keeps multi-selection on a selected one for group drags, Ctrl/Cmd toggles membership) - node_graph: graph_position_at() converts a window-space point (drop position) to graph space --- crates/gpui/examples/learn/effect_stack.rs | 1 + crates/gpui/src/effect_stack/data.rs | 12 +++ crates/gpui/src/effect_stack/stack_view.rs | 100 +++++++++++++++++++-- crates/gpui/src/node_graph/graph_view.rs | 8 ++ crates/gpui/src/timeline/timeline_view.rs | 39 ++++++++ 5 files changed, 151 insertions(+), 9 deletions(-) diff --git a/crates/gpui/examples/learn/effect_stack.rs b/crates/gpui/examples/learn/effect_stack.rs index 2e9b7e0043..ae57e4c50b 100644 --- a/crates/gpui/examples/learn/effect_stack.rs +++ b/crates/gpui/examples/learn/effect_stack.rs @@ -197,6 +197,7 @@ impl StackDemoRoot { EffectStackEvent::ReorderRequested { .. } | EffectStackEvent::RemoveRequested(_) | EffectStackEvent::AddRequested { .. } + | EffectStackEvent::AddTypeRequested { .. } | EffectStackEvent::ContextMenuRequested { .. } | EffectStackEvent::CardSelected { .. } | EffectStackEvent::ParameterChanged { .. } => { diff --git a/crates/gpui/src/effect_stack/data.rs b/crates/gpui/src/effect_stack/data.rs index 4357ce8fa4..4d5a64cd0a 100644 --- a/crates/gpui/src/effect_stack/data.rs +++ b/crates/gpui/src/effect_stack/data.rs @@ -39,6 +39,18 @@ impl From for EffectId { } } +/// Drag payload of an effect dragged out of the app's effect library onto +/// an effect stack (or node graph): the factory/plugin type id to insert +/// plus the display name (used by the drag ghost). Dropping it on a stack +/// emits [`EffectStackEvent::AddTypeRequested`](crate::effect_stack::EffectStackEvent::AddTypeRequested). +#[derive(Clone, Debug)] +pub struct LibraryEffectDrag { + /// The addable-effect type id (factory entry or OFX plugin identifier). + pub type_id: SharedString, + /// The display name, shown on the drag ghost. + pub name: SharedString, +} + /// Which role a card plays in the linear chain. /// /// A well-formed stack is exactly one [`Source`](EffectCardKind::Source) diff --git a/crates/gpui/src/effect_stack/stack_view.rs b/crates/gpui/src/effect_stack/stack_view.rs index a421a17757..a79bb704d7 100644 --- a/crates/gpui/src/effect_stack/stack_view.rs +++ b/crates/gpui/src/effect_stack/stack_view.rs @@ -15,16 +15,19 @@ use crate::{ }; use super::card::{EffectCard, InsertIndicator}; -use super::data::{EffectCardKind, EffectId, EffectStackDataSource}; +use super::data::{EffectCardKind, EffectId, EffectStackDataSource, LibraryEffectDrag}; /// Callback the app registers with /// [`EffectStackView::params_renderer`] to render the parameter controls of /// one effect inside its expanded card. /// -/// Called during render for every expanded card. The returned [`AnyView`] -/// is placed in the card's content slot; its size drives the expanded -/// height of the card. Return any empty view (e.g. [`crate::div()`]'s -/// default) to render a blank parameter area. +/// Called when a card's parameter area is first shown; the returned +/// [`AnyView`] is **cached per effect** (so stateful controls — sliders, +/// checkboxes, text fields — survive re-renders and in-progress drags) +/// and pruned when the effect leaves the stack. The view is placed in the +/// card's content slot; its size drives the expanded height of the card. +/// Return any empty view (e.g. [`crate::div()`]'s default) to render a +/// blank parameter area. pub type ParamsRenderer = Rc AnyView>; /// Edit requests emitted by [`EffectStackView`]. @@ -86,6 +89,17 @@ pub enum EffectStackEvent { /// Insertion index into the current card list. index: usize, }, + /// The user dropped an effect dragged from the app's effect library + /// onto the stack at a stack position (the payload is + /// [`LibraryEffectDrag`](crate::effect_stack::LibraryEffectDrag)). + /// Unlike [`AddRequested`](Self::AddRequested) the effect type is + /// already chosen, so the app inserts it directly at `index`. + AddTypeRequested { + /// Insertion index into the current card list. + index: usize, + /// The dropped effect's type id. + type_id: SharedString, + }, /// The user secondary-clicked a card. The app owns the menu itself — /// the view only reports where and on which card it happened. ContextMenuRequested { @@ -158,6 +172,13 @@ pub struct DragState { pub struct EffectStackView { data: Entity, params_renderer: Option, + /// Per-effect cache of the params view: the renderer creates stateful + /// child entities (sliders, checkboxes, text fields), so recreating the + /// view on every render would destroy an in-progress drag or click the + /// moment any edit notifies. Entries are created on first expansion and + /// pruned when their effect leaves the stack; value re-syncs are the + /// view's own job (it observes the engine). + params_views: std::collections::HashMap, focus_handle: FocusHandle, drag_state: DragState, } @@ -173,6 +194,7 @@ impl EffectStackView { Self { data, params_renderer: None, + params_views: std::collections::HashMap::new(), focus_handle: cx.focus_handle(), drag_state: DragState::default(), } @@ -239,8 +261,7 @@ impl EffectStackView { card_id: EffectId, event: &DragMoveEvent, cx: &mut Context, - ) { - let effects = self.data.read(cx).effects(); + ) { let effects = self.data.read(cx).effects(); let dragged = *event.drag(cx); self.drag_state.dragged = Some(dragged); if !event.bounds.contains(&event.event.position) { @@ -281,6 +302,30 @@ impl EffectStackView { cx.notify(); } + /// Tracks an effect dragged in from the app's effect library + /// ([`LibraryEffectDrag`]): the insertion indicator follows the pointer + /// exactly like a card reorder, except nothing is being removed (the + /// index addresses the current card list directly). + fn update_library_drag( + &mut self, + card_id: EffectId, + event: &DragMoveEvent, + cx: &mut Context, + ) { + let effects = self.data.read(cx).effects(); + if !event.bounds.contains(&event.event.position) { + return; + } + let Some(j) = effects.iter().position(|e| e.id() == card_id) else { + self.drag_state.insertion_index = None; + cx.notify(); + return; + }; + let insert_before = event.event.position.y < event.bounds.center().y; + self.drag_state.insertion_index = Some(if insert_before { j } else { j + 1 }); + cx.notify(); + } + /// Toggles a card's expansion by emitting /// [`EffectStackEvent::ExpansionToggled`], and reports the card click as /// [`EffectStackEvent::CardSelected`] so hosts can sync the node-graph @@ -375,6 +420,11 @@ impl Render for EffectStackView { let data = self.data.read(cx); (data.target_label(), data.effects(), data.selected_effect()) }; + // Prune cached params views whose effect left the stack (remove / + // undo); live effects keep their view across re-renders so an + // in-progress slider drag or click survives edit notifications. + self.params_views + .retain(|id, _| effects.iter().any(|e| e.id() == *id)); let insertion_index = self.drag_state.insertion_index; let dragged_id = self.drag_state.dragged; let i0 = dragged_id.and_then(|d| effects.iter().position(|e| e.id() == d)); @@ -410,6 +460,11 @@ impl Render for EffectStackView { this.drag_state.insertion_index = None; cx.notify(); })) + .on_drag_move::(cx.listener(|this, _event, _window, cx| { + // Same capture-phase clear for an effect-library drag. + this.drag_state.insertion_index = None; + cx.notify(); + })) .on_mouse_up_out( MouseButton::Left, cx.listener(|this, _event, _window, cx| this.cancel_drag(cx)), @@ -575,12 +630,17 @@ impl Render for EffectStackView { if expanded && !fixed { if let Some(renderer) = ¶ms_renderer { + let params = self + .params_views + .entry(id) + .or_insert_with(|| renderer(&id, window, cx)) + .clone(); wrapper = wrapper.child( div() .id(ElementId::named_usize("effect-params", id.0 as usize)) .border_t_1() .border_color(colors.separator) - .child(renderer(&id, window, cx)), + .child(params), ); } } @@ -603,7 +663,29 @@ impl Render for EffectStackView { cx.notify(); }, )) - .can_drop(|payload, _window, _cx| payload.is::()); + // An effect dragged in from the app's effect library + // lands at the indicator position. + .on_drag_move::(cx.listener( + move |this, event, _window, cx| { + this.update_library_drag(id, event, cx); + }, + )) + .on_drop::(cx.listener( + move |this, dragged: &LibraryEffectDrag, _window, cx| { + let index = this.drag_state.insertion_index; + this.drag_state = DragState::default(); + if let Some(index) = index { + cx.emit(EffectStackEvent::AddTypeRequested { + index, + type_id: dragged.type_id.clone(), + }); + } + cx.notify(); + }, + )) + .can_drop(|payload, _window, _cx| { + payload.is::() || payload.is::() + }); } column = column.child(wrapper); diff --git a/crates/gpui/src/node_graph/graph_view.rs b/crates/gpui/src/node_graph/graph_view.rs index e10b89a7b4..4e2831230c 100644 --- a/crates/gpui/src/node_graph/graph_view.rs +++ b/crates/gpui/src/node_graph/graph_view.rs @@ -303,6 +303,14 @@ impl NodeGraphView { &self.state } + /// The graph-space position of a window-space point (e.g. the pointer + /// position of a drop landing on the canvas). Uses the viewport origin + /// captured at the last prepaint. + pub fn graph_position_at(&self, window_position: Point) -> Point { + self.state + .screen_to_graph(window_position - self.viewport.origin) + } + /// Returns the size of the canvas the graph was last painted into, or a /// zero size before the first frame. Hosts use this to fit the viewport /// to the graph (see [`GraphViewState::fit_to_rect`]). diff --git a/crates/gpui/src/timeline/timeline_view.rs b/crates/gpui/src/timeline/timeline_view.rs index 67c4d87c39..a30112b978 100644 --- a/crates/gpui/src/timeline/timeline_view.rs +++ b/crates/gpui/src/timeline/timeline_view.rs @@ -1423,6 +1423,45 @@ impl Render for TimelineView { .h(px((clip_height - 4.0).max(1.0))) .overflow_hidden() .id(ElementId::named_usize("timeline-clip", clip.id.0 as usize)) + .on_mouse_down( + MouseButton::Left, + { + let view = weak_view.clone(); + let id = clip.id; + move |event: &MouseDownEvent, _window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| { + // NLE selection on press: a plain + // press on an unselected clip + // selects just it; a press on an + // already-selected clip keeps the + // multi-selection (group drags + // work); Ctrl/Cmd toggles + // membership. + let changed = if event + .modifiers + .secondary() + { + !this.state.selection.remove(&id) + && { + this.state.selection.insert(id); + true + } + } else if !this.state.selection.contains(&id) { + this.state.selection.clear(); + this.state.selection.insert(id) + } else { + false + }; + if changed { + cx.emit(TimelineEvent::SelectionChanged); + cx.notify(); + } + }); + } + } + }, + ) .on_mouse_down( MouseButton::Right, {