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:
2026-09-11 15:48:17 +08:00
parent 29204d1f63
commit 4413676780
5 changed files with 447 additions and 32 deletions
+10 -1
View File
@@ -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
+76 -8
View File
@@ -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()
+108 -12
View File
@@ -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();
}
}
+16
View File
@@ -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()
}
+237 -11
View File
@@ -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:?}"
);
}
}
}