From 02cea7e3eef8d476b982c5bde14ae3e912b39bef Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Thu, 20 Aug 2026 17:08:26 +0800 Subject: [PATCH] feat(app): node editor follows the selected clip, two-way selection sync - With a clip selected, the node editor shows that clip's context chain (footage -> effects -> clip) instead of the global graph; the clip's node is highlighted. No selection keeps the full graph. - Node clicks in the graph select the node and expand/highlight the matching effect card in the inspector; clicking an inspector card highlights the node in the graph (single source of truth: the engine's selected_graph_node). --- gpui | 2 +- src/oakui/engine.rs | 9 ++ src/oakui/mock.rs | 57 +++++++++ src/oakui/nodegraph.rs | 199 +++++++++++++++++++++++++++++- src/oakui/real.rs | 248 ++++++++++++++++++++++++++++++++++++-- src/panels/inspector.rs | 5 + src/panels/node_editor.rs | 95 ++++++++++++++- 7 files changed, 598 insertions(+), 17 deletions(-) diff --git a/gpui b/gpui index f7a00fdd9..3870d6da0 160000 --- a/gpui +++ b/gpui @@ -1 +1 @@ -Subproject commit f7a00fdd911e17a7fdaf4d6b5c888ae2b59a7021 +Subproject commit 3870d6da06fd67f46fed0a590ba4b5a4270ba555 diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index 836c7073e..b0fd7614a 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -327,6 +327,15 @@ pub trait AppEngine: /// selection-driven stack keep their existing behavior). fn set_selected_clips(&mut self, _clips: Vec, _cx: &mut Context) {} + /// The node-graph selection mirror: the single node currently selected + /// in the node editor (or the effect card clicked in the inspector), + /// when exactly one is selected. The node-editor panel uses this to + /// push the highlight into the graph widget; the inspector derives its + /// stack target and card highlight from it. Default: none. + fn selected_graph_node(&self) -> Option { + None + } + /// The effect types the user can add to the selected clip's chain — the /// factory entries flagged `video_effect` and not hidden from the create /// menu, plus every runtime-registered OpenFX plugin entry (grouped by diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index 9967c03ce..acde65c35 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -874,6 +874,14 @@ impl MockEngine { let index = (*index).min(self.effects.len()); self.effects.insert(index, card); } + EffectStackEvent::CardSelected { effect } => { + // The inspector card click highlights the matching node in + // the node editor (the bidirectional node↔inspector link). + // The demo links cards and nodes by shared title. + if let Some(node) = self.node_for_effect(*effect) { + self.node_selection = BTreeSet::from([node]); + } + } // The app owns the context menu; the mock ignores it. EffectStackEvent::ContextMenuRequested { .. } | EffectStackEvent::ParameterChanged { .. } => {} @@ -930,6 +938,17 @@ impl MockEngine { } NodeGraphEvent::SelectionChanged { nodes } => { self.node_selection = nodes.clone(); + // The demo's node↔inspector link: a single selected node + // expands the matching effect card (the real engine mirrors + // this through `expanded_effects`). + if nodes.len() == 1 { + let node = *nodes.iter().next().expect("a one-element set"); + if let Some(effect) = self.effect_for_node(node) { + if let Some(card) = self.effects.iter_mut().find(|e| e.id == effect) { + card.expanded = true; + } + } + } } NodeGraphEvent::BackgroundClicked { position } => { // The real app opens an "add node" menu here; the mock logs it. @@ -945,6 +964,28 @@ impl MockEngine { &self.node_selection } + /// The demo node↔effect-card link: a card and a graph node share a + /// selection when their titles match (the demo graph's "变换" / "输出" + /// cards map 1:1 to the same-named graph nodes). The inspector card + /// click and the node-graph click route through this mapping. + fn node_for_effect(&self, effect: EffectId) -> Option { + let title = self.effects.iter().find(|e| e.id == effect)?.title.clone(); + self.nodes + .iter() + .find(|n| n.title() == title) + .map(|n| n.id()) + } + + /// The inverse of [`node_for_effect`](Self::node_for_effect): the card + /// matching a selected graph node. + fn effect_for_node(&self, node: NodeId) -> Option { + let title = self.nodes.iter().find(|n| n.id() == node)?.title(); + self.effects + .iter() + .find(|e| e.title == title) + .map(|e| e.id) + } + /// Looks up a node by id (test helper). #[cfg(test)] fn node(&self, id: NodeId) -> Option<&MockNode> { @@ -1273,6 +1314,14 @@ impl AppEngine for MockEngine { self.apply_effect_event(event, cx); } + fn selected_graph_node(&self) -> Option { + if self.node_selection.len() != 1 { + return None; + } + let node = *self.node_selection.iter().next()?; + Some(node.0) + } + fn addable_effects(&self) -> Vec { // The demo list is the real factory's effect table (built-ins plus // any registered OpenFX plugins), so the effect library shows the @@ -2112,6 +2161,14 @@ impl EffectStackDataSource for MockEngine { fn target_label(&self) -> Option { Some("第一稿.mp4 · 00:00:00:00–00:04:18:18".into()) } + + fn selected_effect(&self) -> Option { + if self.node_selection.len() != 1 { + return None; + } + let node = *self.node_selection.iter().next()?; + self.effect_for_node(node) + } } impl NodeGraphDataSource for MockEngine { diff --git a/src/oakui/nodegraph.rs b/src/oakui/nodegraph.rs index 896e72f39..3a98a1aff 100644 --- a/src/oakui/nodegraph.rs +++ b/src/oakui/nodegraph.rs @@ -287,6 +287,35 @@ struct TypedNode { type_id: String, } +/// The displayable nodes of ONE clip's context chain — the per-clip node +/// view the editor shows while that clip is selected: the clip block node +/// plus its effect chain in signal order. The chain (from +/// [`effectchain::chain`]) walks the `tex_in`/effect-input link all the way +/// to the media source, so it already contains the footage node feeding the +/// clip (`[footage, effect1, effect2, ...]`); concatenating the clip yields +/// the full `footage → effects → clip` chain. The sequence node is NOT part +/// of a clip's context — the chain is self-contained, so the filtered view +/// shows no output card and no synthesized clip→output wires. +fn clip_context_nodes(g: &oaknode::graph::Graph, clip: DomainNodeId) -> Vec { + let mut out: Vec = Vec::new(); + // The clip block card (the chain's output end). + out.push(TypedNode { + id: clip, + ident: clip.identity(), + type_id: TYPE_ID_CLIP_BLOCK.into(), + }); + // The media → effects chain, closest-to-source first (the same order + // the effect stack lists cards in). + for node in crate::oakui::effectchain::chain(g, clip) { + out.push(TypedNode { + id: node, + ident: node.identity(), + type_id: graphops::node_type_id(g, node), + }); + } + out +} + /// The displayable nodes of the sequence's graph, in identity order (the /// sequence node itself exactly once). fn graph_nodes(g: &oaknode::graph::Graph, seq: DomainNodeId) -> Vec { @@ -336,8 +365,14 @@ fn context_position( } } -/// Build the (nodes, edges) snapshot of the sequence's node graph. -pub fn build_graph(project: &ProjectRef, seq: DomainNodeId) -> (Vec, Vec) { +/// Build the (nodes, edges) snapshot of the sequence's node graph, or of a +/// single clip's context chain when `clip` is `Some` (the per-clip view the +/// editor shows while that clip is selected). +fn build_graph_impl( + project: &ProjectRef, + seq: DomainNodeId, + clip: Option, +) -> (Vec, Vec) { let g = graphops::lock(project); let g = &g.graph; let mut nodes = Vec::new(); @@ -345,6 +380,11 @@ pub fn build_graph(project: &ProjectRef, seq: DomainNodeId) -> (Vec, V if !g.is_valid(seq) { return (nodes, edges); } + if let Some(clip) = clip { + if !g.is_valid(clip) { + return (nodes, edges); + } + } let seq_ident = seq.identity(); let seq_label = graphops::node_label(g, seq); let seq_name = g @@ -352,7 +392,15 @@ pub fn build_graph(project: &ProjectRef, seq: DomainNodeId) -> (Vec, V .map(|e| e.behavior.name().to_string()) .unwrap_or_default(); - let all = graph_nodes(g, seq); + // The displayable node set: the whole sequence graph, or one clip's + // context chain (footage → effects → clip). + let all = match clip { + Some(clip) => clip_context_nodes(g, clip), + None => graph_nodes(g, seq), + }; + // The synthesized "clip → output" wire only exists in the full-sequence + // view: a per-clip context has no output card to wire into. + let with_output_wires = clip.is_none(); // Build every card's ports first (inputs, the implicit output), so // real edges can resolve their target port index by matching the @@ -427,7 +475,9 @@ pub fn build_graph(project: &ProjectRef, seq: DomainNodeId) -> (Vec, V // Outgoing REAL edges: every source node's output connections, with // the target port resolved to the index of the input whose id matches // (the module stores edges by input id; the index may differ from 0 — - // e.g. a clip's `tex_in` sits after `enabled_in`). + // e.g. a clip's `tex_in` sits after `enabled_in`). Edges whose target + // is not part of THIS view are skipped: a per-clip context ends at the + // clip, so its outgoing sequence/track edges are not part of the chain. let mut node_edges: Vec<(u64, Vec)> = Vec::new(); for (typed, _) in &built { let mut edges_of = Vec::new(); @@ -436,6 +486,9 @@ pub fn build_graph(project: &ProjectRef, seq: DomainNodeId) -> (Vec, V continue; } let to_ident = to.identity(); + if !built.iter().any(|(t, _)| t.ident == to_ident) { + continue; + } let to_index = built .iter() .find(|(t, _)| t.ident == to_ident) @@ -474,10 +527,10 @@ pub fn build_graph(project: &ProjectRef, seq: DomainNodeId) -> (Vec, V // Assemble: every built card + its real edges, plus the synthesized // "clip → output" wires (each clip's main output into the sequence's - // `tex_in`, its first declared input). + // `tex_in`, its first declared input) — full-sequence view only. let clip_input = port_id(seq_ident, PortKind::Input, 0); for (typed, node) in built { - if typed.type_id == TYPE_ID_CLIP_BLOCK { + if with_output_wires && typed.type_id == TYPE_ID_CLIP_BLOCK { edges.push(RealEdge { id: output_wire_id(typed.ident), from_node: node.id, @@ -494,6 +547,27 @@ pub fn build_graph(project: &ProjectRef, seq: DomainNodeId) -> (Vec, V (nodes, edges) } +/// Build the (nodes, edges) snapshot of the sequence's node graph. +pub fn build_graph(project: &ProjectRef, seq: DomainNodeId) -> (Vec, Vec) { + build_graph_impl(project, seq, None) +} + +/// Build the (nodes, edges) snapshot of ONE clip's context chain — the +/// per-clip node view the editor shows while that clip is selected. The +/// chain is footage → effects → clip; the sequence output card and the +/// synthesized wires are not part of a clip's context. An invalid clip +/// identity yields an empty graph. +pub fn build_graph_for_clip( + project: &ProjectRef, + seq: DomainNodeId, + clip_ident: u64, +) -> (Vec, Vec) { + let Some(clip) = graphops::id_of(clip_ident) else { + return (Vec::new(), Vec::new()); + }; + build_graph_impl(project, seq, Some(clip)) +} + /// The role column of a displayed node (footage | effects | clips | /// output); drives the fallback grid's x position. fn role_of(type_id: &str, is_output: bool) -> u32 { @@ -702,3 +776,116 @@ pub fn apply_edit( _ => Ok(()), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::oakui::effectchain; + use crate::oakui::graphops; + + /// Serializes with the other app test modules (the process-global undo + /// stack). + fn stack_lock() -> std::sync::MutexGuard<'static, ()> { + crate::oakui::graphops::test_lock() + } + + /// A project with a sequence and a clip whose chain runs footage → one + /// effect → clip: `(project, seq, clip, effect, footage)`. + fn project_with_chained_clip() -> ( + ProjectRef, + DomainNodeId, + DomainNodeId, + DomainNodeId, + DomainNodeId, + ) { + let project = graphops::create_project(); + let seq = graphops::create_sequence(&project, "Chain"); + let clip = { + let mut g = graphops::lock(&project); + let (core, behavior) = oaknode::block::clip_create(); + g.graph.add_node(core, behavior) + }; + // Insert the effect on the bare clip first (the chain is empty, so + // the insert rewires nothing and connects the effect to the clip's + // `tex_in`). + let ty = effectchain::addable_effects() + .into_iter() + .next() + .expect("the factory registers at least one video effect") + .type_id; + let effect = effectchain::insert(&project, clip, 0, &ty).expect("chain the effect"); + // Then feed the effect's media input from a footage node: the chain + // becomes footage → effect → clip. + let effect_input = graphops::lock(&project) + .graph + .get(effect) + .map(|e| e.core.effect_input.clone()) + .unwrap_or_default(); + let footage = { + let mut g = graphops::lock(&project); + let (core, behavior) = oaknode::footage::FootageBehavior::create(); + g.graph.add_node(core, behavior) + }; + { + let mut g = graphops::lock(&project); + g.graph + .connect(footage, effect, &effect_input, -1) + .expect("wire the footage into the effect"); + } + (project, seq, clip, effect, footage) + } + + /// The per-clip builder yields exactly the clip's context chain + /// (footage → effect → clip): no sequence output card, no synthesized + /// wires, and the chain's real edges connect footage → effect → clip. + /// The full-sequence builder still shows the output card and wires. + #[test] + fn clip_context_build_is_the_clip_chain_only() { + let _g = stack_lock(); + oakundo::global::clear().unwrap(); + let (project, seq, clip, effect, footage) = project_with_chained_clip(); + + let (nodes, edges) = build_graph_for_clip(&project, seq, clip.identity()); + let ids: Vec = nodes.iter().map(|n| n.id.0).collect(); + assert_eq!(ids.len(), 3, "clip + effect + footage (got {ids:?})"); + assert!(ids.contains(&clip.identity()), "the clip node is present"); + assert!(ids.contains(&effect.identity()), "the effect node is present"); + assert!(ids.contains(&footage.identity()), "the footage node is present"); + assert!( + !ids.contains(&seq.identity()), + "the sequence output card is not part of a clip's context" + ); + + // A per-clip view carries no synthesized clip→output wires. + assert!( + edges.iter().all(|e| !is_output_wire(e.id)), + "the per-clip view has no synthesized output wires" + ); + // The chain's real edges: footage feeds the effect, which feeds the + // clip. + assert!( + edges.iter().any(|e| e.from_node == NodeId(footage.identity()) + && e.to_node == NodeId(effect.identity())), + "footage feeds the effect" + ); + assert!( + edges.iter().any(|e| e.from_node == NodeId(effect.identity()) + && e.to_node == NodeId(clip.identity())), + "the effect feeds the clip" + ); + + // The full-sequence view keeps the output card and the synthesized + // clip→output wire. + let (full, full_edges) = build_graph(&project, seq); + assert!( + full.iter().any(|n| n.id == NodeId(seq.identity())), + "the full graph shows the sequence output card" + ); + assert!( + full_edges.iter().any(|e| is_output_wire(e.id)), + "the full graph synthesizes the clip→output wire" + ); + + oakundo::global::clear().unwrap(); + } +} diff --git a/src/oakui/real.rs b/src/oakui/real.rs index e579f3053..55ab54ab4 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -957,8 +957,14 @@ pub struct RealEngine { selected_item: Option, /// The single selected timeline clip — the effect stack's target /// (`None` for an empty or multi-clip selection, or before any - /// selection event). + /// selection event). Also drives the node graph's scope: while a single + /// clip is selected, the editor shows that clip's context chain only. selected_clip: Option, + /// The node-graph selection mirror (`None` = no single node selected): + /// set when the user clicks a node in the node editor (or an effect + /// card in the inspector) so the inspector and the node graph share one + /// selection. A timeline clip selection also writes its block node here. + selected_graph_node: Option, /// The engine clipboard for Cut/Copy/Paste (graphops::ClipboardClip /// entries in timeline order). clipboard: Vec, @@ -1141,6 +1147,7 @@ impl RealEngine { waveforms: Mutex::new(None), selected_item: None, selected_clip: None, + selected_graph_node: None, clipboard: Vec::new(), expanded_effects: BTreeSet::new(), program_playing: false, @@ -2728,16 +2735,62 @@ impl RealEngine { .unwrap_or(false) } + /// Resolves the node-graph selection to the inspector's view: which + /// clip's stack to show, and which effect card (if any) to highlight. + /// A single selected node that names a clip block on the current + /// timeline selects that clip's stack; one that names an effect of some + /// clip's chain selects that clip's stack and returns the matching + /// card. Without a node-graph selection the timeline-selected clip + /// (the existing behavior) is the target. + fn inspector_selection(&self) -> (Option, Option) { + let Some(project) = self.project_ref() else { + return (self.selected_clip, None); + }; + let Some(ident) = self.selected_graph_node else { + return (self.selected_clip, None); + }; + let Some(node) = graphops::id_of(ident) else { + return (self.selected_clip, None); + }; + let guard = graphops::lock(project); + // A clip block node on the current timeline: its stack is the target. + if graphops::clip_behavior(&guard.graph, node).is_some() + && self.tracks.iter().any(|t| t.clips.iter().any(|c| c.id.0 == ident)) + { + return (Some(ClipId(ident)), None); + } + // An effect node of some clip's chain: that clip's stack, with the + // matching card highlighted. + for track in &self.tracks { + for clip in &track.clips { + let Some(block) = graphops::id_of(clip.id.0) else { + continue; + }; + if graphops::clip_behavior(&guard.graph, block).is_none() { + continue; + } + if let Some(effect) = super::effectchain::chain(&guard.graph, block) + .iter() + .find(|n| n.identity() == ident) + { + return (Some(clip.id()), Some(EffectId(effect.identity()))); + } + } + } + (self.selected_clip, None) + } + /// The selected clip's block node, or `None` when no single clip is - /// selected. + /// selected. The inspector's stack target follows the node-graph + /// selection (see [`inspector_selection`](Self::inspector_selection)). fn selected_clip_node(&self) -> Option { - self.clip_block(self.selected_clip?) + self.clip_block(self.inspector_selection().0?) } /// The display label of the selected clip (its timeline snapshot /// label), if any. fn selected_clip_label(&self) -> Option { - let clip_id = self.selected_clip?; + let clip_id = self.inspector_selection().0?; for track in &self.tracks { if let Some(clip) = track.clips.iter().find(|c| c.id() == clip_id) { return Some(clip.label()); @@ -3000,6 +3053,10 @@ impl EffectStackDataSource for RealEngine { super::effectchain::effect_input_of(&guard.graph, host)?; Some(label) } + + fn selected_effect(&self) -> Option { + self.inspector_selection().1 + } } impl NodeGraphDataSource for RealEngine { @@ -3010,14 +3067,23 @@ impl NodeGraphDataSource for RealEngine { let (Some(project), Some(seq)) = (self.project_ref(), self.sequence) else { return Vec::new(); }; - crate::oakui::nodegraph::build_graph(project, seq).0 + match self.selected_clip { + // While one clip is selected the editor shows that clip's + // context chain (footage → effects → clip) instead of the whole + // sequence graph. + Some(clip) => crate::oakui::nodegraph::build_graph_for_clip(project, seq, clip.0).0, + None => crate::oakui::nodegraph::build_graph(project, seq).0, + } } fn edges(&self) -> Vec { let (Some(project), Some(seq)) = (self.project_ref(), self.sequence) else { return Vec::new(); }; - crate::oakui::nodegraph::build_graph(project, seq).1 + match self.selected_clip { + Some(clip) => crate::oakui::nodegraph::build_graph_for_clip(project, seq, clip.0).1, + None => crate::oakui::nodegraph::build_graph(project, seq).1, + } } fn can_connect(&self, from: gpui::node_graph::PortId, to: gpui::node_graph::PortId) -> bool { @@ -3218,11 +3284,18 @@ impl AppEngine for RealEngine { fn set_selected_clips(&mut self, clips: Vec, cx: &mut Context) { // The effect stack targets exactly one clip: an empty or // multi-clip selection keeps the empty state (see - // `EffectStackDataSource::target_label`). + // `EffectStackDataSource::target_label`). The node graph follows: + // while one clip is selected it shows (and highlights) that clip's + // context chain, so its block node becomes the graph selection. self.selected_clip = (clips.len() == 1).then(|| clips[0]); + self.selected_graph_node = self.selected_clip.map(|clip| clip.0); cx.notify(); } + fn selected_graph_node(&self) -> Option { + self.selected_graph_node + } + fn addable_effects(&self) -> Vec { super::effectchain::addable_effects() } @@ -3376,6 +3449,14 @@ impl AppEngine for RealEngine { let _ = index; cx.notify(); } + EffectStackEvent::CardSelected { effect } => { + // The inspector card click selects the effect's node in the + // node editor (the bidirectional node↔inspector link). The + // node editor panel observes the engine and pushes the + // highlight into the graph widget. + self.selected_graph_node = Some(effect.0); + cx.notify(); + } // The app owns the context menu; parameter changes have no // metadata to refresh yet. EffectStackEvent::ContextMenuRequested { .. } @@ -3394,10 +3475,29 @@ impl AppEngine for RealEngine { NodeGraphEvent::NodeMovePreview { .. } | NodeGraphEvent::ViewChanged { .. } | NodeGraphEvent::BackgroundClicked { .. } - | NodeGraphEvent::SelectionChanged { .. } // The node editor panel answers the right-click itself (it owns // the popup); the engine has nothing to apply. | NodeGraphEvent::NodeContextMenuRequested { .. } => {} + NodeGraphEvent::SelectionChanged { nodes } => { + // The node-graph selection is the inspector's shared + // selection: a single selected node is mirrored into + // `selected_graph_node`, and when it names an effect of the + // targeted clip's chain its card is expanded too (so the + // inspector shows that effect's params). + let prev = self.selected_graph_node; + self.selected_graph_node = (nodes.len() == 1).then(|| { + (*nodes + .iter() + .next() + .expect("a one-element set always yields an item")) + .0 + }); + if prev != self.selected_graph_node { + if let Some(effect) = self.selected_effect() { + self.expanded_effects.insert(effect.0); + } + } + } _ => { if let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) { let result = crate::oakui::nodegraph::apply_edit(&project, seq, event); @@ -5885,6 +5985,138 @@ mod tests { let _ = std::fs::remove_file(&media); } + /// The selection linkage: selecting a timeline clip narrows the node + /// graph to that clip's context chain and mirrors its block node as the + /// graph selection; selecting the effect's node in the graph updates the + /// inspector's stack target and the highlighted effect card; and a card + /// click selects the effect node again (the reverse direction). + #[gpui::test] + async fn selection_links_timeline_graph_and_inspector(cx: &mut gpui::TestAppContext) { + let _media = media_lock(); + let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx))); + + let (clip_id, effect_ident, footage_ident) = cx.update(|app| { + engine.update(app, |engine, cx| { + let project = graphops::create_project(); + let seq = graphops::create_sequence(&project, "Selection Link"); + graphops::add_track(&project, seq, TrackType::Video).expect("a video track"); + // The clip: a bare block whose chain gets the effect first + // (the effect-chain insert on an empty chain rewires nothing + // and connects the effect to the clip's `tex_in`), then the + // footage feeds the effect's media input. + let clip = oaktimeline::util::block_clip_create(&project); + let ty = crate::oakui::effectchain::addable_effects() + .into_iter() + .next() + .expect("an addable effect") + .type_id; + let effect = + crate::oakui::effectchain::insert(&project, clip.id, 0, &ty).expect("chain it"); + let media = std::env::temp_dir().join(format!( + "oakapp_sel_link_{}.mp4", + std::process::id() + )); + oakcodec::testmedia::write_test_clip(&media, 32, 32, 10, 10) + .expect("generate test media"); + let footage = graphops::import_footage(&project, &media).expect("import the media"); + let effect_input = graphops::lock(&project) + .graph + .get(effect) + .map(|e| e.core.effect_input.clone()) + .unwrap_or_default(); + { + let mut g = graphops::lock(&project); + g.graph + .connect(footage, effect, &effect_input, -1) + .expect("wire the footage into the effect"); + } + // Place the clip on the track so the engine's timeline + // snapshot includes it (the inspector walks the snapshot). + let track0 = { + let g = graphops::lock(&project); + graphops::track_ids(&g.graph, seq, TrackType::Video)[0] + }; + oaktimeline::util::track_append_block( + &oaktimeline::util::NodeRef::new(project.clone(), track0), + &clip, + ); + let _ = std::fs::remove_file(&media); + engine.adopt_project(project, cx); + (ClipId(clip.id.identity()), effect.identity(), footage.identity()) + }) + }); + + // A single clip selection narrows the node graph to the chain and + // mirrors the block node as the graph selection. + cx.update(|app| { + engine.update(app, |engine, cx| engine.set_selected_clips(vec![clip_id], cx)) + }); + assert_eq!( + cx.read(|app| engine.read(app).selected_graph_node()), + Some(clip_id.0), + "a clip selection highlights its block node" + ); + let ids: Vec = cx + .read(|app| engine.read(app).nodes().into_iter().map(|n| n.id.0).collect()); + assert_eq!(ids.len(), 3, "only the clip's context chain is shown (got {ids:?})"); + assert!(ids.contains(&clip_id.0), "the clip node is part of the chain"); + assert!(ids.contains(&effect_ident), "the effect is part of the chain"); + assert!(ids.contains(&footage_ident), "the footage is part of the chain"); + + // Selecting the effect node in the graph retargets the inspector to + // the owning clip and highlights the matching card. + cx.update(|app| { + engine.update(app, |engine, cx| { + engine.apply_node_graph_event( + &NodeGraphEvent::SelectionChanged { + nodes: BTreeSet::from([gpui::node_graph::NodeId(effect_ident)]), + }, + cx, + ); + }) + }); + assert_eq!( + cx.read(|app| engine.read(app).selected_graph_node()), + Some(effect_ident), + "the graph selection mirrors the clicked node" + ); + let cards = cx.read(|app| engine.read(app).effects()); + assert_eq!( + cards.len(), + 2, + "the inspector shows the owning clip's chain (media source + effect)" + ); + assert_eq!( + cx.read(|app| engine.read(app).selected_effect()), + Some(EffectId(effect_ident)), + "the selected node highlights its effect card" + ); + assert!( + cx.read(|app| engine.read(app).target_label()).is_some(), + "the stack keeps its target label" + ); + + // An inspector card click selects the effect node again (the reverse + // direction of the bidirectional link). + cx.update(|app| { + engine.update(app, |engine, cx| { + engine.apply_effect_event( + &EffectStackEvent::CardSelected { + effect: EffectId(effect_ident), + }, + cx, + ); + }) + }); + assert_eq!( + cx.read(|app| engine.read(app).selected_graph_node()), + Some(effect_ident), + "a card click re-selects the effect's node" + ); + + oakundo::global::clear().unwrap(); + } + /// Regression: the source monitor's full-res job renders the selected /// footage while holding its own project `Arc` — dropping the engine's /// project reference while the job is in flight leaves the job's copy diff --git a/src/panels/inspector.rs b/src/panels/inspector.rs index 752238ea5..e4b7c499e 100644 --- a/src/panels/inspector.rs +++ b/src/panels/inspector.rs @@ -79,6 +79,11 @@ impl InspectorPanel { }) .detach(); + // The stack re-renders whenever the engine changes (selection-driven + // highlight, effect edits, project drops) — the stack itself does not + // subscribe to the engine entity. + cx.observe(&engine, |_this, _engine, cx| cx.notify()).detach(); + let context_menu = ContextMenuHandle::new(Self::on_local_menu_item, window, cx); Self { diff --git a/src/panels/node_editor.rs b/src/panels/node_editor.rs index ba6774431..0c84223ed 100644 --- a/src/panels/node_editor.rs +++ b/src/panels/node_editor.rs @@ -25,10 +25,13 @@ //! view re-reads on the next frame. The toolbar buttons drive the viewport //! directly: zoom in/out at the canvas center, or fit the whole graph. +use std::collections::BTreeSet; + use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; use gpui::node_graph::{ - NodeData, NodeElement, NodeGraphEvent, NodeGraphView, NodeVisualState, MAX_ZOOM, MIN_ZOOM, + NodeData, NodeElement, NodeGraphEvent, NodeGraphView, NodeId, NodeVisualState, MAX_ZOOM, + MIN_ZOOM, }; use gpui::{ div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity, @@ -63,6 +66,11 @@ pub struct NodeEditorPanel { /// The Add-menu item ids currently on offer, mapped to their factory /// type ids (rebuilt whenever the menu opens). add_menu_ids: Vec<(usize, String)>, + /// The engine's node-graph selection mirror the widget was last synced + /// to (see [`Self::sync_graph_selection`]): the panel only pushes into + /// the widget when the engine's authoritative selection changes, so a + /// marquee or click selection inside the graph is never overwritten. + last_graph_selection: Option, } impl NodeEditorPanel { @@ -99,9 +107,18 @@ impl NodeEditorPanel { ) .detach(); + // The engine's selection mirror is the single source of truth for + // what the graph highlights: a timeline clip selection (req: the + // selected clip's block node) and an inspector card click both land + // in `selected_graph_node`, and this pushes it into the widget. + cx.observe(&engine, |this, _engine, cx| { + this.sync_graph_selection(cx); + }) + .detach(); + let context_menu = ContextMenuHandle::new(Self::on_local_menu_item, window, cx); - Self { + let mut panel = Self { graph, engine, fitted: false, @@ -109,7 +126,32 @@ impl NodeEditorPanel { last_right_click: None, add_node_position: None, add_menu_ids: Vec::new(), + last_graph_selection: None, + }; + // If a clip (or graph node) is already selected when the panel is + // built, push the highlight immediately (the observe only fires on + // the next engine notify). + panel.sync_graph_selection(cx); + panel + } + + /// Pushes the engine's selection mirror into the graph widget: the + /// single selected node becomes the widget selection, so a timeline + /// clip selection highlights that clip's block node and an inspector + /// card click highlights the effect's node. `None` is never pushed — + /// the widget keeps its live selection (e.g. a marquee) until the + /// engine names a new authoritative node. + fn sync_graph_selection(&mut self, cx: &mut Context) { + let node = self.engine.read(cx).selected_graph_node(); + if node == self.last_graph_selection { + return; } + self.last_graph_selection = node; + let Some(node) = node else { + return; + }; + self.graph + .update(cx, |graph, cx| graph.set_selection(BTreeSet::from([NodeId(node)]), cx)); } /// Handles the node editor's local (non-registry) context-menu items. @@ -453,6 +495,7 @@ pub(crate) fn background_menu( mod tests { use super::*; use crate::oakui::MockEngine; + use gpui::effect_stack::{EffectId, EffectStackEvent}; use gpui::{size, TestAppContext, VisualTestContext}; /// Builds the panel in a window and returns a `VisualTestContext` for @@ -593,4 +636,52 @@ mod tests { assert!(add_menu_ids.is_empty()); assert!(menu.items[2].submenu.as_ref().unwrap().items.is_empty()); } + + /// The engine's selection mirror is pushed into the graph widget: a node + /// selection (a node click, or an inspector card click) lands in the + /// engine through the panel's event loop, and the panel's observe then + /// sets the widget selection — so the graph, the inspector and the + /// timeline share one highlight. + #[gpui::test] + async fn engine_selection_syncs_into_the_graph_widget(cx: &mut TestAppContext) { + let (cx, panel) = panel_window(cx); + + // A node click round trip: the engine applies the SelectionChanged + // (as the panel subscription would forward it), and the observe + // pushes the mirrored selection into the widget. + cx.update(|_window, app| { + let engine = panel.read(app).engine.clone(); + engine.update(app, |engine, cx| { + engine.apply_node_graph_event( + &NodeGraphEvent::SelectionChanged { + nodes: BTreeSet::from([NodeId(2)]), + }, + cx, + ); + }); + }); + cx.run_until_parked(); + + let selection = cx.read(|app| panel.read(app).graph.read(app).state().selection().clone()); + assert!( + selection.contains(&NodeId(2)), + "the widget highlights the mirrored node (got {selection:?})" + ); + + // An inspector card click (the "变换" card) selects the same-named + // node through the same mirror. + cx.update(|_window, app| { + let engine = panel.read(app).engine.clone(); + engine.update(app, |engine, cx| { + engine.apply_effect_event(&EffectStackEvent::CardSelected { effect: EffectId(1) }, cx); + }); + }); + cx.run_until_parked(); + + let selection = cx.read(|app| panel.read(app).graph.read(app).state().selection().clone()); + assert!( + selection.contains(&NodeId(2)), + "the card click highlights the matching node (got {selection:?})" + ); + } }