//! The top-level effect-stack view and its event type. //! //! [`EffectStackView`] renders the linear card chain described by an //! [`EffectStackDataSource`] and reports every user edit intent as an //! [`EffectStackEvent`]. See the [module-level docs](crate::effect_stack) //! for the "edits are requests" contract. use std::rc::Rc; use crate::{ colors::DefaultColors, div, AnyView, App, AppContext, ClickEvent, Context, DragMoveEvent, ElementId, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, Render, SharedString, StatefulInteractiveElement, Styled, Window, }; use super::card::{EffectCard, InsertIndicator}; use super::data::{EffectCardKind, EffectId, EffectStackDataSource}; /// 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. pub type ParamsRenderer = Rc AnyView>; /// Edit requests emitted by [`EffectStackView`]. /// /// **Every variant is a request, not a completed edit.** The view does not /// mutate the [`EffectStackDataSource`]. The app subscribes, applies the /// request through its engine and undo stack, then calls /// [`cx.notify()`](Context::notify) so the stack re-renders from the /// updated model. If the request is rejected (invalid engine state, failed /// validation), the app simply does nothing — the view keeps rendering the /// unchanged model. #[derive(Clone, Debug)] pub enum EffectStackEvent { /// The user dragged a card to a new position. /// /// `new_index` is an index into the list returned by /// [`EffectStackDataSource::effects`] **after** removal of the dragged /// card (i.e. an insertion position). The app maps this to rewiring the /// node-graph path; see the [module docs](crate::effect_stack). The /// view has already filtered positions rejected by /// [`EffectStackDataSource::can_reorder`], but the app must re-validate. ReorderRequested { /// The dragged effect. effect: EffectId, /// Insertion index in the post-removal card list. new_index: usize, }, /// The user clicked the enable/disable toggle on a card. EnableToggled { /// The toggled effect. effect: EffectId, /// The desired new enabled state. enabled: bool, }, /// The user clicked a card header to expand or collapse its parameter /// area. ExpansionToggled { /// The toggled effect. effect: EffectId, /// The desired new expansion state. expanded: bool, }, /// The user clicked the remove button on a removable card. RemoveRequested(EffectId), /// The user invoked an "add effect" affordance at a stack position. /// /// The app typically responds by opening its effect browser; once the /// user picks an effect, the app inserts the corresponding node at /// `index` and notifies. AddRequested { /// Insertion index into the current card list. index: usize, }, /// The user secondary-clicked a card. The app owns the menu itself — /// the view only reports where and on which card it happened. ContextMenuRequested { /// The effect that was clicked. effect: EffectId, /// Mouse position in window coordinates, suitable for positioning a /// context menu. position: Point, }, /// A parameter of an effect changed inside its card's parameter area. /// /// Emitted when the app's parameter UI calls /// [`EffectStackView::notify_parameter_changed`]. The view uses it to /// refresh card metadata (e.g. the animated-parameter badge); the app /// may additionally subscribe to e.g. schedule a preview re-render. ParameterChanged { /// The effect whose parameters changed. effect: EffectId, }, } /// Transient drag state for an in-progress card reorder. /// /// Purely visual: tracks which card is being dragged and the current /// insertion position so [`render`](Render::render) can draw the ghost and /// the [`InsertIndicator`](crate::effect_stack::InsertIndicator). Cleared /// on drop or cancel; never survives into the app's model. #[derive(Clone, Copy, Debug, Default)] pub struct DragState { /// The card currently being dragged, if any. pub dragged: Option, /// Current insertion index (into the post-removal list) while dragging, /// if the pointer is over a valid drop position. pub insertion_index: Option, } /// The linear effect-stack inspector view. /// /// Generic over the app's data-source entity `D`. Construct with /// [`EffectStackView::new`], optionally register a parameter renderer with /// [`EffectStackView::params_renderer`], then subscribe to /// [`EffectStackEvent`]s on the entity. /// /// Implements [`Render`], [`Focusable`] (for keyboard interaction) and /// [`EventEmitter`]. /// /// # Interactions /// /// - **Click card header**: emits [`EffectStackEvent::ExpansionToggled`]. /// - **Enable toggle (eye/power)**: emits [`EffectStackEvent::EnableToggled`]. /// - **Drag card by its handle**: live /// [`InsertIndicator`](crate::effect_stack::InsertIndicator) tracks the /// pointer; drop emits [`EffectStackEvent::ReorderRequested`]. Cards with /// [`EffectData::is_reorderable`](crate::effect_stack::EffectData::is_reorderable) /// `== false` (source/output by default) cannot be dragged. /// - **Remove button**: emits [`EffectStackEvent::RemoveRequested`]. /// - **Secondary click**: emits [`EffectStackEvent::ContextMenuRequested`]; /// the app renders the actual menu. /// - **Empty selection** ([`EffectStackDataSource::target_label`] is /// `None`): renders an empty state and disables all interactions. /// /// # Virtualization /// /// The card list is rendered as a plain vertical flex column, **not** a /// [`uniform_list`](crate::uniform_list). Effect stacks in a video editor /// rarely exceed a few dozen cards, and expanded cards have variable, /// content-driven heights with stateful child views (parameter UIs), which /// a virtualized list would fight against. Revisit if profiles show /// otherwise. pub struct EffectStackView { data: Entity, params_renderer: Option, focus_handle: FocusHandle, drag_state: DragState, } impl EffectStackView { /// Creates a new view over the given data-source entity. /// /// The view does not subscribe to the entity itself; the app is /// expected to call [`cx.notify()`](Context::notify) on the data source /// (or on this view) after applying edits, per the "edits are requests" /// contract. pub fn new(data: Entity, cx: &mut Context) -> Self { Self { data, params_renderer: None, focus_handle: cx.focus_handle(), drag_state: DragState::default(), } } /// Registers the app callback that renders an effect's parameter /// controls inside its expanded card. See [`ParamsRenderer`]. /// /// Builder style; call once at setup: /// /// ```ignore /// let stack = cx.new(|cx| { /// EffectStackView::new(data, cx).params_renderer(|id, window, cx| { /// my_effect_params_view(*id).into() /// }) /// }); /// ``` /// /// Cards of [`EffectCardKind::Source`](crate::effect_stack::EffectCardKind::Source) /// and [`Output`](crate::effect_stack::EffectCardKind::Output) never /// invoke the renderer — they have no parameter area. pub fn params_renderer( mut self, renderer: impl Fn(&EffectId, &mut Window, &mut App) -> AnyView + 'static, ) -> Self { self.params_renderer = Some(Rc::new(renderer)); self } /// The data-source entity this view reads from. pub fn data(&self) -> &Entity { &self.data } /// Current transient drag state (visual only). pub fn drag_state(&self) -> DragState { self.drag_state } /// Helper for the app's parameter UIs: reports that a parameter of /// `effect` changed, causing the view to refresh card metadata and to /// emit [`EffectStackEvent::ParameterChanged`]. /// /// Call this from within the app's parameter view after applying a /// parameter edit to the engine. This is a notification of an edit the /// app already performed — unlike the other events, it does not require /// a follow-up model change. pub fn notify_parameter_changed(&mut self, effect: EffectId, cx: &mut Context) { cx.emit(EffectStackEvent::ParameterChanged { effect }); cx.notify(); } /// Updates the transient drag state while a reorder drag moves over the /// card `card_id`. /// /// The root-level drag-move listener runs first (capture phase, /// registration order) and clears the insertion index, so a card's /// listener only needs to set it while the pointer is inside that card's /// own bounds — a pointer is inside at most one card, so the indicator /// tracks exactly the card under the pointer. When the pointer is /// elsewhere, this returns without touching the (already cleared) state. fn update_drag( &mut self, card_id: EffectId, event: &DragMoveEvent, cx: &mut Context, ) { 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) { return; } let Some(i0) = effects.iter().position(|e| e.id() == dragged) else { self.drag_state.insertion_index = None; cx.notify(); return; }; let Some(j) = effects.iter().position(|e| e.id() == card_id) else { self.drag_state.insertion_index = None; cx.notify(); return; }; // The pointer is inside this card, so the indicator sits on the // nearer of the two edges of the card; the index is expressed in // the post-removal card list. let insert_before = event.event.position.y < event.bounds.center().y; let pos_in_removed = j - usize::from(j > i0); let new_index = if insert_before { pos_in_removed } else { pos_in_removed + 1 }; self.drag_state.insertion_index = if self.data.read(cx).can_reorder(dragged, new_index) { Some(new_index) } else { None }; cx.notify(); } /// Clears the transient drag state (mouse released outside the stack, or /// any gesture that should abort an in-progress drag). fn cancel_drag(&mut self, cx: &mut Context) { self.drag_state = DragState::default(); cx.notify(); } /// Toggles a card's expansion by emitting /// [`EffectStackEvent::ExpansionToggled`]. fn toggle_expanded(&mut self, id: EffectId, cx: &mut Context) { self.drag_state = DragState::default(); let expanded = self .data .read(cx) .effects() .iter() .find(|e| e.id() == id) .map(|e| !e.is_expanded()) .unwrap_or(false); cx.emit(EffectStackEvent::ExpansionToggled { effect: id, expanded }); cx.notify(); } /// Toggles a card's enabled state by emitting /// [`EffectStackEvent::EnableToggled`]. fn toggle_enabled(&mut self, id: EffectId, cx: &mut Context) { self.drag_state = DragState::default(); let enabled = self .data .read(cx) .effects() .iter() .find(|e| e.id() == id) .map(|e| !e.is_enabled()) .unwrap_or(false); cx.emit(EffectStackEvent::EnableToggled { effect: id, enabled }); cx.notify(); } /// Requests removal of a card by emitting /// [`EffectStackEvent::RemoveRequested`]. fn remove(&mut self, id: EffectId, cx: &mut Context) { self.drag_state = DragState::default(); cx.emit(EffectStackEvent::RemoveRequested(id)); cx.notify(); } /// Reports a secondary click on a card by emitting /// [`EffectStackEvent::ContextMenuRequested`]. fn context_menu(&mut self, id: EffectId, position: Point, cx: &mut Context) { self.drag_state = DragState::default(); cx.emit(EffectStackEvent::ContextMenuRequested { effect: id, position }); cx.notify(); } /// Requests insertion of a new effect at the end of the stack by /// emitting [`EffectStackEvent::AddRequested`]. fn add(&mut self, cx: &mut Context) { let index = self.data.read(cx).effects().len(); cx.emit(EffectStackEvent::AddRequested { index }); cx.notify(); } } impl EventEmitter for EffectStackView {} impl Focusable for EffectStackView { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } impl Render for EffectStackView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); let focus_handle = self.focus_handle.clone(); let params_renderer = self.params_renderer.clone(); // A drag that ended without a drop (released outside a drop target, or // cancelled) leaves transient drag state behind; clear it on the next // render so no card stays half-transparent and no indicator lingers. if self.drag_state.dragged.is_some() && !cx.has_active_drag() { self.drag_state = DragState::default(); } let (label, effects) = { let data = self.data.read(cx); (data.target_label(), data.effects()) }; 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)); let i0_guard = i0.unwrap_or(usize::MAX); // Constructs the ghost view shown under the pointer while a card is // being dragged. let ghost_ctor = { let data = self.data.clone(); move |_id: &EffectId, _origin: Point, _window: &mut Window, cx: &mut App| { let title = data .read(cx) .effects() .iter() .find(|e| e.id() == *_id) .map(|e| e.title()) .unwrap_or_else(|| SharedString::from("Effect")); cx.new(|_cx| DragGhost { title }) } }; let mut root = div() .id("effect-stack") .flex() .flex_col() .w_full() .h_full() .track_focus(&focus_handle) .on_drag_move::(cx.listener(|this, _event, _window, cx| { // Runs first (capture phase, registration order): clear the // indicator by default; the per-card listener re-sets it // while the pointer is inside that card. this.drag_state.insertion_index = None; cx.notify(); })) .on_mouse_up_out( MouseButton::Left, cx.listener(|this, _event, _window, cx| this.cancel_drag(cx)), ); let Some(label) = label else { return root.child( div() .id("empty-state") .flex() .flex_1() .items_center() .justify_center() .text_sm() .text_color(colors.disabled) .child("No selection"), ); }; root = root.child( div() .id("effect-stack-header") .px_3() .py_2() .text_sm() .text_color(colors.text) .child(label), ); let mut column = div().id("effect-stack-cards").flex().flex_col().w_full(); for (index, effect) in effects.iter().enumerate() { let id = effect.id(); let fixed = effect.kind() != EffectCardKind::Effect; let enabled = effect.is_enabled(); let expanded = effect.is_expanded(); let removable = effect.is_removable(); let reorderable = effect.is_reorderable(); // Insertion indicator for a drop position just before this card. if let Some(p) = insertion_index { let k = p + usize::from(p >= i0_guard); if k == index { column = column.child(InsertIndicator::valid()); } } // Header row: drag handle, enable toggle, the card itself // (flexing to fill), and the remove button. let mut header_row = div() .id(ElementId::named_usize("effect-header", id.0 as usize)) .flex() .flex_row() .items_center() .gap_1() .px_2() .py_1() .on_aux_click(cx.listener(move |this, event: &ClickEvent, _window, cx| { cx.stop_propagation(); this.context_menu(id, event.position(), cx); })); if !fixed { header_row = header_row.cursor_pointer().on_click( cx.listener(move |this, _event, _window, cx| { cx.stop_propagation(); this.toggle_expanded(id, cx); }), ); } if reorderable { header_row = header_row.child( div() .id(ElementId::named_usize("effect-handle", id.0 as usize)) .cursor_grab() .text_color(colors.disabled) .child("⠿") .on_drag(id, ghost_ctor.clone()), ); } if !fixed { header_row = header_row.child( div() .id(ElementId::named_usize("effect-toggle", id.0 as usize)) .cursor_pointer() .text_color(if enabled { colors.text } else { colors.disabled }) .child("⏻") .on_click(cx.listener(move |this, _event, _window, cx| { cx.stop_propagation(); this.toggle_enabled(id, cx); })), ); } let card = EffectCard::new(id) .kind(effect.kind()) .title(effect.title()) .subtitle(effect.subtitle()) .enabled(enabled) .expanded(expanded) .removable(removable) .reorderable(reorderable) .badge_count(effect.badge_count()) .drag_ghost(dragged_id == Some(id)); header_row = header_row.child(card); if removable { header_row = header_row.child( div() .id(ElementId::named_usize("effect-remove", id.0 as usize)) .cursor_pointer() .text_color(colors.disabled) .child("✕") .on_click(cx.listener(move |this, _event, _window, cx| { cx.stop_propagation(); this.remove(id, cx); })), ); } let mut wrapper = div() .id(ElementId::named_usize("effect", id.0 as usize)) .flex() .flex_col() .w_full() .rounded_md() .border_1() .border_color(if fixed { colors.border } else { colors.separator }) .bg(if fixed { colors.container } else { colors.background }) .overflow_hidden(); wrapper = wrapper.child(header_row); if expanded && !fixed { if let Some(renderer) = ¶ms_renderer { 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)), ); } } if reorderable { wrapper = wrapper .on_drag_move::(cx.listener(move |this, event, _window, cx| { this.update_drag(id, event, cx); })) .on_drop::( cx.listener(move |this, &dragged: &EffectId, _window, cx| { let index = this.drag_state.insertion_index; this.drag_state = DragState::default(); if let Some(index) = index { cx.emit(EffectStackEvent::ReorderRequested { effect: dragged, new_index: index, }); } cx.notify(); }), ) .can_drop(|payload, _window, _cx| payload.is::()); } column = column.child(wrapper); } // Insertion indicator for a drop position after the last card. if let Some(p) = insertion_index { let k = p + usize::from(p >= i0_guard); if k == effects.len() { column = column.child(InsertIndicator::valid()); } } let add_button = div() .id("effect-add") .cursor_pointer() .px_3() .py_2() .text_sm() .text_color(colors.text) .child("+ Add Effect") .on_click(cx.listener(move |this, _event, _window, cx| this.add(cx))); root.child(column).child(add_button) } } /// The floating view shown under the pointer while a card is being dragged. struct DragGhost { title: SharedString, } impl Render for DragGhost { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); div() .px_2() .py_1() .rounded_md() .bg(colors.background) .border_1() .border_color(colors.border) .shadow_md() .child(self.title.clone()) } }