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:
@@ -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
|
||||
|
||||
@@ -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:00–00: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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user