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).
This commit is contained in:
2026-08-20 17:08:26 +08:00
parent d7cbeba850
commit 02cea7e3ee
7 changed files with 598 additions and 17 deletions
+9
View File
@@ -327,6 +327,15 @@ pub trait AppEngine:
/// selection-driven stack keep their existing behavior).
fn set_selected_clips(&mut self, _clips: Vec<ClipId>, _cx: &mut Context<Self>) {}
/// 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<u64> {
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
+57
View File
@@ -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<NodeId> {
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<EffectId> {
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<u64> {
if self.node_selection.len() != 1 {
return None;
}
let node = *self.node_selection.iter().next()?;
Some(node.0)
}
fn addable_effects(&self) -> Vec<crate::oakui::engine::EffectEntry> {
// 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<SharedString> {
Some("第一稿.mp4 · 00:00:00:0000:04:18:18".into())
}
fn selected_effect(&self) -> Option<EffectId> {
if self.node_selection.len() != 1 {
return None;
}
let node = *self.node_selection.iter().next()?;
self.effect_for_node(node)
}
}
impl NodeGraphDataSource for MockEngine {
+193 -6
View File
@@ -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<TypedNode> {
let mut out: Vec<TypedNode> = 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<TypedNode> {
@@ -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<RealNode>, Vec<RealEdge>) {
/// 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<DomainNodeId>,
) -> (Vec<RealNode>, Vec<RealEdge>) {
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<RealNode>, 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<RealNode>, 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<RealNode>, 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<RealEdge>)> = 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<RealNode>, 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<RealNode>, 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<RealNode>, V
(nodes, edges)
}
/// Build the (nodes, edges) snapshot of the sequence's node graph.
pub fn build_graph(project: &ProjectRef, seq: DomainNodeId) -> (Vec<RealNode>, Vec<RealEdge>) {
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<RealNode>, Vec<RealEdge>) {
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<u64> = 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();
}
}
+240 -8
View File
@@ -957,8 +957,14 @@ pub struct RealEngine {
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).
/// 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<ClipId>,
/// 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<u64>,
/// The engine clipboard for Cut/Copy/Paste (graphops::ClipboardClip
/// entries in timeline order).
clipboard: Vec<graphops::ClipboardClip>,
@@ -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<ClipId>, Option<EffectId>) {
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<NodeId> {
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<SharedString> {
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<EffectId> {
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<Self::Edge> {
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<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`).
// `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<u64> {
self.selected_graph_node
}
fn addable_effects(&self) -> Vec<crate::oakui::engine::EffectEntry> {
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<u64> = 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
+5
View File
@@ -79,6 +79,11 @@ impl<E: AppEngine> InspectorPanel<E> {
})
.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 {
+93 -2
View File
@@ -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<E: AppEngine> {
/// 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<u64>,
}
impl<E: AppEngine> NodeEditorPanel<E> {
@@ -99,9 +107,18 @@ impl<E: AppEngine> NodeEditorPanel<E> {
)
.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<E: AppEngine> NodeEditorPanel<E> {
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<Self>) {
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:?})"
);
}
}