From 441367678011f345e5228e5214b4f732fccaea03 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 11 Sep 2026 15:48:17 +0800 Subject: [PATCH] app: show the graph endpoints in the node editor, protected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- crates/oak-app/src/oakui/engine.rs | 11 +- crates/oak-app/src/oakui/mock.rs | 84 +++++++- crates/oak-app/src/oakui/nodegraph.rs | 120 +++++++++-- crates/oak-app/src/oakui/real.rs | 16 ++ crates/oak-app/src/panels/node_editor.rs | 248 ++++++++++++++++++++++- 5 files changed, 447 insertions(+), 32 deletions(-) diff --git a/crates/oak-app/src/oakui/engine.rs b/crates/oak-app/src/oakui/engine.rs index 71fd20e0e..25df9a107 100644 --- a/crates/oak-app/src/oakui/engine.rs +++ b/crates/oak-app/src/oakui/engine.rs @@ -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 { + 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 diff --git a/crates/oak-app/src/oakui/mock.rs b/crates/oak-app/src/oakui/mock.rs index 69d5c4937..4ef25fcde 100644 --- a/crates/oak-app/src/oakui/mock.rs +++ b/crates/oak-app/src/oakui/mock.rs @@ -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, 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, +} + +impl MockNode { + /// The virtual endpoint role of this card, if it is an endpoint. + pub fn endpoint(&self) -> Option { + self.endpoint + } } impl NodeData for MockNode { @@ -627,7 +638,8 @@ impl MockEngine { position: (f32, f32), color: f32, inputs: Vec, - outputs: Vec| MockNode { + outputs: Vec, + endpoint: Option| 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 { + self.nodes + .iter() + .filter(|node| node.endpoint.is_some()) + .map(|node| node.id) + .collect() + } + fn addable_effects(&self) -> Vec { // The demo list is the real factory's effect table (built-ins plus // any registered OpenFX plugins), so the effect library shows the @@ -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() diff --git a/crates/oak-app/src/oakui/nodegraph.rs b/crates/oak-app/src/oakui/nodegraph.rs index f58910fef..7a4cabd96 100644 --- a/crates/oak-app/src/oakui/nodegraph.rs +++ b/crates/oak-app/src/oakui/nodegraph.rs @@ -140,7 +140,8 @@ pub struct RealNode { pub position: Point, /// Input ports (top to bottom). pub inputs: Vec, - /// The single implicit output port. + /// The implicit output port (`out`); empty on the output endpoint, + /// which is a pure sink. pub outputs: Vec, /// Category accent. pub header_color: Option, @@ -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, } 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 { + 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(); + } } diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index 009239f84..32fd0c97e 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -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 { + 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 { super::effectchain::addable_effects() } diff --git a/crates/oak-app/src/panels/node_editor.rs b/crates/oak-app/src/panels/node_editor.rs index 44e270517..f6263320a 100644 --- a/crates/oak-app/src/panels/node_editor.rs +++ b/crates/oak-app/src/panels/node_editor.rs @@ -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 NodeEditorPanel { pub fn new(engine: Entity, window: &mut Window, cx: &mut Context) -> 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 NodeEditorPanel { 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 NodeEditorPanel { 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 { + 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 = 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 = 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 = 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 = 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, edges: Vec| { + 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 { 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:?}" + ); + } + } }