From 0a43c4c956dbef41af077979bda6a36451a9196a Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 7 Jul 2026 03:04:49 -0700 Subject: [PATCH 01/11] Move drag offsets out of the stack dependents cache into dedicated drag session state --- .../node_graph/node_graph_message_handler.rs | 4 +- .../utility_types/network_interface/caches.rs | 31 +++++++----- .../utility_types/network_interface/layout.rs | 50 +++++-------------- .../utility_types/network_interface/types.rs | 5 +- 4 files changed, 37 insertions(+), 53 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs index 430787802e..fd7b3e5e0d 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs @@ -1352,7 +1352,7 @@ impl<'a> MessageHandler> for NodeG self.shift_without_push = false; // Reset all offsets to end the rubber banding while dragging - network_interface.unload_stack_dependents_y_offset(selection_network_path); + network_interface.clear_drag_offsets(selection_network_path); let Some(selected_nodes) = network_interface.selected_nodes_in_nested_network(selection_network_path) else { log::error!("Could not get selected nodes in PointerUp"); return; @@ -1812,7 +1812,7 @@ impl<'a> MessageHandler> for NodeG network_interface.shift_selected_nodes(direction, self.shift_without_push, selection_network_path); if !rubber_band { - network_interface.unload_stack_dependents_y_offset(selection_network_path); + network_interface.clear_drag_offsets(selection_network_path); } if graph_view_overlay_open { diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs index eb85e21561..a9af5697fd 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs @@ -222,7 +222,7 @@ impl NodeNetworkInterface { for sole_dependent in sole_dependents { if !owned_sole_dependents.contains(&sole_dependent) { - stack_dependents.insert(sole_dependent, LayerOwner::None(0)); + stack_dependents.insert(sole_dependent, LayerOwner::None); } } } @@ -243,20 +243,27 @@ impl NodeNetworkInterface { network_metadata.transient_metadata.stack_dependents.unload(); } - /// Resets all the offsets for nodes with no LayerOwner when the drag ends - pub fn unload_stack_dependents_y_offset(&mut self, network_path: &[NodeId]) { - let Some(network_metadata) = self.network_metadata_mut(network_path) else { - log::error!("Could not get nested network_metadata in unload_stack_dependents_y_offset"); + /// The vertical distance the node has been pushed from its resting position during the current drag. + pub(crate) fn drag_offset(&self, node_id: &NodeId, network_path: &[NodeId]) -> i32 { + self.network_metadata(network_path) + .map_or(0, |network_metadata| network_metadata.transient_metadata.drag_offsets.borrow().get(node_id).copied().unwrap_or(0)) + } + + pub(crate) fn add_drag_offset(&self, node_id: &NodeId, delta: i32, network_path: &[NodeId]) { + let Some(network_metadata) = self.network_metadata(network_path) else { + log::error!("Could not get nested network_metadata in add_drag_offset"); return; }; + *network_metadata.transient_metadata.drag_offsets.borrow_mut().entry(*node_id).or_insert(0) += delta; + } - if let Some(stack_dependents) = network_metadata.transient_metadata.stack_dependents.get_loaded_mut() { - for layer_owner in stack_dependents.values_mut() { - if let LayerOwner::None(offset) = layer_owner { - *offset = 0; - } - } - } + /// Discards all drag offsets when the drag ends. + pub fn clear_drag_offsets(&self, network_path: &[NodeId]) { + let Some(network_metadata) = self.network_metadata(network_path) else { + log::error!("Could not get nested network_metadata in clear_drag_offsets"); + return; + }; + network_metadata.transient_metadata.drag_offsets.borrow_mut().clear(); } pub fn import_export_ports(&mut self, network_path: &[NodeId]) -> Option<&Ports> { diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs index 53eed72eff..4ff44ffcf8 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs @@ -432,16 +432,9 @@ impl NodeNetworkInterface { shifted_nodes.insert(*node_id); self.shift_node(node_id, IVec2::new(0, shift_sign), network_path); - let Some(network_metadata) = self.network_metadata_mut(network_path) else { - log::error!("Could not get nested network_metadata in export_ports"); - continue; - }; - if let Some(stack_dependents) = network_metadata.transient_metadata.stack_dependents.get_loaded_mut() - && let Some(LayerOwner::None(offset)) = stack_dependents.get_mut(node_id) - { - *offset += shift_sign; - self.transaction_modified(); - }; + if self.with_stack_dependents(network_path, |stack_dependents| matches!(stack_dependents.get(node_id), Some(LayerOwner::None))) == Some(true) { + self.add_drag_offset(node_id, shift_sign, network_path); + } // Shift the upstream layer so that it stays in the same place if self.is_layer(node_id, network_path) { @@ -470,10 +463,11 @@ impl NodeNetworkInterface { let mut stack_dependents_with_position = stack_dependents .iter() .filter_map(|(node_id, owner)| { - let LayerOwner::None(offset) = owner else { + let LayerOwner::None = owner else { return None; }; - if *offset == 0 { + let offset = self.drag_offset(node_id, network_path); + if offset == 0 { return None; } if self.selected_nodes_in_nested_network(network_path).is_some_and(|selected_nodes| { @@ -487,7 +481,7 @@ impl NodeNetworkInterface { log::error!("Could not get position for node {node_id} in shift_selected_nodes"); return None; }; - Some((*node_id, *offset, position.y)) + Some((*node_id, offset, position.y)) }) .collect::>(); @@ -533,29 +527,11 @@ impl NodeNetworkInterface { self.shift_node(node_id, IVec2::new(0, shift_sign), network_path); - let Some(network_metadata) = self.network_metadata_mut(network_path) else { - log::error!("Could not get nested network_metadata in export_ports"); - return; - }; - let Some(stack_dependents) = network_metadata.transient_metadata.stack_dependents.get_loaded_mut() else { - log::error!("Stack dependents should be loaded in vertical_shift_with_push"); - return; - }; - - let mut default_layer_owner = LayerOwner::None(0); - let layer_owner = stack_dependents.get_mut(node_id).unwrap_or_else(|| { - log::error!("Could not get layer owner in vertical_shift_with_push for node {node_id}"); - &mut default_layer_owner - }); - - match layer_owner { - LayerOwner::None(offset) => { - *offset += shift_sign; - self.transaction_modified(); - } - LayerOwner::Layer(_) => { - log::error!("Node being shifted with a push should not be owned"); - } + match self.with_stack_dependents(network_path, |stack_dependents| stack_dependents.get(node_id).cloned()) { + Some(Some(LayerOwner::None)) => self.add_drag_offset(node_id, shift_sign, network_path), + Some(Some(LayerOwner::Layer(_))) => log::error!("Node being shifted with a push should not be owned"), + Some(None) => log::error!("Could not get layer owner in vertical_shift_with_push for node {node_id}"), + None => log::error!("Stack dependents should be loaded in vertical_shift_with_push"), } // Shift the upstream layer so that it stays in the same place @@ -652,7 +628,7 @@ impl NodeNetworkInterface { let layer_owner = *layer_owner; self.shift_node_or_parent(&layer_owner, shift_sign, shifted_nodes, network_path) } - LayerOwner::None(_) => self.vertical_shift_with_push(node_id, shift_sign, shifted_nodes, network_path), + LayerOwner::None => self.vertical_shift_with_push(node_id, shift_sign, shifted_nodes, network_path), } } diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/types.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/types.rs index 9ae8ff3bdf..e162d98999 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/types.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/types.rs @@ -437,6 +437,8 @@ pub struct NodeNetworkTransientMetadata { /// Cached wire SVG paths per input connector, where an entry's presence means that wire is loaded. pub(crate) wires: std::cell::RefCell>, + /// Drag-session state: how far each unowned stack dependent has been pushed vertically from its resting position. Cleared when the drag ends. + pub(crate) drag_offsets: std::cell::RefCell>, } #[derive(Debug, Clone)] @@ -459,8 +461,7 @@ pub struct NetworkEdgeDistance { pub enum LayerOwner { // Used to get the layer that should be shifted when there is a collision. Layer(NodeId), - // The vertical offset of a node from the start of its shift. Should be reset when the drag ends. - None(i32), + None, } #[derive(Debug, Default, serde::Serialize, serde::Deserialize)] From 499cb04e532ae53293077f37bcab84bad530a655 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 7 Jul 2026 03:08:12 -0700 Subject: [PATCH 02/11] Replace the move_layer_to_stack selection hijack with a seeded stack dependents load --- .../utility_types/network_interface/caches.rs | 6 +++++- .../utility_types/network_interface/layout.rs | 12 +++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs index a9af5697fd..f507ede166 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs @@ -113,8 +113,12 @@ impl NodeNetworkInterface { log::error!("Could not get selected nodes in load_stack_dependents"); return; }; + self.load_stack_dependents_for_nodes(selected_nodes.selected_nodes().cloned().collect(), network_path); + } - let mut selected_layers = selected_nodes.selected_nodes().filter(|node_id| self.is_layer(node_id, network_path)).cloned().collect::>(); + /// Builds the stack dependents as if `seed_nodes` were the selection, for shifts driven by a node set other than the selection. + pub(crate) fn load_stack_dependents_for_nodes(&self, seed_nodes: Vec, network_path: &[NodeId]) { + let mut selected_layers = seed_nodes.iter().filter(|node_id| self.is_layer(node_id, network_path)).copied().collect::>(); // Deselect all layers that are upstream of other selected layers let mut removed_layers = Vec::new(); diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs index 4ff44ffcf8..df09715f7c 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs @@ -848,12 +848,9 @@ impl NodeNetworkInterface { // If there is an upstream node in the new location for the layer, create space for the moved layer by shifting the upstream node down if let Some(upstream_node_id) = post_node_input.as_node() { - // Select the layer to move to ensure the shifting works correctly - let Some(selected_nodes) = self.selected_nodes_mut(network_path) else { - log::error!("Could not get selected nodes in move_layer_to_stack"); - return; - }; - let old_selected_nodes = selected_nodes.replace_with(vec![upstream_node_id]); + // Build the stack dependents from the upstream node rather than the selection so the shifting works correctly + self.unload_stack_dependents(network_path); + self.load_stack_dependents_for_nodes(vec![upstream_node_id], network_path); // Create the minimum amount space for the moved layer for _ in 0..STACK_VERTICAL_GAP { @@ -862,6 +859,7 @@ impl NodeNetworkInterface { let Some(stack_position) = self.position(&upstream_node_id, network_path) else { log::error!("Could not get stack position in move_layer_to_stack"); + self.unload_stack_dependents(network_path); return; }; @@ -872,7 +870,7 @@ impl NodeNetworkInterface { self.vertical_shift_with_push(&upstream_node_id, 1, &mut HashSet::new(), network_path); } - let _ = self.selected_nodes_mut(network_path).unwrap().replace_with(old_selected_nodes); + self.unload_stack_dependents(network_path); } // If true, this node should be inserted before the post node (toward root from the layer), and all outward wires from the pre node should be moved to its output. From b68317cadc90e9435a6c9dfc8e6dbc5fc9be2f67 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 10 Jul 2026 04:29:30 -0700 Subject: [PATCH 03/11] Move text measurement into a neutral utility module out of the overlays layer --- .../document/overlays/utility_functions.rs | 22 +------------------ .../portfolio/document/utility_types/mod.rs | 1 + .../utility_types/network_interface.rs | 2 +- .../utility_types/network_interface/caches.rs | 2 +- .../document/utility_types/text_metrics.rs | 21 ++++++++++++++++++ 5 files changed, 25 insertions(+), 23 deletions(-) create mode 100644 editor/src/messages/portfolio/document/utility_types/text_metrics.rs diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index 57315f58d3..c276c0b1c3 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -2,16 +2,14 @@ use super::utility_types::{DrawHandles, OverlayContext}; use crate::consts::HIDE_HANDLE_DISTANCE; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; -use crate::messages::portfolio::fonts::FALLBACK_FONT_RESOURCE; +pub use crate::messages::portfolio::document::utility_types::text_metrics::{GLOBAL_TEXT_CONTEXT, text_width}; use crate::messages::tool::common_functionality::shape_editor::{SelectedLayerState, ShapeState}; use crate::messages::tool::tool_messages::tool_prelude::DocumentMessageHandler; use glam::{DAffine2, DVec2}; use graphene_std::subpath::{Bezier, BezierHandles}; -use graphene_std::text::{TextAlign, TextContext, TypesettingConfig}; use graphene_std::vector::misc::ManipulatorPointId; use graphene_std::vector::{PointId, SegmentId, Vector}; use std::collections::HashMap; -use std::sync::{LazyLock, Mutex}; #[cfg(target_family = "wasm")] use wasm_bindgen::JsCast; @@ -222,24 +220,6 @@ pub fn path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: & } } -pub static GLOBAL_TEXT_CONTEXT: LazyLock> = LazyLock::new(|| Mutex::new(TextContext::default())); - -pub fn text_width(text: &str, font_size: f64) -> f64 { - let typesetting = TypesettingConfig { - font_size, - line_height_ratio: 1.2, - letter_spacing: 0., - letter_tilt: 0., - max_width: None, - max_height: None, - align: TextAlign::AlignLeft, - }; - - let mut text_context = GLOBAL_TEXT_CONTEXT.lock().expect("Failed to lock global text context"); - let bounds = text_context.bounding_box(text, &FALLBACK_FONT_RESOURCE, typesetting, false); - bounds.x -} - pub fn hex_to_rgba_u8(hex: &str) -> [u8; 4] { let hex = hex.trim().trim_start_matches('#'); if hex.len() != 6 && hex.len() != 8 { diff --git a/editor/src/messages/portfolio/document/utility_types/mod.rs b/editor/src/messages/portfolio/document/utility_types/mod.rs index 1fcf5c60ca..a38ca2c545 100644 --- a/editor/src/messages/portfolio/document/utility_types/mod.rs +++ b/editor/src/messages/portfolio/document/utility_types/mod.rs @@ -3,5 +3,6 @@ pub mod error; pub mod misc; pub mod network_interface; pub mod nodes; +pub mod text_metrics; pub mod transformation; pub mod wires; diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index e7d2a7bba3..ef7dc2bb34 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -30,8 +30,8 @@ use crate::consts::{ use crate::messages::portfolio::document::graph_operation::utility_types::ModifyInputsContext; use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_document_node_type}; use crate::messages::portfolio::document::node_graph::utility_types::{Direction, FrontendClickTargets, FrontendGraphDataType, FrontendGraphInput, FrontendGraphOutput}; -use crate::messages::portfolio::document::overlays::utility_functions::text_width; use crate::messages::portfolio::document::utility_types::network_interface::resolved_types::ResolvedDocumentNodeTypes; +use crate::messages::portfolio::document::utility_types::text_metrics::text_width; use crate::messages::portfolio::document::utility_types::wires::{GraphWireStyle, WirePath, WirePathUpdate, build_thick_wire_center_line, build_vector_wire}; use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::tool_messages::tool_prelude::NumberInputMode; diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs index f507ede166..4d6346b883 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs @@ -1092,7 +1092,7 @@ impl NodeNetworkInterface { let name_left = node_top_left.x + NAME_LEFT_OFFSET; let icons_reserve = VISIBILITY_INSET_FROM_LAYER_RIGHT + icons_width + GRIP_WIDTH; let name_right_max = node_top_left.x + width as f64 - icons_reserve; - let text_w = crate::messages::portfolio::document::overlays::utility_functions::text_width(&display_name, FONT_SIZE); + let text_w = text_width(&display_name, FONT_SIZE); let name_right = (name_left + text_w).min(name_right_max); if name_right > name_left { // The 1-grid-tall name strip is centered vertically in the 2-grid-tall layer. diff --git a/editor/src/messages/portfolio/document/utility_types/text_metrics.rs b/editor/src/messages/portfolio/document/utility_types/text_metrics.rs new file mode 100644 index 0000000000..cc3e7debd3 --- /dev/null +++ b/editor/src/messages/portfolio/document/utility_types/text_metrics.rs @@ -0,0 +1,21 @@ +use crate::messages::portfolio::fonts::FALLBACK_FONT_RESOURCE; +use graphene_std::text::{TextAlign, TextContext, TypesettingConfig}; +use std::sync::{LazyLock, Mutex}; + +pub static GLOBAL_TEXT_CONTEXT: LazyLock> = LazyLock::new(|| Mutex::new(TextContext::default())); + +pub fn text_width(text: &str, font_size: f64) -> f64 { + let typesetting = TypesettingConfig { + font_size, + line_height_ratio: 1.2, + letter_spacing: 0., + letter_tilt: 0., + max_width: None, + max_height: None, + align: TextAlign::AlignLeft, + }; + + let mut text_context = GLOBAL_TEXT_CONTEXT.lock().expect("Failed to lock global text context"); + let bounds = text_context.bounding_box(text, &FALLBACK_FONT_RESOURCE, typesetting, false); + bounds.x +} From e020ed37927d021730bd1128fc5ea3630a26cae9 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 10 Jul 2026 04:32:12 -0700 Subject: [PATCH 04/11] Deduplicate the document bounds queries and name the artboard clip input index --- .../network_interface/queries.rs | 52 +++++++------------ 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs index 112b036312..cf8f870faf 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs @@ -1,5 +1,8 @@ use super::*; +/// Index of the Artboard definition's Clip input, which must match the input order authored in document_node_definitions.rs. +pub(crate) const ARTBOARD_CLIP_INPUT_INDEX: usize = 5; + impl NodeNetworkInterface { /// Runs a query against a resolved [`NetworkView`], logging any error at this message-boundary wrapper and mapping it to None. pub(crate) fn query<'a, 'p, T>(&'a self, network_path: &'p [NodeId], caller: &str, query: impl FnOnce(NetworkView<'a, 'p>) -> Result) -> Option { @@ -885,27 +888,30 @@ impl NodeNetworkInterface { /// Calculates the document bounds in document space pub fn document_bounds_document_space(&self, include_artboards: bool) -> Option<[DVec2; 2]> { + self.combined_document_bounds(include_artboards, |metadata, layer| metadata.bounding_box_document(layer)) + } + + fn combined_document_bounds(&self, include_artboards: bool, layer_bounds: impl Fn(&DocumentMetadata, LayerNodeIdentifier) -> Option<[DVec2; 2]>) -> Option<[DVec2; 2]> { self.document_metadata .all_layers() .filter(|layer| include_artboards || !self.is_artboard(&layer.to_node(), &[])) .filter_map(|layer| { + // A layer clipped by its artboard contributes only the intersection of the two bounds if !self.is_artboard(&layer.to_node(), &[]) && let Some(artboard_node_identifier) = layer .ancestors(self.document_metadata()) .find(|ancestor| *ancestor != LayerNodeIdentifier::ROOT_PARENT && self.is_artboard(&ancestor.to_node(), &[])) + && let Some(artboard) = self.document_node(&artboard_node_identifier.to_node(), &[]) + && let Some(clip_input) = artboard.inputs.get(ARTBOARD_CLIP_INPUT_INDEX) + && let NodeInput::Value { tagged_value, .. } = clip_input + && tagged_value.clone().deref() == &TaggedValue::Bool(true) { - let artboard = self.document_node(&artboard_node_identifier.to_node(), &[]); - let clip_input = artboard.unwrap().inputs.get(5).unwrap(); - if let NodeInput::Value { tagged_value, .. } = clip_input - && tagged_value.clone().deref() == &TaggedValue::Bool(true) - { - return Some(Quad::clip( - self.document_metadata.bounding_box_document(layer).unwrap_or_default(), - self.document_metadata.bounding_box_document(artboard_node_identifier).unwrap_or_default(), - )); - } + return Some(Quad::clip( + layer_bounds(&self.document_metadata, layer).unwrap_or_default(), + self.document_metadata.bounding_box_document(artboard_node_identifier).unwrap_or_default(), + )); } - self.document_metadata.bounding_box_document(layer) + layer_bounds(&self.document_metadata, layer) }) // Skip any layer bounds containing NaN to avoid poisoning the combined result .filter(|[min, max]| min.is_finite() && max.is_finite()) @@ -922,29 +928,7 @@ impl NodeNetworkInterface { /// Calculates the document bounds in document space, expanding vector layer bounds to include the rendered /// stroke width. Used for export so the output canvas captures strokes that overflow the path geometry. pub fn document_bounds_document_space_with_stroke(&self, include_artboards: bool) -> Option<[DVec2; 2]> { - self.document_metadata - .all_layers() - .filter(|layer| include_artboards || !self.is_artboard(&layer.to_node(), &[])) - .filter_map(|layer| { - if !self.is_artboard(&layer.to_node(), &[]) - && let Some(artboard_node_identifier) = layer - .ancestors(self.document_metadata()) - .find(|ancestor| *ancestor != LayerNodeIdentifier::ROOT_PARENT && self.is_artboard(&ancestor.to_node(), &[])) - && let Some(artboard) = self.document_node(&artboard_node_identifier.to_node(), &[]) - && let Some(clip_input) = artboard.inputs.get(5) - && let NodeInput::Value { tagged_value, .. } = clip_input - && tagged_value.clone().deref() == &TaggedValue::Bool(true) - { - return Some(Quad::clip( - self.document_metadata.bounding_box_document_with_stroke(layer).unwrap_or_default(), - self.document_metadata.bounding_box_document(artboard_node_identifier).unwrap_or_default(), - )); - } - self.document_metadata.bounding_box_document_with_stroke(layer) - }) - // Skip any layer bounds containing NaN to avoid poisoning the combined result - .filter(|[min, max]| min.is_finite() && max.is_finite()) - .reduce(Quad::combine_bounds) + self.combined_document_bounds(include_artboards, |metadata, layer| metadata.bounding_box_document_with_stroke(layer)) } /// Calculates the selected layer bounds in document space From 0849df8a3f2fc971e6557b9de6d9ef48a812fc01 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 10 Jul 2026 04:35:07 -0700 Subject: [PATCH 05/11] Replace the remove_references_from_network selection hijack with an explicit shift set --- .../utility_types/network_interface/layout.rs | 16 ++++++++++------ .../utility_types/network_interface/mutations.rs | 16 ++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs index df09715f7c..1a7152f9ec 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs @@ -297,13 +297,18 @@ impl NodeNetworkInterface { } pub fn shift_selected_nodes(&mut self, direction: Direction, shift_without_push: bool, network_path: &[NodeId]) { - let Some(mut node_ids) = self + let Some(node_ids) = self .selected_nodes_in_nested_network(network_path) .map(|selected_nodes| selected_nodes.selected_nodes().cloned().collect::>()) else { log::error!("Could not get selected nodes in shift_selected_nodes"); return; }; + self.shift_nodes(node_ids, direction, shift_without_push, network_path); + } + + pub(crate) fn shift_nodes(&mut self, mut node_ids: HashSet, direction: Direction, shift_without_push: bool, network_path: &[NodeId]) { + let seed_nodes = node_ids.clone(); if !shift_without_push { // The owned nodes of each layer are populated by the stack dependents load, which otherwise may not run until after this filter self.try_load_stack_dependents(network_path); @@ -470,11 +475,10 @@ impl NodeNetworkInterface { if offset == 0 { return None; } - if self.selected_nodes_in_nested_network(network_path).is_some_and(|selected_nodes| { - selected_nodes - .selected_nodes() - .any(|selected_node| selected_node == node_id || self.with_owned_nodes(node_id, network_path, |owned_nodes| owned_nodes.contains(selected_node)) == Some(true)) - }) { + if seed_nodes + .iter() + .any(|seed_node| seed_node == node_id || self.with_owned_nodes(node_id, network_path, |owned_nodes| owned_nodes.contains(seed_node)) == Some(true)) + { return None; }; let Some(position) = self.position(node_id, network_path) else { diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs index 46458359f2..3c996e402c 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs @@ -1252,24 +1252,20 @@ impl NodeNetworkInterface { }; let max_shift_distance = reconnected_node_position.y - disconnected_node_position.y; - let upstream_nodes = self.upstream_flow_back_from_nodes(vec![*reconnect_node], network_path, FlowType::PrimaryFlow).collect::>(); + let upstream_nodes = self.upstream_flow_back_from_nodes(vec![*reconnect_node], network_path, FlowType::PrimaryFlow).collect::>(); - // Select the reconnect node to move to ensure the shifting works correctly - let Some(selected_nodes) = self.selected_nodes_mut(network_path) else { - log::error!("Could not get selected nodes in remove_references_from_network"); - return false; - }; - - let old_selected_nodes = selected_nodes.replace_with(upstream_nodes); + // Build the stack dependents from the reconnected flow rather than the selection so the shifting works correctly + self.unload_stack_dependents(network_path); + self.load_stack_dependents_for_nodes(upstream_nodes.iter().copied().collect(), network_path); // Shift up until there is either a collision or the disconnected node position is reached let mut current_shift_distance = 0; while self.check_collision_with_stack_dependents(reconnect_node, -1, network_path).is_empty() && max_shift_distance > current_shift_distance { - self.shift_selected_nodes(Direction::Up, false, network_path); + self.shift_nodes(upstream_nodes.clone(), Direction::Up, false, network_path); current_shift_distance += 1; } - let _ = self.selected_nodes_mut(network_path).unwrap().replace_with(old_selected_nodes); + self.unload_stack_dependents(network_path); } true From fe599e5698ee36f2ccb53e684c9e9330d6e11eb9 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 10 Jul 2026 04:38:15 -0700 Subject: [PATCH 06/11] Extract shared import and export wire disconnection used by the expose handlers --- .../node_graph/node_graph_message_handler.rs | 28 ++------------ .../network_interface/mutations.rs | 38 +++++++++++++++---- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs index fd7b3e5e0d..970dc4faa5 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs @@ -530,19 +530,8 @@ impl<'a> MessageHandler> for NodeG network_interface.set_input(&encapsulating_connector, input, network_path); - let Some(outward_wires) = network_interface.outward_wires(breadcrumb_network_path) else { - log::error!("Could not get outward wires in remove_import"); - return; - }; - let Some(downstream_connections) = outward_wires.get(&OutputConnector::Import(0)).cloned() else { - log::error!("Could not get outward wires for import in remove_import"); - return; - }; - - // Disconnect all connections in the encapsulating network - for downstream_connection in &downstream_connections { - network_interface.disconnect_input(downstream_connection, breadcrumb_network_path); - } + // Disconnect all connections fed by the hidden import in the nested network + network_interface.disconnect_import_wires(0, breadcrumb_network_path); responses.add(NodeGraphMessage::UpdateImportsExports); responses.add(NodeGraphMessage::SendWires); @@ -565,18 +554,7 @@ impl<'a> MessageHandler> for NodeG // Disconnect all connections in the encapsulating network if let Some((encapsulating_node, encapsulating_path)) = breadcrumb_network_path.split_last() { - let Some(outward_wires) = network_interface.outward_wires(encapsulating_path) else { - log::error!("Could not get outward wires in remove_import"); - return; - }; - let Some(downstream_connections) = outward_wires.get(&OutputConnector::node(*encapsulating_node, 0)).cloned() else { - log::error!("Could not get outward wires for import in remove_import"); - return; - }; - - for downstream_connection in &downstream_connections { - network_interface.disconnect_input(downstream_connection, encapsulating_path); - } + network_interface.disconnect_output_wires(&OutputConnector::node(*encapsulating_node, 0), encapsulating_path); } responses.add(NodeGraphMessage::UpdateImportsExports); diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs index 3c996e402c..2f75e949ed 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs @@ -226,6 +226,36 @@ impl NodeNetworkInterface { self.unload_modify_import_export(network_path); } + /// Disconnects every wire fed by the given import within the network. + pub(crate) fn disconnect_import_wires(&mut self, import_index: usize, network_path: &[NodeId]) { + let Some(wires_for_import) = self.with_outward_wires(network_path, |outward_wires| outward_wires.get(&OutputConnector::Import(import_index)).cloned()) else { + log::error!("Could not get outward wires in disconnect_import_wires"); + return; + }; + let Some(wires_for_import) = wires_for_import else { + log::error!("Could not get outward wires for import in disconnect_import_wires"); + return; + }; + for downstream_connection in wires_for_import { + self.disconnect_input(&downstream_connection, network_path); + } + } + + /// Disconnects every wire fed by the given output within the network. + pub(crate) fn disconnect_output_wires(&mut self, output_connector: &OutputConnector, network_path: &[NodeId]) { + let Some(downstream_connections) = self.with_outward_wires(network_path, |outward_wires| outward_wires.get(output_connector).cloned()) else { + log::error!("Could not get outward wires in disconnect_output_wires"); + return; + }; + let Some(downstream_connections) = downstream_connections else { + log::error!("Could not get downstream connections in disconnect_output_wires"); + return; + }; + for downstream_connection in downstream_connections { + self.disconnect_input(&downstream_connection, network_path); + } + } + // First disconnects the export, then removes it pub fn remove_export(&mut self, export_index: usize, network_path: &[NodeId]) { let mut encapsulating_network_path = network_path.to_vec(); @@ -300,10 +330,6 @@ impl NodeNetworkInterface { log::error!("Could not get outward wires in remove_import"); return; }; - let Some(outward_wires_for_import) = outward_wires.get(&OutputConnector::Import(import_index)).cloned() else { - log::error!("Could not get outward wires for import in remove_import"); - return; - }; let mut new_import_mapping = Vec::new(); for i in (import_index + 1)..number_of_inputs { let Some(outward_wires_for_import) = outward_wires.get(&OutputConnector::Import(i)).cloned() else { @@ -316,9 +342,7 @@ impl NodeNetworkInterface { } // Disconnect all upstream connections - for outward_wire in outward_wires_for_import { - self.disconnect_input(&outward_wire, network_path); - } + self.disconnect_import_wires(import_index, network_path); // Shift inputs connected to to imports at a higher index down one for (output_connector, input_wire) in new_import_mapping { self.create_wire(&output_connector, &input_wire, network_path); From a221b3832d5758771cd4b501af4afbb7df2b1cd5 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 10 Jul 2026 04:41:10 -0700 Subject: [PATCH 07/11] Extract the shared epilogue of the import and export removal operations --- .../network_interface/mutations.rs | 51 ++++++++----------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs index 2f75e949ed..116f2903bc 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs @@ -256,6 +256,25 @@ impl NodeNetworkInterface { } } + /// Refreshes the metadata invalidated when the encapsulating node's signature changes, demoting it from a layer if it is no longer eligible. + fn finish_signature_edit(&mut self, parent_id: NodeId, encapsulating_network_path: &[NodeId], network_path: &[NodeId]) { + // Update the metadata for the encapsulating node + self.unload_outward_wires(encapsulating_network_path); + self.unload_node_click_targets(&parent_id, encapsulating_network_path); + self.unload_all_nodes_bounding_box(encapsulating_network_path); + if !self.is_eligible_to_be_layer(&parent_id, encapsulating_network_path) && self.is_layer(&parent_id, encapsulating_network_path) { + self.set_to_node_or_layer(&parent_id, encapsulating_network_path, false); + } + if encapsulating_network_path.is_empty() { + self.load_structure(); + } + + // Unload the metadata for the nested network + self.unload_outward_wires(network_path); + self.unload_import_export_ports(network_path); + self.unload_modify_import_export(network_path); + } + // First disconnects the export, then removes it pub fn remove_export(&mut self, export_index: usize, network_path: &[NodeId]) { let mut encapsulating_network_path = network_path.to_vec(); @@ -301,21 +320,7 @@ impl NodeNetworkInterface { network_metadata.persistent_metadata.reference = None; } - // Update the metadata for the encapsulating node - self.unload_outward_wires(&encapsulating_network_path); - self.unload_node_click_targets(&parent_id, &encapsulating_network_path); - self.unload_all_nodes_bounding_box(&encapsulating_network_path); - if !self.is_eligible_to_be_layer(&parent_id, &encapsulating_network_path) && self.is_layer(&parent_id, &encapsulating_network_path) { - self.set_to_node_or_layer(&parent_id, &encapsulating_network_path, false); - } - if encapsulating_network_path.is_empty() { - self.load_structure(); - } - - // Unload the metadata for the nested network - self.unload_outward_wires(network_path); - self.unload_import_export_ports(network_path); - self.unload_modify_import_export(network_path); + self.finish_signature_edit(parent_id, &encapsulating_network_path, network_path); } // First disconnects the import, then removes it @@ -371,21 +376,7 @@ impl NodeNetworkInterface { network_metadata.persistent_metadata.reference = None; } - // Update the metadata for the encapsulating node - self.unload_outward_wires(encapsulating_network_path); - self.unload_node_click_targets(parent_id, encapsulating_network_path); - self.unload_all_nodes_bounding_box(encapsulating_network_path); - if !self.is_eligible_to_be_layer(parent_id, encapsulating_network_path) && self.is_layer(parent_id, encapsulating_network_path) { - self.set_to_node_or_layer(parent_id, encapsulating_network_path, false); - } - if encapsulating_network_path.is_empty() { - self.load_structure(); - } - - // Unload the metadata for the nested network - self.unload_outward_wires(network_path); - self.unload_import_export_ports(network_path); - self.unload_modify_import_export(network_path); + self.finish_signature_edit(*parent_id, encapsulating_network_path, network_path); } /// The end index is before the export is removed, so moving to the end is the length of the current exports From 599f162c665136c29dac90535d35632e19a4077d Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 10 Jul 2026 04:44:00 -0700 Subject: [PATCH 08/11] Move the post node lookup into the network interface to fix the layering inversion --- .../document/graph_operation/utility_types.rs | 68 +------------------ .../utility_types/network_interface.rs | 1 - .../utility_types/network_interface/layout.rs | 4 +- .../network_interface/queries.rs | 55 +++++++++++++++ 4 files changed, 58 insertions(+), 70 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 1ad8cb6ff8..df3f276dd9 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -1,7 +1,7 @@ use super::transform_utils; use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_document_node_type, resolve_network_node_type, resolve_proto_node_type}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; -use crate::messages::portfolio::document::utility_types::network_interface::{self, FlowType, InputConnector, NodeNetworkInterface, OutputConnector}; +use crate::messages::portfolio::document::utility_types::network_interface::{self, FlowType, InputConnector, NodeNetworkInterface}; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils::{get_fill_input_node_id, get_upstream_gradient_value_node_id, gradient_chain_target_input}; use glam::{DAffine2, DVec2, IVec2}; @@ -58,72 +58,6 @@ impl<'a> ModifyInputsContext<'a> { Some(document) } - /// Starts at any folder, or the output, and skips layer nodes based on insert_index. Non layer nodes are always skipped. Returns the post node InputConnector and pre node OutputConnector - /// Non layer nodes directly upstream of a layer are treated as part of that layer. See insert_index == 2 in the diagram - /// -----> Post node - /// | if insert_index == 0, return (Post node, Some(Layer1)) - /// -> Layer1 - /// ↑ if insert_index == 1, return (Layer1, Some(Layer2)) - /// -> Layer2 - /// ↑ - /// -> NonLayerNode - /// ↑ if insert_index == 2, return (NonLayerNode, Some(Layer3)) - /// -> Layer3 - /// if insert_index == 3, return (Layer3, None) - pub fn get_post_node_with_index(network_interface: &NodeNetworkInterface, parent: LayerNodeIdentifier, insert_index: usize) -> InputConnector { - let mut post_node_input_connector = if parent == LayerNodeIdentifier::ROOT_PARENT { - InputConnector::Export(0) - } else { - InputConnector::node(parent.to_node(), 1) - }; - // Skip layers based on skip_layer_nodes, which inserts the new layer at a certain index of the layer stack. - let mut current_index = 0; - - // Set the post node to the layer node at insert_index - loop { - if current_index == insert_index { - break; - } - let next_node_in_stack_id = network_interface - .input_from_connector(&post_node_input_connector, &[]) - .and_then(|input_from_connector| if let NodeInput::Node { node_id, .. } = input_from_connector { Some(node_id) } else { None }); - - if let Some(next_node_in_stack_id) = next_node_in_stack_id { - // Only increment index for layer nodes - if network_interface.is_layer(next_node_in_stack_id, &[]) { - current_index += 1; - } - // Input as a sibling to the Layer node above - post_node_input_connector = InputConnector::node(*next_node_in_stack_id, 0); - } else { - log::error!("Error getting post node: insert_index out of bounds"); - break; - }; - } - - let layer_input_connector = post_node_input_connector; - - // Sink post_node down to the end of the non layer chain that feeds into post_node, such that pre_node is the layer node at insert_index + 1, or None if insert_index is the last layer - loop { - let pre_node_output_connector = network_interface.upstream_output_connector(&post_node_input_connector, &[]); - - match pre_node_output_connector { - Some(OutputConnector::Node { node_id: pre_node_id, .. }) if !network_interface.is_layer(&pre_node_id, &[]) => { - // Update post_node_input_connector for the next iteration - post_node_input_connector = InputConnector::node(pre_node_id, 0); - // Insert directly under layer if moving to the end of a layer stack that ends with a non layer node that does not have an exposed primary input - let primary_is_exposed = network_interface.input_from_connector(&post_node_input_connector, &[]).is_some_and(|input| input.is_exposed()); - if !primary_is_exposed { - return layer_input_connector; - } - } - _ => break, // Break if pre_node_output_connector is None or if pre_node_id is a layer - } - } - - post_node_input_connector - } - /// Creates a new layer and adds it to the document network. network_interface.move_layer_to_stack should be called after pub fn create_layer(&mut self, new_id: NodeId) -> LayerNodeIdentifier { let new_merge_node = resolve_network_node_type("Merge").expect("Merge node").default_node_template(); diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index ef7dc2bb34..a223222911 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -27,7 +27,6 @@ use crate::consts::{ EXPORTS_TO_RIGHT_EDGE_PIXEL_GAP, EXPORTS_TO_TOP_EDGE_PIXEL_GAP, GRID_SIZE, HALF_GRID_SIZE, IMPORTS_TO_LEFT_EDGE_PIXEL_GAP, IMPORTS_TO_TOP_EDGE_PIXEL_GAP, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH, STACK_VERTICAL_GAP, }; -use crate::messages::portfolio::document::graph_operation::utility_types::ModifyInputsContext; use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_document_node_type}; use crate::messages::portfolio::document::node_graph::utility_types::{Direction, FrontendClickTargets, FrontendGraphDataType, FrontendGraphInput, FrontendGraphOutput}; use crate::messages::portfolio::document::utility_types::network_interface::resolved_types::ResolvedDocumentNodeTypes; diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs index 1a7152f9ec..9852713cb2 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs @@ -703,7 +703,7 @@ impl NodeNetworkInterface { insert_index = 0; } - let post_node = ModifyInputsContext::get_post_node_with_index(self, parent, insert_index); + let post_node = self.post_node_with_index(parent, insert_index); let Some(post_node_input) = self.input_from_connector(&post_node, network_path).cloned() else { log::error!("Could not get previous input in move_layer_to_stack_for_import"); return; @@ -791,7 +791,7 @@ impl NodeNetworkInterface { // Disconnect layer to move self.remove_references_from_network(&layer.to_node(), network_path); - let post_node = ModifyInputsContext::get_post_node_with_index(self, parent, insert_index); + let post_node = self.post_node_with_index(parent, insert_index); // Get the previous input to the post node before inserting the layer let Some(post_node_input) = self.input_from_connector(&post_node, network_path).cloned() else { diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs index cf8f870faf..6d490a0468 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs @@ -622,6 +622,61 @@ impl NodeNetworkInterface { true } + /// The input connector into which a layer should be inserted for the given parent and stack index. + pub(crate) fn post_node_with_index(&self, parent: LayerNodeIdentifier, insert_index: usize) -> InputConnector { + let mut post_node_input_connector = if parent == LayerNodeIdentifier::ROOT_PARENT { + InputConnector::Export(0) + } else { + InputConnector::node(parent.to_node(), 1) + }; + // Skip layers based on skip_layer_nodes, which inserts the new layer at a certain index of the layer stack. + let mut current_index = 0; + + // Set the post node to the layer node at insert_index + loop { + if current_index == insert_index { + break; + } + let next_node_in_stack_id = self + .input_from_connector(&post_node_input_connector, &[]) + .and_then(|input_from_connector| if let NodeInput::Node { node_id, .. } = input_from_connector { Some(node_id) } else { None }); + + if let Some(next_node_in_stack_id) = next_node_in_stack_id { + // Only increment index for layer nodes + if self.is_layer(next_node_in_stack_id, &[]) { + current_index += 1; + } + // Input as a sibling to the Layer node above + post_node_input_connector = InputConnector::node(*next_node_in_stack_id, 0); + } else { + log::error!("Error getting post node: insert_index out of bounds"); + break; + }; + } + + let layer_input_connector = post_node_input_connector; + + // Sink post_node down to the end of the non layer chain that feeds into post_node, such that pre_node is the layer node at insert_index + 1, or None if insert_index is the last layer + loop { + let pre_node_output_connector = self.upstream_output_connector(&post_node_input_connector, &[]); + + match pre_node_output_connector { + Some(OutputConnector::Node { node_id: pre_node_id, .. }) if !self.is_layer(&pre_node_id, &[]) => { + // Update post_node_input_connector for the next iteration + post_node_input_connector = InputConnector::node(pre_node_id, 0); + // Insert directly under layer if moving to the end of a layer stack that ends with a non layer node that does not have an exposed primary input + let primary_is_exposed = self.input_from_connector(&post_node_input_connector, &[]).is_some_and(|input| input.is_exposed()); + if !primary_is_exposed { + return layer_input_connector; + } + } + _ => break, // Break if pre_node_output_connector is None or if pre_node_id is a layer + } + } + + post_node_input_connector + } + // All chain nodes and branches from the chain which are sole dependents of the layer pub fn upstream_nodes_below_layer(&self, node_id: &NodeId, network_path: &[NodeId]) -> HashSet { // Every upstream node below layer must be a sole dependent From 2c13c4f023858c9c4839c22a7c61a3fb27ad04f2 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 10 Jul 2026 21:56:08 -0700 Subject: [PATCH 09/11] Clear drag offsets when the stack dependents cache unloads so programmatic shifts cannot replay them --- .../utility_types/network_interface/caches.rs | 3 + .../characterization_tests.rs | 78 ++++++++++++++----- editor/src/test_utils.rs | 10 ++- 3 files changed, 69 insertions(+), 22 deletions(-) diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs index 4d6346b883..d8d606c541 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs @@ -245,6 +245,9 @@ impl NodeNetworkInterface { return; }; network_metadata.transient_metadata.stack_dependents.unload(); + + // Drag offsets are only meaningful relative to the stack dependents snapshot they were accumulated against, so they must not outlive it + network_metadata.transient_metadata.drag_offsets.borrow_mut().clear(); } /// The vertical distance the node has been pushed from its resting position during the current drag. diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/characterization_tests.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/characterization_tests.rs index 9f62742b7f..29c4775004 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/characterization_tests.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/characterization_tests.rs @@ -1,4 +1,5 @@ use super::{InputConnector, OutputConnector, Previewing, RootNode, TransactionStatus}; +use crate::messages::portfolio::document::node_graph::utility_types::Direction; use crate::test_utils::test_prelude::*; use graph_craft::document::NodeInput; use graph_craft::document::value::TaggedValue; @@ -187,26 +188,13 @@ fn merge_definition() -> DefinitionIdentifier { DefinitionIdentifier::Network("Merge".to_string()) } -async fn create_node_at(editor: &mut EditorTestUtils, node_type: DefinitionIdentifier, x: i32, y: i32) -> NodeId { - let node_id = NodeId::new(); - editor - .handle_message(NodeGraphMessage::CreateNodeFromContextMenu { - node_id: Some(node_id), - node_type, - xy: Some((x, y)), - add_transaction: true, - }) - .await; - node_id -} - #[tokio::test] async fn layer_stacking_follows_wiring() { let mut editor = EditorTestUtils::create(); editor.new_document().await; - let upper = create_node_at(&mut editor, merge_definition(), 20, 10).await; - let lower = create_node_at(&mut editor, merge_definition(), 20, 16).await; + let upper = editor.create_node_by_name_at(merge_definition(), 20, 10).await; + let lower = editor.create_node_by_name_at(merge_definition(), 20, 16).await; let network_interface = &mut editor.active_document_mut().network_interface; network_interface.set_to_node_or_layer(&upper, &[], true); @@ -234,8 +222,8 @@ async fn chain_membership_follows_wiring() { let mut editor = EditorTestUtils::create(); editor.new_document().await; - let layer = create_node_at(&mut editor, merge_definition(), 20, 10).await; - let node = create_node_at(&mut editor, rectangle_definition(), 15, 10).await; + let layer = editor.create_node_by_name_at(merge_definition(), 20, 10).await; + let node = editor.create_node_by_name_at(rectangle_definition(), 15, 10).await; let network_interface = &mut editor.active_document_mut().network_interface; network_interface.set_to_node_or_layer(&layer, &[], true); @@ -265,8 +253,8 @@ async fn move_layer_to_stack_builds_the_layer_stack() { let artboard = NodeId::new(); editor.handle_message(new_artboard_message(artboard)).await; - let first = create_node_at(&mut editor, merge_definition(), 0, 30).await; - let second = create_node_at(&mut editor, merge_definition(), 0, 40).await; + let first = editor.create_node_by_name_at(merge_definition(), 0, 30).await; + let second = editor.create_node_by_name_at(merge_definition(), 0, 40).await; let network_interface = &mut editor.active_document_mut().network_interface; network_interface.set_to_node_or_layer(&first, &[], true); @@ -288,12 +276,60 @@ async fn move_layer_to_stack_builds_the_layer_stack() { assert_invariants(&editor, "after moving two layers into an artboard"); } +#[tokio::test] +async fn drag_offsets_do_not_outlive_programmatic_shifts() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let artboard = NodeId::new(); + editor.handle_message(new_artboard_message(artboard)).await; + + let first = editor.create_node_by_name_at(merge_definition(), 0, 30).await; + let second = editor.create_node_by_name_at(merge_definition(), 0, 40).await; + let third = editor.create_node_by_name_at(merge_definition(), 0, 50).await; + + let network_interface = &mut editor.active_document_mut().network_interface; + network_interface.set_to_node_or_layer(&first, &[], true); + network_interface.set_to_node_or_layer(&second, &[], true); + network_interface.set_to_node_or_layer(&third, &[], true); + + let artboard_layer = LayerNodeIdentifier::new(artboard, network_interface); + network_interface.move_layer_to_stack(LayerNodeIdentifier::new(first, network_interface), artboard_layer, 0, &[]); + network_interface.move_layer_to_stack(LayerNodeIdentifier::new(second, network_interface), artboard_layer, 1, &[]); + network_interface.move_layer_to_stack(LayerNodeIdentifier::new(third, network_interface), artboard_layer, 2, &[]); + + // Making room while inserting into the stack pushes neighbors with drag-offset bookkeeping that must not outlive the operation + for node_id in [first, second, third] { + assert_eq!(network_interface.drag_offset(&node_id, &[]), 0, "Inserting into a stack should not leave a drag offset on {node_id}"); + } + + // Deleting the middle layer collapses the stack upward, which must also leave no offsets behind + network_interface.delete_nodes(vec![second], false, &[]); + assert_eq!(network_interface.drag_offset(&third, &[]), 0, "Collapsing the deleted layer's space should not leave a drag offset"); + + // A later nudge must move the survivor by exactly one unit, without replaying any restore toward its pre-collapse position + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![third] }).await; + let network_interface = &editor.active_document().network_interface; + let before = network_interface.position(&third, &[]).expect("Surviving layer should have a position"); + editor + .handle_message(NodeGraphMessage::ShiftSelectedNodes { + direction: Direction::Down, + rubber_band: false, + }) + .await; + let network_interface = &editor.active_document().network_interface; + let after = network_interface.position(&third, &[]).expect("Surviving layer should have a position"); + assert_eq!(after.y - before.y, 1, "A single nudge should move the layer exactly one unit"); + + assert_invariants(&editor, "after stack pushes, a delete collapse, and a nudge"); +} + #[tokio::test] async fn signature_edits_keep_parallel_metadata_in_sync() { let mut editor = EditorTestUtils::create(); editor.new_document().await; - let merge = create_node_at(&mut editor, merge_definition(), 0, 0).await; + let merge = editor.create_node_by_name_at(merge_definition(), 0, 0).await; let path = vec![merge]; let network_interface = &mut editor.active_document_mut().network_interface; @@ -331,7 +367,7 @@ async fn toggle_preview_transitions_with_a_connected_export() { let artboard = NodeId::new(); editor.handle_message(new_artboard_message(artboard)).await; - let node = create_node_at(&mut editor, rectangle_definition(), 0, 20).await; + let node = editor.create_node_by_name_at(rectangle_definition(), 0, 20).await; let network_interface = &mut editor.active_document_mut().network_interface; let export_node = |network_interface: &super::NodeNetworkInterface| network_interface.input_from_connector(&InputConnector::Export(0), &[]).and_then(|input| input.as_node()); diff --git a/editor/src/test_utils.rs b/editor/src/test_utils.rs index 12bcfe45bd..946276c0e0 100644 --- a/editor/src/test_utils.rs +++ b/editor/src/test_utils.rs @@ -324,11 +324,19 @@ impl EditorTestUtils { } pub async fn create_node_by_name(&mut self, node_type: DefinitionIdentifier) -> NodeId { + self.create_node(node_type, None).await + } + + pub async fn create_node_by_name_at(&mut self, node_type: DefinitionIdentifier, x: i32, y: i32) -> NodeId { + self.create_node(node_type, Some((x, y))).await + } + + async fn create_node(&mut self, node_type: DefinitionIdentifier, xy: Option<(i32, i32)>) -> NodeId { let node_id = NodeId::new(); self.handle_message(NodeGraphMessage::CreateNodeFromContextMenu { node_id: Some(node_id), node_type, - xy: None, + xy, add_transaction: true, }) .await; From 682ea98dc5bd76321bb33772d0954060b4d70dab Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 26 Jul 2026 00:44:52 -0700 Subject: [PATCH 10/11] Use the thread-local text context for text measurement instead of a process-global mutex --- .../document/overlays/utility_functions.rs | 2 +- .../document/overlays/utility_types_native.rs | 16 ++++++++-------- .../document/utility_types/text_metrics.rs | 7 +------ 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index c276c0b1c3..17ea05b22a 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -2,7 +2,7 @@ use super::utility_types::{DrawHandles, OverlayContext}; use crate::consts::HIDE_HANDLE_DISTANCE; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; -pub use crate::messages::portfolio::document::utility_types::text_metrics::{GLOBAL_TEXT_CONTEXT, text_width}; +pub use crate::messages::portfolio::document::utility_types::text_metrics::text_width; use crate::messages::tool::common_functionality::shape_editor::{SelectedLayerState, ShapeState}; use crate::messages::tool::tool_messages::tool_prelude::DocumentMessageHandler; use glam::{DAffine2, DVec2}; diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs index 1cf6d0ac4a..1781aa9105 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -3,7 +3,7 @@ use crate::consts::{ COLOR_OVERLAY_YELLOW_DULL, COMPASS_ROSE_ARROW_SIZE, COMPASS_ROSE_HOVER_RING_DIAMETER, COMPASS_ROSE_MAIN_RING_DIAMETER, COMPASS_ROSE_RING_INNER_DIAMETER, DOWEL_PIN_RADIUS, GRADIENT_MIDPOINT_DIAMOND_RADIUS, MANIPULATOR_GROUP_MARKER_SIZE, PIVOT_CROSSHAIR_LENGTH, PIVOT_CROSSHAIR_THICKNESS, PIVOT_DIAMETER, RESIZE_HANDLE_SIZE, SKEW_TRIANGLE_OFFSET, SKEW_TRIANGLE_SIZE, }; -use crate::messages::portfolio::document::overlays::utility_functions::{GLOBAL_TEXT_CONTEXT, hex_to_rgba_u8}; +use crate::messages::portfolio::document::overlays::utility_functions::hex_to_rgba_u8; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::fonts::FALLBACK_FONT_RESOURCE; use crate::messages::prelude::Message; @@ -16,7 +16,7 @@ use graphene_std::ATTR_TRANSFORM; use graphene_std::list::List; use graphene_std::math::quad::Quad; use graphene_std::subpath::{self, Subpath}; -use graphene_std::text::{TextAlign, TypesettingConfig}; +use graphene_std::text::{TextAlign, TextContext, TypesettingConfig}; use graphene_std::vector::click_target::ClickTargetType; use graphene_std::vector::misc::point_to_dvec2; use graphene_std::vector::{PointId, SegmentId, Vector}; @@ -1117,17 +1117,17 @@ impl OverlayContextInternal { align: TextAlign::AlignLeft, }; - // Get text dimensions directly from layout - let mut text_context = GLOBAL_TEXT_CONTEXT.lock().expect("Failed to lock global text context"); - let text_size = text_context.bounding_box(text, &FALLBACK_FONT_RESOURCE, typesetting, false); + // Lay out the text once, taking its dimensions and vector paths from the same thread-local context pass + let (text_size, text_list) = TextContext::with_thread_local(|text_context| { + let text_size = text_context.bounding_box(text, &FALLBACK_FONT_RESOURCE, typesetting, false); + let text_list = text_context.to_path(text, &FALLBACK_FONT_RESOURCE, typesetting, false); + (text_size, text_list) + }); let text_width = text_size.x; let text_height = text_size.y; // Create a rect from the size (assuming text starts at origin) let text_bounds = kurbo::Rect::new(0., 0., text_width, text_height); - // Convert text to vector paths for rendering - let text_list = text_context.to_path(text, &FALLBACK_FONT_RESOURCE, typesetting, false); - // Calculate position based on pivot let mut position = DVec2::ZERO; match pivot[0] { diff --git a/editor/src/messages/portfolio/document/utility_types/text_metrics.rs b/editor/src/messages/portfolio/document/utility_types/text_metrics.rs index cc3e7debd3..8449988edb 100644 --- a/editor/src/messages/portfolio/document/utility_types/text_metrics.rs +++ b/editor/src/messages/portfolio/document/utility_types/text_metrics.rs @@ -1,8 +1,5 @@ use crate::messages::portfolio::fonts::FALLBACK_FONT_RESOURCE; use graphene_std::text::{TextAlign, TextContext, TypesettingConfig}; -use std::sync::{LazyLock, Mutex}; - -pub static GLOBAL_TEXT_CONTEXT: LazyLock> = LazyLock::new(|| Mutex::new(TextContext::default())); pub fn text_width(text: &str, font_size: f64) -> f64 { let typesetting = TypesettingConfig { @@ -15,7 +12,5 @@ pub fn text_width(text: &str, font_size: f64) -> f64 { align: TextAlign::AlignLeft, }; - let mut text_context = GLOBAL_TEXT_CONTEXT.lock().expect("Failed to lock global text context"); - let bounds = text_context.bounding_box(text, &FALLBACK_FONT_RESOURCE, typesetting, false); - bounds.x + TextContext::with_thread_local(|text_context| text_context.bounding_box(text, &FALLBACK_FONT_RESOURCE, typesetting, false).x) } From b4f8ad9727e7c570fc2f3e6b27c1ea1f2f1b2216 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 26 Jul 2026 00:47:32 -0700 Subject: [PATCH 11/11] Resolve the post node lookup within the network being edited instead of the document root --- .../utility_types/network_interface/layout.rs | 4 ++-- .../utility_types/network_interface/queries.rs | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs index 9852713cb2..c7c9c655a2 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/layout.rs @@ -703,7 +703,7 @@ impl NodeNetworkInterface { insert_index = 0; } - let post_node = self.post_node_with_index(parent, insert_index); + let post_node = self.post_node_with_index(parent, insert_index, network_path); let Some(post_node_input) = self.input_from_connector(&post_node, network_path).cloned() else { log::error!("Could not get previous input in move_layer_to_stack_for_import"); return; @@ -791,7 +791,7 @@ impl NodeNetworkInterface { // Disconnect layer to move self.remove_references_from_network(&layer.to_node(), network_path); - let post_node = self.post_node_with_index(parent, insert_index); + let post_node = self.post_node_with_index(parent, insert_index, network_path); // Get the previous input to the post node before inserting the layer let Some(post_node_input) = self.input_from_connector(&post_node, network_path).cloned() else { diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs index 6d490a0468..0ad94edf99 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs @@ -623,7 +623,7 @@ impl NodeNetworkInterface { } /// The input connector into which a layer should be inserted for the given parent and stack index. - pub(crate) fn post_node_with_index(&self, parent: LayerNodeIdentifier, insert_index: usize) -> InputConnector { + pub(crate) fn post_node_with_index(&self, parent: LayerNodeIdentifier, insert_index: usize, network_path: &[NodeId]) -> InputConnector { let mut post_node_input_connector = if parent == LayerNodeIdentifier::ROOT_PARENT { InputConnector::Export(0) } else { @@ -638,12 +638,12 @@ impl NodeNetworkInterface { break; } let next_node_in_stack_id = self - .input_from_connector(&post_node_input_connector, &[]) + .input_from_connector(&post_node_input_connector, network_path) .and_then(|input_from_connector| if let NodeInput::Node { node_id, .. } = input_from_connector { Some(node_id) } else { None }); if let Some(next_node_in_stack_id) = next_node_in_stack_id { // Only increment index for layer nodes - if self.is_layer(next_node_in_stack_id, &[]) { + if self.is_layer(next_node_in_stack_id, network_path) { current_index += 1; } // Input as a sibling to the Layer node above @@ -658,14 +658,14 @@ impl NodeNetworkInterface { // Sink post_node down to the end of the non layer chain that feeds into post_node, such that pre_node is the layer node at insert_index + 1, or None if insert_index is the last layer loop { - let pre_node_output_connector = self.upstream_output_connector(&post_node_input_connector, &[]); + let pre_node_output_connector = self.upstream_output_connector(&post_node_input_connector, network_path); match pre_node_output_connector { - Some(OutputConnector::Node { node_id: pre_node_id, .. }) if !self.is_layer(&pre_node_id, &[]) => { + Some(OutputConnector::Node { node_id: pre_node_id, .. }) if !self.is_layer(&pre_node_id, network_path) => { // Update post_node_input_connector for the next iteration post_node_input_connector = InputConnector::node(pre_node_id, 0); // Insert directly under layer if moving to the end of a layer stack that ends with a non layer node that does not have an exposed primary input - let primary_is_exposed = self.input_from_connector(&post_node_input_connector, &[]).is_some_and(|input| input.is_exposed()); + let primary_is_exposed = self.input_from_connector(&post_node_input_connector, network_path).is_some_and(|input| input.is_exposed()); if !primary_is_exposed { return layer_input_connector; }