app: show the graph endpoints in the node editor, protected
The GraphInput/GraphOutput cards render through the same build path as every other node (real graph data, fixed header accents outside both palettes), GraphOutput as a pure sink with no output ports. UI-layer protection keeps the pair fixed: the context menu drops the whole edit section (cut/copy/paste/duplicate/rename/delete) for them, and delete requests naming an endpoint are narrowed at the panel — the endpoints and the wires hanging off them always stay, a request left with nothing is dropped whole. The engine reports the protected set through AppEngine::protected_graph_nodes (the real engine resolves Graph::endpoints; the mock demo graph carries its own marked pair).
This commit is contained in:
@@ -36,7 +36,7 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::effect_stack::{EffectId, EffectStackDataSource, EffectStackEvent};
|
||||
use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent};
|
||||
use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent, NodeId};
|
||||
use gpui::timeline::{
|
||||
ClipId, Frame, FrameRate, TimelineDataSource, TimelineEvent, TrackData, TrackKind,
|
||||
};
|
||||
@@ -377,6 +377,15 @@ pub trait AppEngine:
|
||||
None
|
||||
}
|
||||
|
||||
/// The graph nodes the UI must not let the user edit: the virtual
|
||||
/// endpoints (`GraphInput` / `GraphOutput`), which every project graph
|
||||
/// carries as fixed furniture. The node-editor panel hides these nodes'
|
||||
/// edit entries and drops delete requests naming them, so the model
|
||||
/// never sees that edit. Default: none (engines without endpoints).
|
||||
fn protected_graph_nodes(&self) -> Vec<NodeId> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -71,6 +71,7 @@ use super::engine::{
|
||||
Project, ScopeData, Sequence, VideoFormat, WizardFootage, WizardSyncOffset,
|
||||
};
|
||||
use super::graphops;
|
||||
use super::nodegraph::GraphEndpoint;
|
||||
use super::transport::TransportState;
|
||||
|
||||
/// The demo sequence length: 00:04:18:18 at 25 fps.
|
||||
@@ -393,6 +394,16 @@ pub struct MockNode {
|
||||
header_color: Option<Hsla>,
|
||||
enabled: bool,
|
||||
collapsed: bool,
|
||||
/// The virtual endpoint role, when this card is one of the demo graph's
|
||||
/// fixed endpoint pair (`None` for ordinary nodes).
|
||||
endpoint: Option<GraphEndpoint>,
|
||||
}
|
||||
|
||||
impl MockNode {
|
||||
/// The virtual endpoint role of this card, if it is an endpoint.
|
||||
pub fn endpoint(&self) -> Option<GraphEndpoint> {
|
||||
self.endpoint
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeData for MockNode {
|
||||
@@ -627,7 +638,8 @@ impl MockEngine {
|
||||
position: (f32, f32),
|
||||
color: f32,
|
||||
inputs: Vec<MockPort>,
|
||||
outputs: Vec<MockPort>| MockNode {
|
||||
outputs: Vec<MockPort>,
|
||||
endpoint: Option<GraphEndpoint>| MockNode {
|
||||
id: NodeId(id),
|
||||
title: title.into(),
|
||||
position: point(px(position.0), px(position.1)),
|
||||
@@ -636,6 +648,7 @@ impl MockEngine {
|
||||
header_color: Some(node_color(color)),
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
endpoint,
|
||||
};
|
||||
let nodes = vec![
|
||||
node(
|
||||
@@ -645,6 +658,7 @@ impl MockEngine {
|
||||
0.55,
|
||||
vec![],
|
||||
vec![port(1, PortKind::Output, "video", &video_type)],
|
||||
None,
|
||||
),
|
||||
node(
|
||||
1,
|
||||
@@ -656,6 +670,7 @@ impl MockEngine {
|
||||
port(3, PortKind::Output, "video", &video_type),
|
||||
port(4, PortKind::Output, "audio", &audio_type),
|
||||
],
|
||||
None,
|
||||
),
|
||||
node(
|
||||
2,
|
||||
@@ -667,6 +682,7 @@ impl MockEngine {
|
||||
port(22, PortKind::Input, "mask", &video_type),
|
||||
],
|
||||
vec![port(21, PortKind::Output, "out", &video_type)],
|
||||
None,
|
||||
),
|
||||
node(
|
||||
3,
|
||||
@@ -675,6 +691,7 @@ impl MockEngine {
|
||||
0.78,
|
||||
vec![port(30, PortKind::Input, "in", &video_type)],
|
||||
vec![port(31, PortKind::Output, "out", &video_type)],
|
||||
None,
|
||||
),
|
||||
node(
|
||||
4,
|
||||
@@ -686,6 +703,7 @@ impl MockEngine {
|
||||
port(41, PortKind::Input, "B", &video_type),
|
||||
],
|
||||
vec![port(42, PortKind::Output, "out", &video_type)],
|
||||
None,
|
||||
),
|
||||
node(
|
||||
5,
|
||||
@@ -694,6 +712,40 @@ impl MockEngine {
|
||||
0.0,
|
||||
vec![port(50, PortKind::Input, "in", &video_type)],
|
||||
vec![],
|
||||
None,
|
||||
),
|
||||
// The fixed virtual endpoint pair every project graph carries:
|
||||
// the input endpoint feeds the walk, the output endpoint is the
|
||||
// frame's read-out (a sink — no output port). Their hues match
|
||||
// `nodegraph::endpoint_color` (0.40 / 0.85) so demo endpoints carry
|
||||
// the same fixed accent as real engine ones.
|
||||
node(
|
||||
6,
|
||||
crate::i18n::tr("node.graphinput.name"),
|
||||
(1160.0, 40.0),
|
||||
0.40,
|
||||
vec![port(
|
||||
60,
|
||||
PortKind::Input,
|
||||
crate::i18n::tr("node.graphinput.input.feed_in"),
|
||||
&video_type,
|
||||
)],
|
||||
vec![port(61, PortKind::Output, "out", &video_type)],
|
||||
Some(GraphEndpoint::Input),
|
||||
),
|
||||
node(
|
||||
7,
|
||||
crate::i18n::tr("node.graphoutput.name"),
|
||||
(1160.0, 230.0),
|
||||
0.85,
|
||||
vec![port(
|
||||
62,
|
||||
PortKind::Input,
|
||||
crate::i18n::tr("node.graphoutput.input.tex_in"),
|
||||
&video_type,
|
||||
)],
|
||||
vec![],
|
||||
Some(GraphEndpoint::Output),
|
||||
),
|
||||
];
|
||||
let edge = |id: u64, from_node: u64, from_port: u64, to_node: u64, to_port: u64| MockEdge {
|
||||
@@ -709,6 +761,9 @@ impl MockEngine {
|
||||
edge(3, 2, 21, 4, 40),
|
||||
edge(4, 3, 31, 4, 41),
|
||||
edge(5, 4, 42, 5, 50),
|
||||
// The endpoint pair's default wire: input endpoint → output
|
||||
// endpoint (the graph's initial `feed_in → tex_in` link).
|
||||
edge(6, 6, 61, 7, 62),
|
||||
];
|
||||
|
||||
let mut this = Self {
|
||||
@@ -812,7 +867,7 @@ impl MockEngine {
|
||||
meter_phase: 0,
|
||||
nodes,
|
||||
edges,
|
||||
next_edge_id: 6,
|
||||
next_edge_id: 7,
|
||||
next_node_id: 100,
|
||||
next_port_id: 1000,
|
||||
node_selection: BTreeSet::new(),
|
||||
@@ -1420,6 +1475,14 @@ impl AppEngine for MockEngine {
|
||||
Some(node.0)
|
||||
}
|
||||
|
||||
fn protected_graph_nodes(&self) -> Vec<NodeId> {
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|node| node.endpoint.is_some())
|
||||
.map(|node| node.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1655,6 +1718,7 @@ impl AppEngine for MockEngine {
|
||||
header_color: None,
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
endpoint: None,
|
||||
};
|
||||
self.next_node_id += 1;
|
||||
self.nodes.push(node);
|
||||
@@ -3602,15 +3666,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn demo_graph_has_six_nodes_and_five_edges(cx: &mut TestAppContext) {
|
||||
async fn demo_graph_has_eight_nodes_and_six_edges(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
let engine = engine.read(app);
|
||||
let nodes = engine.nodes();
|
||||
let edges = engine.edges();
|
||||
|
||||
assert_eq!(nodes.len(), 6, "media, transform, blur, mixer, viewer");
|
||||
assert_eq!(edges.len(), 5);
|
||||
assert_eq!(
|
||||
nodes.len(),
|
||||
8,
|
||||
"media, transform, blur, mixer, viewer, and the endpoint pair"
|
||||
);
|
||||
assert_eq!(edges.len(), 6);
|
||||
|
||||
// The chain ends at the viewer node, fed by the mixer.
|
||||
let viewer = nodes
|
||||
@@ -3718,7 +3786,7 @@ mod tests {
|
||||
);
|
||||
});
|
||||
let engine_read = engine.read(app);
|
||||
assert_eq!(engine_read.edges().len(), 6);
|
||||
assert_eq!(engine_read.edges().len(), 7);
|
||||
assert!(engine_read
|
||||
.port(PortId(22))
|
||||
.is_some_and(|p| p.is_connected()));
|
||||
@@ -3734,7 +3802,7 @@ mod tests {
|
||||
cx,
|
||||
);
|
||||
});
|
||||
assert_eq!(engine.read(app).edges().len(), 5);
|
||||
assert_eq!(engine.read(app).edges().len(), 6);
|
||||
assert!(
|
||||
!engine
|
||||
.read(app)
|
||||
@@ -3754,7 +3822,7 @@ mod tests {
|
||||
);
|
||||
});
|
||||
let engine_read = engine.read(app);
|
||||
assert_eq!(engine_read.nodes().len(), 5);
|
||||
assert_eq!(engine_read.nodes().len(), 7);
|
||||
assert!(
|
||||
!engine_read
|
||||
.edges()
|
||||
|
||||
@@ -140,7 +140,8 @@ pub struct RealNode {
|
||||
pub position: Point<Pixels>,
|
||||
/// Input ports (top to bottom).
|
||||
pub inputs: Vec<RealPort>,
|
||||
/// The single implicit output port.
|
||||
/// The implicit output port (`out`); empty on the output endpoint,
|
||||
/// which is a pure sink.
|
||||
pub outputs: Vec<RealPort>,
|
||||
/// Category accent.
|
||||
pub header_color: Option<Hsla>,
|
||||
@@ -148,6 +149,9 @@ pub struct RealNode {
|
||||
pub collapsed: bool,
|
||||
/// Enabled state.
|
||||
pub enabled: bool,
|
||||
/// The virtual endpoint role, when this card is one of the fixed
|
||||
/// endpoint pair (`None` for ordinary effect / media cards).
|
||||
pub endpoint: Option<GraphEndpoint>,
|
||||
}
|
||||
|
||||
impl NodeData for RealNode {
|
||||
@@ -280,6 +284,41 @@ fn is_displayed(type_id: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// The virtual endpoint pair every project graph carries: `GraphInput`
|
||||
/// (the evaluation walk's root) and `GraphOutput` (where the frame is
|
||||
/// read). Endpoints are shown as fixed cards — the panel hides their edit
|
||||
/// entries and filters delete requests naming them, and the graph model
|
||||
/// itself refuses to remove or copy the pair.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GraphEndpoint {
|
||||
/// `GraphInput`: data enters through `feed_in`, leaves through `tex_out`.
|
||||
Input,
|
||||
/// `GraphOutput`: every branch converges on `tex_in`; a pure sink.
|
||||
Output,
|
||||
}
|
||||
|
||||
/// Whether `type_id` names one of the virtual endpoints, and which.
|
||||
pub fn endpoint_of_type_id(type_id: &str) -> Option<GraphEndpoint> {
|
||||
use oak_node::nodes::graphendpoints::{GRAPH_INPUT_TYPE_ID, GRAPH_OUTPUT_TYPE_ID};
|
||||
if type_id == GRAPH_INPUT_TYPE_ID {
|
||||
Some(GraphEndpoint::Input)
|
||||
} else if type_id == GRAPH_OUTPUT_TYPE_ID {
|
||||
Some(GraphEndpoint::Output)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixed header accent of an endpoint card: the pair reads as
|
||||
/// structural furniture, not as one more effect (deliberately outside the
|
||||
/// [`node_color`] palette — and the demo graph's hand-picked hues).
|
||||
fn endpoint_color(endpoint: GraphEndpoint) -> Hsla {
|
||||
match endpoint {
|
||||
GraphEndpoint::Input => hsla(0.40, 0.55, 0.35, 1.0),
|
||||
GraphEndpoint::Output => hsla(0.85, 0.65, 0.45, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// A displayed node: its domain id plus its type id.
|
||||
struct TypedNode {
|
||||
/// The node's domain id.
|
||||
@@ -429,6 +468,7 @@ fn build_graph_impl(
|
||||
label
|
||||
}
|
||||
};
|
||||
let endpoint = endpoint_of_type_id(&typed.type_id);
|
||||
|
||||
// Inputs: only stream inputs (texture / samples) become ports — an
|
||||
// OFX plugin declares every PARAMETER as an input too, and those
|
||||
@@ -476,16 +516,22 @@ fn build_graph_impl(
|
||||
});
|
||||
}
|
||||
|
||||
// The single implicit output.
|
||||
let out_count = g.output_connections(typed.id).len();
|
||||
let outputs = vec![RealPort {
|
||||
id: port_id(ident, PortKind::Output, 0),
|
||||
kind: PortKind::Output,
|
||||
input_id: SharedString::new_static(""),
|
||||
label: SharedString::new_static("out"),
|
||||
data_type: out_type(),
|
||||
connected: out_count > 0,
|
||||
}];
|
||||
// The implicit output port. The output endpoint is a sink — nothing
|
||||
// is wired out of it, so it gets no port; the input endpoint keeps
|
||||
// its `tex_out` (the walk's root output).
|
||||
let outputs = if endpoint == Some(GraphEndpoint::Output) {
|
||||
Vec::new()
|
||||
} else {
|
||||
let out_count = g.output_connections(typed.id).len();
|
||||
vec![RealPort {
|
||||
id: port_id(ident, PortKind::Output, 0),
|
||||
kind: PortKind::Output,
|
||||
input_id: SharedString::new_static(""),
|
||||
label: SharedString::new_static("out"),
|
||||
data_type: out_type(),
|
||||
connected: out_count > 0,
|
||||
}]
|
||||
};
|
||||
|
||||
let position = context_position(g, seq, typed.id);
|
||||
built.push((
|
||||
@@ -496,9 +542,13 @@ fn build_graph_impl(
|
||||
position,
|
||||
inputs,
|
||||
outputs,
|
||||
header_color: Some(node_color(ident)),
|
||||
header_color: Some(match endpoint {
|
||||
Some(endpoint) => endpoint_color(endpoint),
|
||||
None => node_color(ident),
|
||||
}),
|
||||
collapsed: false,
|
||||
enabled: true,
|
||||
endpoint,
|
||||
},
|
||||
));
|
||||
}
|
||||
@@ -923,4 +973,50 @@ mod tests {
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// Every project graph carries the virtual endpoint pair: the full view
|
||||
/// shows both as marked cards (the output endpoint as a pure sink), and
|
||||
/// a clip's context chain shows neither.
|
||||
#[test]
|
||||
fn endpoints_build_as_marked_cards() {
|
||||
let _g = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
let (project, seq, clip, _, _) = project_with_chained_clip();
|
||||
|
||||
let (nodes, _edges) = build_graph(&project, seq);
|
||||
assert_eq!(
|
||||
nodes.iter().filter(|n| n.endpoint.is_some()).count(),
|
||||
2,
|
||||
"exactly one endpoint pair is displayed"
|
||||
);
|
||||
let input = nodes
|
||||
.iter()
|
||||
.find(|n| n.endpoint == Some(GraphEndpoint::Input))
|
||||
.expect("the input endpoint card");
|
||||
let output = nodes
|
||||
.iter()
|
||||
.find(|n| n.endpoint == Some(GraphEndpoint::Output))
|
||||
.expect("the output endpoint card");
|
||||
assert!(
|
||||
!input.outputs.is_empty(),
|
||||
"the input endpoint keeps its `tex_out`"
|
||||
);
|
||||
assert!(
|
||||
output.outputs.is_empty(),
|
||||
"the output endpoint is a sink and has no output port"
|
||||
);
|
||||
assert!(
|
||||
!input.title.is_empty() && !output.title.is_empty(),
|
||||
"endpoint cards carry their behavior name as the title"
|
||||
);
|
||||
|
||||
// The per-clip chain view contains no endpoint cards.
|
||||
let (chain, _) = build_graph_for_clip(&project, seq, clip.identity());
|
||||
assert!(
|
||||
chain.iter().all(|n| n.endpoint.is_none()),
|
||||
"a clip's context chain contains no endpoints"
|
||||
);
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4335,6 +4335,22 @@ impl AppEngine for RealEngine {
|
||||
self.selected_graph_node
|
||||
}
|
||||
|
||||
/// The open project graph's virtual endpoint pair, if any: the node
|
||||
/// editor must not let the user delete, copy or rename them.
|
||||
fn protected_graph_nodes(&self) -> Vec<gpui::node_graph::NodeId> {
|
||||
let Some(project) = self.project.clone() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let guard = graphops::lock(&project);
|
||||
match guard.graph.endpoints() {
|
||||
Some((input, output)) => vec![
|
||||
gpui::node_graph::NodeId(input.identity()),
|
||||
gpui::node_graph::NodeId(output.identity()),
|
||||
],
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn addable_effects(&self) -> Vec<crate::oakui::engine::EffectEntry> {
|
||||
super::effectchain::addable_effects()
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ use std::collections::BTreeSet;
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::node_graph::{
|
||||
NodeData, NodeElement, NodeGraphEvent, NodeGraphView, NodeId, NodeVisualState, MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
EdgeData, EdgeId, NodeData, NodeElement, NodeGraphEvent, NodeGraphView, NodeId,
|
||||
NodeVisualState, MAX_ZOOM, MIN_ZOOM,
|
||||
};
|
||||
use gpui::{
|
||||
div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity,
|
||||
@@ -79,10 +79,15 @@ impl<E: AppEngine> NodeEditorPanel<E> {
|
||||
pub fn new(engine: Entity<E>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let graph = cx.new(|cx| NodeGraphView::new(engine.clone(), window, cx));
|
||||
// The "edits are requests" loop: every graph gesture goes to the
|
||||
// engine, which applies it to its model and notifies.
|
||||
// engine, which applies it to its model and notifies. The one
|
||||
// exception is the fixed endpoint pair, which the panel filters out
|
||||
// of delete requests before the engine can see them.
|
||||
cx.subscribe(&graph, |this, _graph, event: &NodeGraphEvent, cx| {
|
||||
let Some(event) = this.filter_delete_request(event, cx) else {
|
||||
return;
|
||||
};
|
||||
this.engine
|
||||
.update(cx, |engine, cx| engine.apply_node_graph_event(event, cx));
|
||||
.update(cx, |engine, cx| engine.apply_node_graph_event(&event, cx));
|
||||
})
|
||||
.detach();
|
||||
// The panel-side half of the graph events: the context menus.
|
||||
@@ -99,8 +104,9 @@ impl<E: AppEngine> NodeEditorPanel<E> {
|
||||
this.context_menu.show(window_position, menu, cx);
|
||||
}
|
||||
}
|
||||
NodeGraphEvent::NodeContextMenuRequested { position, .. } => {
|
||||
this.context_menu.show(*position, node_menu(), cx);
|
||||
NodeGraphEvent::NodeContextMenuRequested { node, position } => {
|
||||
let protected = this.engine.read(cx).protected_graph_nodes().contains(node);
|
||||
this.context_menu.show(*position, node_menu(protected), cx);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
@@ -135,6 +141,53 @@ impl<E: AppEngine> NodeEditorPanel<E> {
|
||||
panel
|
||||
}
|
||||
|
||||
/// The UI-layer guard for the fixed endpoint pair: a graph gesture that
|
||||
/// names a protected node (the `GraphInput` / `GraphOutput` cards the
|
||||
/// engine reports) never reaches the engine's model. Only delete requests
|
||||
/// can name nodes today, so every other event passes through untouched; a
|
||||
/// delete request that names an endpoint is narrowed to the rest of the
|
||||
/// selection — the endpoints stay, and so do the wires hanging off them.
|
||||
/// A request left with nothing to delete is dropped whole (`None`).
|
||||
fn filter_delete_request(&self, event: &NodeGraphEvent, cx: &App) -> Option<NodeGraphEvent> {
|
||||
let NodeGraphEvent::DeleteRequested { nodes, edges } = event else {
|
||||
return Some(event.clone());
|
||||
};
|
||||
let protected = self.engine.read(cx).protected_graph_nodes();
|
||||
if protected.is_empty() || !nodes.iter().any(|node| protected.contains(node)) {
|
||||
return Some(event.clone());
|
||||
}
|
||||
let kept_nodes: Vec<NodeId> = nodes
|
||||
.iter()
|
||||
.filter(|node| !protected.contains(node))
|
||||
.copied()
|
||||
.collect();
|
||||
// The widget packs every edge incident to a deleted node into the
|
||||
// request; an edge that touches a surviving endpoint would be
|
||||
// stranded, so it stays with its node.
|
||||
let stranded: Vec<EdgeId> = self
|
||||
.engine
|
||||
.read(cx)
|
||||
.edges()
|
||||
.iter()
|
||||
.filter(|edge| {
|
||||
protected.contains(&edge.from_node()) || protected.contains(&edge.to_node())
|
||||
})
|
||||
.map(|edge| edge.id())
|
||||
.collect();
|
||||
let kept_edges: Vec<EdgeId> = edges
|
||||
.iter()
|
||||
.filter(|edge| !stranded.contains(edge))
|
||||
.copied()
|
||||
.collect();
|
||||
if kept_nodes.is_empty() && kept_edges.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(NodeGraphEvent::DeleteRequested {
|
||||
nodes: kept_nodes,
|
||||
edges: kept_edges,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -440,11 +493,17 @@ const LOCAL_ADD_NODE_BASE: usize = 2420;
|
||||
|
||||
/// The node context menu: the shared edit section, grouping, color labels,
|
||||
/// viewer/parameter-editor reveals and properties (the C++ node branch).
|
||||
pub(crate) fn node_menu() -> Menu {
|
||||
/// With `protected` (the fixed endpoint pair) the whole edit section is left
|
||||
/// out: the endpoints cannot be cut, copied, pasted over, duplicated,
|
||||
/// renamed or deleted, so none of those entries are offered.
|
||||
pub(crate) fn node_menu(protected: bool) -> Menu {
|
||||
use crate::i18n::tr;
|
||||
let mut items = menu::edit_section(false);
|
||||
if let Some(last) = items.last_mut() {
|
||||
last.separator_after = true;
|
||||
let mut items = Vec::new();
|
||||
if !protected {
|
||||
items = menu::edit_section(false);
|
||||
if let Some(last) = items.last_mut() {
|
||||
last.separator_after = true;
|
||||
}
|
||||
}
|
||||
items.push(MenuItem::new(LOCAL_GROUP, tr("node.context.group")));
|
||||
items.push(MenuItem::new(LOCAL_UNGROUP, tr("node.context.ungroup")));
|
||||
@@ -516,8 +575,11 @@ pub(crate) fn background_menu(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::actions::ActionId;
|
||||
use crate::oakui::nodegraph::GraphEndpoint;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::effect_stack::{EffectId, EffectStackEvent};
|
||||
use gpui::node_graph::{NodeGraphDataSource, PortData, PortId};
|
||||
use gpui::{size, TestAppContext, VisualTestContext};
|
||||
|
||||
/// Builds the panel in a window and returns a `VisualTestContext` for
|
||||
@@ -578,7 +640,7 @@ mod tests {
|
||||
// en-US under the shared language lock.
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
let menu = node_menu();
|
||||
let menu = node_menu(false);
|
||||
let ids: Vec<usize> = menu.items.iter().map(|item| item.id).collect();
|
||||
assert!(ids.contains(&LOCAL_GROUP));
|
||||
assert!(ids.contains(&LOCAL_UNGROUP));
|
||||
@@ -710,4 +772,168 @@ mod tests {
|
||||
"the card click highlights the matching node (got {selection:?})"
|
||||
);
|
||||
}
|
||||
|
||||
/// The demo graph carries the fixed endpoint pair as real graph data: two
|
||||
/// endpoint-marked cards, the input one with the output port the walk
|
||||
/// pulls from, the output one a pure sink. Both are reported as protected,
|
||||
/// and the connection rules follow the ports — the input's `feed_in` takes
|
||||
/// a wire, its output starts one, and the sink can never source a wire
|
||||
/// (it has no output port to drag from).
|
||||
#[gpui::test]
|
||||
async fn demo_endpoints_are_protected_and_marked(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
|
||||
let protected = cx.read(|app| panel.read(app).engine.read(app).protected_graph_nodes());
|
||||
assert_eq!(protected.len(), 2, "the endpoint pair is protected");
|
||||
|
||||
let nodes = cx.read(|app| panel.read(app).engine.read(app).nodes());
|
||||
assert_eq!(
|
||||
nodes
|
||||
.iter()
|
||||
.filter(|node| node.endpoint().is_some())
|
||||
.count(),
|
||||
2,
|
||||
"exactly the fixed pair is marked as endpoints"
|
||||
);
|
||||
let input = nodes
|
||||
.iter()
|
||||
.find(|node| node.endpoint() == Some(GraphEndpoint::Input))
|
||||
.expect("the demo graph has a graph-input endpoint");
|
||||
let output = nodes
|
||||
.iter()
|
||||
.find(|node| node.endpoint() == Some(GraphEndpoint::Output))
|
||||
.expect("the demo graph has a graph-output endpoint");
|
||||
assert!(protected.contains(&input.id()));
|
||||
assert!(protected.contains(&output.id()));
|
||||
assert_eq!((input.inputs().len(), input.outputs().len()), (1, 1));
|
||||
assert_eq!((output.inputs().len(), output.outputs().len()), (1, 0));
|
||||
|
||||
let can_connect = |from: PortId, to: PortId| {
|
||||
cx.read(|app| panel.read(app).engine.read(app).can_connect(from, to))
|
||||
};
|
||||
let feed_in = input.inputs()[0].id();
|
||||
let tex_out = input.outputs()[0].id();
|
||||
let tex_in = output.inputs()[0].id();
|
||||
assert!(can_connect(PortId(42), feed_in), "feed_in accepts a wire");
|
||||
assert!(
|
||||
can_connect(tex_out, PortId(22)),
|
||||
"the input endpoint sources a wire"
|
||||
);
|
||||
assert!(
|
||||
!can_connect(tex_in, PortId(42)),
|
||||
"the sink has no output port, so no wire can leave it"
|
||||
);
|
||||
}
|
||||
|
||||
/// The UI-layer guard over the endpoint pair: a delete request that names
|
||||
/// an endpoint is narrowed by the panel before the engine sees it — the
|
||||
/// endpoints (and the wires hanging off them) survive, the rest of the
|
||||
/// request still lands, and a request left with nothing to delete is
|
||||
/// dropped whole. The last step goes behind the panel to show the model
|
||||
/// itself has no protection: the panel is the only thing standing between
|
||||
/// a Delete/Backspace gesture and the pair.
|
||||
#[gpui::test]
|
||||
async fn delete_requests_cannot_remove_endpoints(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
|
||||
// The widget's Delete/Backspace path: the request is emitted by the
|
||||
// graph entity (a node selection plus the incident edges it packs),
|
||||
// exactly as the widget emits it.
|
||||
let emit_delete = |cx: &mut VisualTestContext, nodes: Vec<NodeId>, edges: Vec<EdgeId>| {
|
||||
let graph = cx.read(|app| panel.read(app).graph.clone());
|
||||
graph.update(cx, |_graph, cx| {
|
||||
cx.emit(NodeGraphEvent::DeleteRequested { nodes, edges })
|
||||
});
|
||||
cx.run_until_parked();
|
||||
};
|
||||
let counts = |cx: &VisualTestContext| {
|
||||
cx.read(|app| {
|
||||
let engine = panel.read(app).engine.read(app);
|
||||
(engine.nodes().len(), engine.edges().len())
|
||||
})
|
||||
};
|
||||
assert_eq!(counts(cx), (8, 6));
|
||||
|
||||
// A mixed request: the ordinary node goes, the endpoint stays — and so
|
||||
// does the wire it carries, even though the request named it.
|
||||
emit_delete(cx, vec![NodeId(2), NodeId(6)], vec![EdgeId(6)]);
|
||||
assert_eq!(counts(cx), (7, 4));
|
||||
let input_survived = cx.read(|app| {
|
||||
panel
|
||||
.read(app)
|
||||
.engine
|
||||
.read(app)
|
||||
.nodes()
|
||||
.iter()
|
||||
.any(|node| node.endpoint() == Some(GraphEndpoint::Input))
|
||||
});
|
||||
assert!(input_survived, "the endpoint survives the mixed request");
|
||||
|
||||
// A request naming only the pair is dropped whole: nothing changes.
|
||||
emit_delete(cx, vec![NodeId(6), NodeId(7)], Vec::new());
|
||||
assert_eq!(counts(cx), (7, 4));
|
||||
|
||||
// A plain edge deletion names no node and passes through untouched.
|
||||
emit_delete(cx, Vec::new(), vec![EdgeId(4)]);
|
||||
assert_eq!(counts(cx), (7, 3));
|
||||
|
||||
// Behind the panel, the mock model deletes an endpoint happily, so the
|
||||
// survival above is the panel's doing.
|
||||
cx.update(|_window, app| {
|
||||
let engine = panel.read(app).engine.clone();
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_node_graph_event(
|
||||
&NodeGraphEvent::DeleteRequested {
|
||||
nodes: vec![NodeId(6)],
|
||||
edges: Vec::new(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(counts(cx), (6, 2));
|
||||
}
|
||||
|
||||
/// The endpoint cards get the protected node menu: the whole edit section
|
||||
/// (undo/redo, cut/copy/paste, duplicate, rename, delete) is left out,
|
||||
/// while the grouping, reveal and properties entries stay. The ordinary
|
||||
/// node menu keeps its edit entries.
|
||||
#[test]
|
||||
fn protected_node_menu_has_no_edit_section() {
|
||||
let ids = |menu: &Menu| -> Vec<usize> { menu.items.iter().map(|item| item.id).collect() };
|
||||
|
||||
let protected = node_menu(true);
|
||||
let protected_ids = ids(&protected);
|
||||
let edit_items = [
|
||||
ActionId::Undo,
|
||||
ActionId::Redo,
|
||||
ActionId::Cut,
|
||||
ActionId::Copy,
|
||||
ActionId::Paste,
|
||||
ActionId::PasteInsert,
|
||||
ActionId::Duplicate,
|
||||
ActionId::Rename,
|
||||
ActionId::Delete,
|
||||
];
|
||||
for action in edit_items {
|
||||
assert!(
|
||||
!protected_ids.contains(&action.menu_id()),
|
||||
"the endpoint menu hides {action:?}"
|
||||
);
|
||||
}
|
||||
assert!(protected_ids.contains(&LOCAL_GROUP));
|
||||
assert!(protected_ids.contains(&LOCAL_UNGROUP));
|
||||
assert!(protected_ids.contains(&LOCAL_OPEN_IN_VIEWER));
|
||||
assert!(protected_ids.contains(&LOCAL_SHOW_IN_PARAM_EDITOR));
|
||||
assert!(protected_ids.contains(&LOCAL_NODE_PROPERTIES));
|
||||
|
||||
let ordinary_ids = ids(&node_menu(false));
|
||||
for action in edit_items {
|
||||
assert!(
|
||||
ordinary_ids.contains(&action.menu_id()),
|
||||
"an ordinary node menu offers {action:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user