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
This commit is contained in:
2026-08-20 23:45:22 +08:00
parent 8a0b7569a3
commit 3325521a85
5 changed files with 151 additions and 9 deletions
@@ -197,6 +197,7 @@ impl StackDemoRoot {
EffectStackEvent::ReorderRequested { .. }
| EffectStackEvent::RemoveRequested(_)
| EffectStackEvent::AddRequested { .. }
| EffectStackEvent::AddTypeRequested { .. }
| EffectStackEvent::ContextMenuRequested { .. }
| EffectStackEvent::CardSelected { .. }
| EffectStackEvent::ParameterChanged { .. } => {
+12
View File
@@ -39,6 +39,18 @@ impl From<u64> 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)
+91 -9
View File
@@ -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<dyn Fn(&EffectId, &mut Window, &mut App) -> 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<D: EffectStackDataSource> {
data: Entity<D>,
params_renderer: Option<ParamsRenderer>,
/// 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<EffectId, AnyView>,
focus_handle: FocusHandle,
drag_state: DragState,
}
@@ -173,6 +194,7 @@ impl<D: EffectStackDataSource> EffectStackView<D> {
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<D: EffectStackDataSource> EffectStackView<D> {
card_id: EffectId,
event: &DragMoveEvent<EffectId>,
cx: &mut Context<Self>,
) {
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<D: EffectStackDataSource> EffectStackView<D> {
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<LibraryEffectDrag>,
cx: &mut Context<Self>,
) {
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<D: EffectStackDataSource> Render for EffectStackView<D> {
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<D: EffectStackDataSource> Render for EffectStackView<D> {
this.drag_state.insertion_index = None;
cx.notify();
}))
.on_drag_move::<LibraryEffectDrag>(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<D: EffectStackDataSource> Render for EffectStackView<D> {
if expanded && !fixed {
if let Some(renderer) = &params_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<D: EffectStackDataSource> Render for EffectStackView<D> {
cx.notify();
},
))
.can_drop(|payload, _window, _cx| payload.is::<EffectId>());
// An effect dragged in from the app's effect library
// lands at the indicator position.
.on_drag_move::<LibraryEffectDrag>(cx.listener(
move |this, event, _window, cx| {
this.update_library_drag(id, event, cx);
},
))
.on_drop::<LibraryEffectDrag>(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::<EffectId>() || payload.is::<LibraryEffectDrag>()
});
}
column = column.child(wrapper);
+8
View File
@@ -303,6 +303,14 @@ impl<D: NodeGraphDataSource + 'static> NodeGraphView<D> {
&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<Pixels>) -> Point<Pixels> {
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`]).
+39
View File
@@ -1423,6 +1423,45 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
.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,
{