feat(engine,app): effect chain facade + app effect stack wiring

facade (oakengine/oaknode, API only extended):
- oaknode_node_get_effect_input / oaknode_node_get_flags module exports
- oakengine_clip_as_node, oakengine_node_effect_count/at/insert/remove/
  move/set_enabled, oakengine_node_identity/is_enabled/get_type_id,
  oakengine_node_factory_id_at — chain edits wrap disconnect/reconnect
  into single undo groups; insert/move/remove validated against the
  factory and the host's effect input
- it_node covers enumeration/insert/remove/move/toggle/undo-redo and
  the NULL/illegal-input matrix

app:
- RealEngine binds the selected clip's effect chain to the inspector's
  effect stack (cards, enable, reorder, remove, expansion state), all
  edits undoable through the facade
- AppEngine gains set_selected_clips / addable_effects / add_effect
  (default no-ops keep the mock untouched); the timeline selection
  drives the stack target; the inspector gets a small add-effect menu
- known module gap (documented): ClipBlockBehavior sets no effect_input
  yet, so timeline clips cannot host effects until oaknode grows tex_in
This commit is contained in:
2026-08-11 20:04:44 +08:00
parent f86a895c8a
commit cdda643d64
10 changed files with 1443 additions and 28 deletions
+12 -2
View File
@@ -41,7 +41,7 @@ use std::time::Duration;
use gpui::dock::{
DockArea, DockLayout, DropTarget, DropZone, NodePath, PanelHandle, PanelRegistry,
};
use gpui::timeline::{Frame, TimelineEvent, TimelineView};
use gpui::timeline::{ClipId, Frame, TimelineEvent, TimelineView};
use gpui::{
div, prelude::*, px, size, App, AsyncWindowContext, Bounds, Context, Entity, Render, Window,
WindowBounds, WindowOptions,
@@ -397,9 +397,19 @@ impl<E: AppEngine> OakApp<E> {
// Every timeline widget request (playhead seek, trim, move, track
// height) is applied by the engine through its backend's edit
// commands; the playhead is routed to the program monitor.
// `SelectionChanged` carries no payload — the selection is read from
// the view and forwarded to the engine so the inspector's effect
// stack can target the selected clip.
cx.subscribe(
&timeline,
|this, _timeline, event: &TimelineEvent, cx| {
|this, timeline, event: &TimelineEvent, cx| {
if matches!(event, TimelineEvent::SelectionChanged) {
let clips: Vec<ClipId> =
timeline.read(cx).selection().iter().copied().collect();
this.engine.update(cx, |engine, cx| {
engine.set_selected_clips(clips, cx)
});
}
this.engine
.update(cx, |engine, cx| engine.apply_timeline_event(event, cx));
},
+24
View File
@@ -207,6 +207,30 @@ pub trait AppEngine:
/// Applies an effect-stack edit request to the engine's model.
fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context<Self>);
/// Updates the timeline clip selection (drives the effect stack's
/// target). The app shell forwards `TimelineEvent::SelectionChanged`
/// with the view's selection set. Default: no-op (engines without a
/// selection-driven stack keep their existing behavior).
fn set_selected_clips(&mut self, _clips: Vec<ClipId>, _cx: &mut Context<Self>) {}
/// The effect types the user can add to the selected clip's chain, as
/// (type id, display name) pairs — the facade factory entries flagged
/// `video_effect` and not hidden from the create menu. The inspector
/// panel lists them in its "add effect" menu. Default: empty.
fn addable_effects(&self) -> Vec<(String, String)> {
Vec::new()
}
/// Inserts the effect `type_id` at `index` into the selected clip's
/// chain (undoable). `index` is an insertion index into
/// [`EffectStackDataSource::effects`] (0 = closest to the source);
/// the panel passes the position carried by the `AddRequested` event.
/// Returns a user-facing error message on failure. Default: unsupported.
fn add_effect(&mut self, index: usize, type_id: &str, cx: &mut Context<Self>) -> Result<(), String> {
let _ = (index, type_id, cx);
Err("add effect not supported".into())
}
/// Applies a node-editor edit request to the engine's model.
fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context<Self>);
+95
View File
@@ -103,6 +103,7 @@ engine_handle! {
OakEngineClip,
OakEngineEncodingParams,
OakEngineFrame,
OakEngineNode,
OakEngineProject,
OakEngineRenderer,
OakEngineSequence,
@@ -480,6 +481,100 @@ unsafe extern "C" {
/// `oakengine_track_height_pixels_to_internal`.
pub fn oakengine_track_height_pixels_to_internal(pixels: c_int) -> f64;
// -- oakengine::node (effect chain) --
//
// The effect-stack surface over a selected clip: convert the clip to its
// node view, enumerate its effect chain (index 0 = closest to the
// source), and edit the chain (insert / remove / reorder / enable
// toggle), each edit packaged as an undoable facade command. Factory
// enumeration feeds the "add effect" menu. Node boxes are freed with
// `oakengine_node_free`; the clip box returned by
// `oakengine_sequence_clip_at` is freed with [`free_box`].
/// `oakengine_clip_as_node` — the clip's node view (borrowed box;
/// freed with `oakengine_node_free`).
pub fn oakengine_clip_as_node(self_: *const OakEngineClip) -> *mut OakEngineNode;
/// `oakengine_node_effect_count` — the chain length (0 without effects).
pub fn oakengine_node_effect_count(self_: *const OakEngineNode) -> c_int;
/// `oakengine_node_effect_at` — the `index`-th effect (borrowed box;
/// NULL out of range / no chain).
pub fn oakengine_node_effect_at(
self_: *const OakEngineNode,
index: c_int,
) -> *mut OakEngineNode;
/// `oakengine_node_identity` — the node's stable identity (the stack's
/// card id). 0 for NULL/invalid.
pub fn oakengine_node_identity(self_: *const OakEngineNode) -> u64;
/// `oakengine_node_is_enabled` — 1/0 (the stack's enable switch).
pub fn oakengine_node_is_enabled(self_: *const OakEngineNode) -> c_int;
/// `oakengine_node_effect_set_enabled` — undoable enable toggle.
pub fn oakengine_node_effect_set_enabled(
self_: *mut OakEngineNode,
enabled: c_int,
) -> c_int;
/// `oakengine_node_effect_insert` — undoable insert at `index`
/// (0 = closest to the source; clamped to the ends).
pub fn oakengine_node_effect_insert(
self_: *mut OakEngineNode,
index: c_int,
type_id: *const c_char,
) -> c_int;
/// `oakengine_node_effect_remove` — undoable removal of `effect` from
/// `self_`'s chain (the node is left orphaned in the project graph).
pub fn oakengine_node_effect_remove(
self_: *mut OakEngineNode,
effect: *mut OakEngineNode,
) -> c_int;
/// `oakengine_node_effect_move` — undoable reorder of `effect` to
/// `new_index` (post-removal insertion index, matching the effect
/// stack's `ReorderRequested`).
pub fn oakengine_node_effect_move(
self_: *mut OakEngineNode,
effect: *mut OakEngineNode,
new_index: c_int,
) -> c_int;
/// `oakengine_node_get_effect_input` — the id of the input the effect
/// chain attaches to (negative error when the node cannot host
/// effects; `element` receives -1 for non-array inputs).
pub fn oakengine_node_get_effect_input(
self_: *const OakEngineNode,
input_id: *mut c_char,
input_id_size: c_int,
element: *mut c_int,
) -> c_int;
/// `oakengine_node_get_type_id` — the node's factory type id (buf/size).
pub fn oakengine_node_get_type_id(
self_: *const OakEngineNode,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakengine_node_get_flags` — the node's flags bitmask (0 for
/// NULL/invalid).
pub fn oakengine_node_get_flags(self_: *const OakEngineNode) -> u64;
/// `oakengine_node_free` — free a node box (NULL no-op).
pub fn oakengine_node_free(node: *mut OakEngineNode);
/// `oakengine_node_factory_id_count` — factory entry count.
pub fn oakengine_node_factory_id_count() -> c_int;
/// `oakengine_node_factory_id_at` — type id at `index` (buf/size;
/// negative error out of range).
pub fn oakengine_node_factory_id_at(
index: c_int,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakengine_node_factory_name_from_id` — display name (buf/size).
pub fn oakengine_node_factory_name_from_id(
type_id: *const c_char,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakengine_node_factory_create_from_id` — owned node, not added.
pub fn oakengine_node_factory_create_from_id(type_id: *const c_char) -> *mut OakEngineNode;
/// `oakengine_node_flag_video_effect`.
pub fn oakengine_node_flag_video_effect() -> u64;
/// `oakengine_node_flag_dont_show_in_create_menu`.
pub fn oakengine_node_flag_dont_show_in_create_menu() -> u64;
// -- oakengine::render (CPU frame renderer) --
//
// The renderer binds a sequence handle to an output geometry; each
+343 -12
View File
@@ -48,9 +48,12 @@
//! facade CPU renderer ([`RealEngine::render_program_frame`]) at a proxy
//! resolution; the full-resolution async render worker (the facade's
//! worker module) is a separate process surface not bound yet.
//! * Effect stack, node graph and audio meter feed empty/silent data: the
//! facade surfaces for them (effect chains, graph nodes, audio levels)
//! are not bound in this increment.
//! * Effect stack — the selected clip's effect chain is bound: the stack
//! reads the chain through the facade (see
//! [`EffectStackDataSource`](EffectStackDataSource) for `RealEngine`)
//! and edits go through the facade's undoable effect commands. Node
//! graph and audio meter still feed empty/silent data (their facade
//! surfaces are not bound in this increment).
//! * `oakengine_sequence_move_clip` is a documented facade stub (module gap),
//! so clip moves report the facade error instead of applying.
//!
@@ -62,14 +65,16 @@
//! `oakengine_task_cancel` mirrors the C++ capi contract (cancel atom set
//! from the UI thread while the task runs on its own thread).
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
use std::ffi::{c_char, c_int, c_void, CString};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use gpui::effect_stack::{EffectData, EffectStackDataSource, EffectStackEvent};
use gpui::effect_stack::{
EffectCardKind, EffectData, EffectId, EffectStackDataSource, EffectStackEvent,
};
use gpui::node_graph::{
EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeGraphEvent, NodeId, PortData, PortId,
PortDataType, PortKind,
@@ -399,6 +404,46 @@ impl ClipData for RealClip {
}
}
/// One card of the real effect stack: the facade chain node's identity,
/// its factory display name, its enabled flag, and the app-owned
/// expansion state ([`RealEngine::expanded_effects`]). No source/output
/// cards: the host clip node is the implicit output, the chain's unlinked
/// upstream input is the implicit source (the effect stack shows only the
/// editable middle).
#[derive(Debug, Clone)]
struct RealEffect {
/// The node's stable identity (also the card's `EffectId`).
id: EffectId,
/// The factory display name of the node's type.
title: SharedString,
/// The node's `enabled_in` flag.
enabled: bool,
/// The app-owned expansion state (not undoable).
expanded: bool,
}
impl EffectData for RealEffect {
fn id(&self) -> EffectId {
self.id
}
fn kind(&self) -> EffectCardKind {
EffectCardKind::Effect
}
fn title(&self) -> SharedString {
self.title.clone()
}
fn is_enabled(&self) -> bool {
self.enabled
}
fn is_expanded(&self) -> bool {
self.expanded
}
}
/// A track on the real timeline (snapshot handed to the timeline widget).
#[derive(Debug, Clone)]
pub struct RealTrack {
@@ -587,6 +632,14 @@ pub struct RealEngine {
bin_children: Vec<ProjectEntry>,
/// The selected material-bin entry (demo state).
selected_item: Option<u64>,
/// The single selected timeline clip — the effect stack's target
/// (`None` for an empty or multi-clip selection, or before any
/// selection event).
selected_clip: Option<ClipId>,
/// Node identities whose effect cards are expanded (view state; kept
/// here because `EffectData` is a pure read and expansion is not
/// undoable).
expanded_effects: BTreeSet<u64>,
/// Whether the program monitor is playing (mirrors the clock; kept here
/// because the audio-meter data source has no `App` to read the clock).
program_playing: bool,
@@ -628,6 +681,8 @@ impl RealEngine {
bin_roots: Vec::new(),
bin_children: Vec::new(),
selected_item: None,
selected_clip: None,
expanded_effects: BTreeSet::new(),
program_playing: false,
meter_phase: 0,
cpu_frame_cache: Mutex::new(HashMap::new()),
@@ -1016,6 +1071,140 @@ impl RealEngine {
None
}
/// The selected clip's facade node view (a boxed node handle the
/// caller frees with `oakengine_node_free`), or `None` when no single
/// clip is selected or the clip box resolves to no node.
fn selected_clip_node(&self) -> Option<*mut OakEngineNode> {
let clip_id = self.selected_clip?;
let (kind, track_index, clip_index) = self.clip_coords(clip_id)?;
let seq = self.seq_ptr()?;
let clip = unsafe {
oakengine_sequence_clip_at(
seq,
Self::track_type_of(kind),
track_index as c_int,
clip_index as c_int,
)
};
if clip.is_null() {
return None;
}
let node = unsafe { oakengine_clip_as_node(clip) };
// SAFETY: the clip box is a plain facade box (see `free_box`); the
// node box is independent (the facade boxed its own handle copy).
unsafe { free_box(clip) };
// SAFETY: `node` is a fresh box the caller frees, or NULL.
if node.is_null() {
None
} else {
Some(node)
}
}
/// Whether `node` can host effects (its effect-input id is non-empty;
/// the facade reports `E_NOT_FOUND` for nodes without one).
///
/// # Safety
/// `node` must be a live node box (NULL reports false).
unsafe fn node_hosts_effects(node: *mut OakEngineNode) -> bool {
let mut buf = [0 as c_char; 64];
let mut element: c_int = 0;
unsafe {
oakengine_node_get_effect_input(node, buf.as_mut_ptr(), buf.len() as c_int, &mut element)
>= 0
}
}
/// The effect in `host`'s chain whose identity is `identity` (a boxed
/// node handle the caller frees), or `None` when not a member.
///
/// # Safety
/// `host` must be a live node box.
unsafe fn chain_effect_by_identity(
host: *mut OakEngineNode,
identity: u64,
) -> Option<*mut OakEngineNode> {
let count = unsafe { oakengine_node_effect_count(host) };
for i in 0..count.max(0) {
let effect = unsafe { oakengine_node_effect_at(host, i) };
if effect.is_null() {
continue;
}
if unsafe { oakengine_node_identity(effect) } == identity {
return Some(effect);
}
// SAFETY: `effect` is a box from `oakengine_node_effect_at`.
unsafe { oakengine_node_free(effect) };
}
None
}
/// The display label of the selected clip (its timeline snapshot
/// label), if any.
fn selected_clip_label(&self) -> Option<SharedString> {
let clip_id = self.selected_clip?;
for track in &self.tracks {
if let Some(clip) = track.clips.iter().find(|c| c.id() == clip_id) {
return Some(clip.label());
}
}
None
}
/// The card list of the selected clip's effect chain. Each card wraps
/// one chain node (index 0 = closest to the source); a clip that
/// cannot host effects yields an empty list (its `target_label` is
/// `None`, so the stack shows the empty state).
fn selected_effect_cards(&self) -> Vec<Arc<dyn EffectData>> {
let Some(node) = self.selected_clip_node() else {
return Vec::new();
};
let mut out: Vec<Arc<dyn EffectData>> = Vec::new();
// SAFETY: `node` is a live box; freed on every return path.
unsafe {
if !Self::node_hosts_effects(node) {
oakengine_node_free(node);
return out;
}
let count = oakengine_node_effect_count(node);
for i in 0..count.max(0) {
let effect = oakengine_node_effect_at(node, i);
if effect.is_null() {
continue;
}
let identity = oakengine_node_identity(effect);
let type_id = read_string(|buf, size| {
// SAFETY: `effect` is a live box; buf/size follow the
// facade two-stage convention (the enclosing `unsafe`
// block covers this closure body).
oakengine_node_get_type_id(effect, buf, size)
});
let title = CString::new(type_id.clone())
.ok()
.map(|c| {
read_string(|buf, size| {
// SAFETY: as above; `c` outlives the call.
oakengine_node_factory_name_from_id(c.as_ptr(), buf, size)
})
})
.filter(|n| !n.is_empty())
.unwrap_or(type_id);
let enabled = oakengine_node_is_enabled(effect) != 0;
let expanded = self.expanded_effects.contains(&identity);
out.push(Arc::new(RealEffect {
id: EffectId(identity),
title: title.into(),
enabled,
expanded,
}) as Arc<dyn EffectData>);
// SAFETY: `effect` is a box from `oakengine_node_effect_at`.
oakengine_node_free(effect);
}
oakengine_node_free(node);
}
out
}
/// The facade track-type constant for a [`TrackKind`].
fn track_type_of(kind: TrackKind) -> c_int {
match kind {
@@ -1166,13 +1355,17 @@ impl TimelineDataSource for RealEngine {
impl EffectStackDataSource for RealEngine {
fn effects(&self) -> Vec<Arc<dyn EffectData>> {
// The effect-chain surface of the facade is not bound in this
// increment; the stack shows its empty state.
Vec::new()
self.selected_effect_cards()
}
fn target_label(&self) -> Option<SharedString> {
None
let label = self.selected_clip_label()?;
let node = self.selected_clip_node()?;
// SAFETY: `node` is a live box; freed below. A clip that cannot
// host effects keeps the empty state (no label, no cards).
let hosts = unsafe { Self::node_hosts_effects(node) };
unsafe { oakengine_node_free(node) };
hosts.then_some(label)
}
}
@@ -1333,12 +1526,150 @@ impl AppEngine for RealEngine {
cx.notify();
}
fn apply_effect_event(&mut self, _event: &EffectStackEvent, cx: &mut Context<Self>) {
// No effect model in the real engine yet; requests are logged by the
// caller.
fn set_selected_clips(&mut self, clips: Vec<ClipId>, cx: &mut Context<Self>) {
// The effect stack targets exactly one clip: an empty or
// multi-clip selection keeps the empty state (see
// `EffectStackDataSource::target_label`).
self.selected_clip = (clips.len() == 1).then(|| clips[0]);
cx.notify();
}
fn addable_effects(&self) -> Vec<(String, String)> {
// The factory entries flagged `video_effect` and not hidden from
// the create menu (per the facade contract). A scratch node per
// entry just to read its flags (freed immediately).
let mut out = Vec::new();
let count = unsafe { oakengine_node_factory_id_count() };
let video_flag = unsafe { oakengine_node_flag_video_effect() };
let hidden_flag = unsafe { oakengine_node_flag_dont_show_in_create_menu() };
for i in 0..count.max(0) {
let type_id =
read_string(|buf, size| unsafe { oakengine_node_factory_id_at(i, buf, size) });
let Some(c_id) = CString::new(type_id.clone()).ok() else {
continue;
};
let node = unsafe { oakengine_node_factory_create_from_id(c_id.as_ptr()) };
if node.is_null() {
continue;
}
let flags = unsafe { oakengine_node_get_flags(node) };
// SAFETY: `node` is an owned box from the factory.
unsafe { oakengine_node_free(node) };
if flags & video_flag != 0 && flags & hidden_flag == 0 {
let name = read_string(|buf, size| {
// SAFETY: `c_id` outlives the call; buf/size follow the
// facade two-stage convention.
unsafe { oakengine_node_factory_name_from_id(c_id.as_ptr(), buf, size) }
});
let name = if name.is_empty() { type_id.clone() } else { name };
out.push((type_id, name));
}
}
out
}
fn add_effect(&mut self, index: usize, type_id: &str, cx: &mut Context<Self>) -> Result<(), String> {
let Some(host) = self.selected_clip_node() else {
return Err("no selected clip".into());
};
let c_id = CString::new(type_id).map_err(|_| "invalid effect type id".to_string())?;
// SAFETY: `host` is a live box; freed below.
let rc = unsafe { oakengine_node_effect_insert(host, index as c_int, c_id.as_ptr()) };
unsafe { oakengine_node_free(host) };
if rc != 0 {
return Err(format!("facade error {rc}"));
}
self.apply_edit(rc, "add effect", cx);
Ok(())
}
fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context<Self>) {
match event {
EffectStackEvent::EnableToggled { effect, enabled } => {
let Some(host) = self.selected_clip_node() else {
cx.notify();
return;
};
// SAFETY: both boxes are live and freed below.
let rc = unsafe {
let Some(eff) = Self::chain_effect_by_identity(host, effect.0) else {
oakengine_node_free(host);
cx.notify();
return;
};
let rc = oakengine_node_effect_set_enabled(eff, *enabled as c_int);
oakengine_node_free(eff);
oakengine_node_free(host);
rc
};
self.apply_edit(rc, "toggle effect", cx);
}
EffectStackEvent::ExpansionToggled { effect, expanded } => {
// View state only (not undoable); kept here so the card
// list re-reads it after the notify.
if *expanded {
self.expanded_effects.insert(effect.0);
} else {
self.expanded_effects.remove(&effect.0);
}
cx.notify();
}
EffectStackEvent::RemoveRequested(id) => {
let Some(host) = self.selected_clip_node() else {
cx.notify();
return;
};
// SAFETY: both boxes are live and freed below.
let rc = unsafe {
let Some(eff) = Self::chain_effect_by_identity(host, id.0) else {
oakengine_node_free(host);
cx.notify();
return;
};
let rc = oakengine_node_effect_remove(host, eff);
oakengine_node_free(eff);
oakengine_node_free(host);
rc
};
self.apply_edit(rc, "remove effect", cx);
}
EffectStackEvent::ReorderRequested { effect, new_index } => {
let Some(host) = self.selected_clip_node() else {
cx.notify();
return;
};
// SAFETY: both boxes are live and freed below.
let rc = unsafe {
let Some(eff) = Self::chain_effect_by_identity(host, effect.0) else {
oakengine_node_free(host);
cx.notify();
return;
};
let rc = oakengine_node_effect_move(host, eff, *new_index as c_int);
oakengine_node_free(eff);
oakengine_node_free(host);
rc
};
self.apply_edit(rc, "reorder effect", cx);
}
EffectStackEvent::AddRequested { index } => {
// The effect choice is a panel-owned menu (see
// `InspectorPanel`); the undoable insert runs through
// `AppEngine::add_effect` once the user picks a type.
// `index` is acknowledged here for parity with the
// request semantics (the panel passes it back).
let _ = index;
cx.notify();
}
// The app owns the context menu; parameter changes have no
// metadata to refresh yet.
EffectStackEvent::ContextMenuRequested { .. }
| EffectStackEvent::ParameterChanged { .. } => {
cx.notify();
}
}
}
fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context<Self>) {
match event {
NodeGraphEvent::NodeMovePreview { .. }
+104 -4
View File
@@ -33,6 +33,11 @@ use crate::panels::ids::INSPECTOR;
pub struct InspectorPanel<E: AppEngine> {
stack: Entity<EffectStackView<E>>,
engine: Entity<E>,
/// An in-flight "add effect" flow: the stack insertion index carried
/// by the last [`EffectStackEvent::AddRequested`]. While set (and the
/// engine has a stack target), the panel renders a small menu of the
/// engine's addable effects instead of forwarding the bare request.
pending_add: Option<usize>,
}
impl<E: AppEngine> InspectorPanel<E> {
@@ -43,20 +48,115 @@ impl<E: AppEngine> InspectorPanel<E> {
.params_renderer(|_effect, _window, cx| cx.new(|_cx| ParamPlaceholder).into())
});
// The "edits are requests" loop: forward each request to the engine,
// which applies it to its model and notifies.
// which applies it to its model and notifies. `AddRequested` carries
// no effect type, so the panel records the insertion index and lets
// the user pick one from the small menu below (the actual insert
// runs through `AppEngine::add_effect`).
cx.subscribe(&stack, |this, _stack, event: &EffectStackEvent, cx| {
if let EffectStackEvent::AddRequested { index } = event {
this.pending_add = Some(*index);
}
this.engine
.update(cx, |engine, cx| engine.apply_effect_event(event, cx));
})
.detach();
Self { stack, engine }
Self {
stack,
engine,
pending_add: None,
}
}
/// The "add effect" menu: one clickable row per addable effect of the
/// engine. Selecting a row inserts that effect at the recorded stack
/// index; a dismiss row closes the menu without adding.
fn render_add_menu(
&mut self,
index: usize,
colors: &gpui::colors::Colors,
cx: &mut Context<Self>,
) -> impl IntoElement {
let effects = self.engine.read(cx).addable_effects();
let mut menu = div()
.id("inspector-add-menu")
.px_2()
.py_1()
.border_t_1()
.border_color(colors.separator)
.flex()
.flex_col()
.gap_1();
for (type_id, name) in &effects {
let engine = self.engine.clone();
let type_id = type_id.clone();
let name = name.clone();
let index = index;
menu = menu.child(
div()
.id(SharedString::from(format!("add-effect-{type_id}")))
.cursor_pointer()
.px_2()
.py_1()
.rounded_sm()
.hover(|style| style.bg(colors.selected))
.text_color(colors.text)
.text_sm()
.child(name)
.on_click(
cx.listener(move |this, _event: &gpui::ClickEvent, _window, cx| {
this.pending_add = None;
engine.update(cx, |engine, cx| {
if let Err(err) = engine.add_effect(index, &type_id, cx) {
println!("[inspector] add effect failed: {err}");
}
});
cx.notify();
}),
),
);
}
// A dismiss row, so a cancelled pick does not linger.
menu = menu.child(
div()
.id("add-effect-dismiss")
.cursor_pointer()
.px_2()
.py_1()
.rounded_sm()
.hover(|style| style.bg(colors.selected))
.text_color(colors.disabled)
.text_sm()
.child("")
.on_click(
cx.listener(move |this, _event: &gpui::ClickEvent, _window, cx| {
this.pending_add = None;
cx.notify();
}),
),
);
menu
}
}
impl<E: AppEngine> Render for InspectorPanel<E> {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.stack.clone())
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let mut root = div().id("inspector-panel").size_full().flex().flex_col();
root = root.child(self.stack.clone());
// The add-effect menu sits below the stack while an add is
// pending. It only makes sense while the engine has a stack
// target (a clip that can host effects).
if let Some(index) = self.pending_add {
if self.engine.read(cx).target_label().is_some() {
root = root.child(self.render_add_menu(index, colors.as_ref(), cx));
} else {
self.pending_add = None;
}
}
root
}
}