From fc7348d08add832a579f1dab0aede67224622189 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 28 Apr 2026 17:30:25 -0700 Subject: [PATCH] Add support for double-clicking to rename layers in the graph view (#4071) * Add support for double-clicking to rename layers in the graph view * Fix text double-click area ending 10px too early from the right * Fix clicking out and intermittent cursor clicking in text field --- editor/src/consts.rs | 1 + .../src/messages/frontend/frontend_message.rs | 5 + .../document/node_graph/node_graph_message.rs | 5 + .../node_graph/node_graph_message_handler.rs | 8 ++ .../utility_types/network_interface.rs | 96 +++++++++++++++---- frontend/src/components/views/Graph.svelte | 84 ++++++++++++++-- 6 files changed, 173 insertions(+), 26 deletions(-) diff --git a/editor/src/consts.rs b/editor/src/consts.rs index 645ab7ce..bee85d36 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -1,5 +1,6 @@ // GRAPH pub const GRID_SIZE: u32 = 24; +pub const HALF_GRID_SIZE: u32 = GRID_SIZE / 2; pub const EXPORTS_TO_TOP_EDGE_PIXEL_GAP: u32 = 72; pub const EXPORTS_TO_RIGHT_EDGE_PIXEL_GAP: u32 = 120; pub const IMPORTS_TO_TOP_EDGE_PIXEL_GAP: u32 = 72; diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index f79eac8f..f8039bae 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -139,6 +139,11 @@ pub enum FrontendMessage { preferences: PreferencesMessageHandler, }, TriggerTextCommit, + /// Asks the frontend to enter inline-rename mode for a layer in the graph view. + TriggerEditLayerNameInGraph { + #[serde(rename = "nodeId")] + node_id: NodeId, + }, TriggerVisitLink { url: String, }, diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs index f5367e88..8e864f66 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs @@ -155,6 +155,11 @@ pub enum NodeGraphMessage { input_connector: InputConnector, input: NodeInput, }, + /// Asks the frontend to enter inline-rename mode for the given layer's display name in the graph view. + /// Triggered by double-clicking the layer's name area. + BeginEditLayerName { + node_id: NodeId, + }, SetDisplayName { node_id: NodeId, /// The path to the network containing `node_id`. Empty for nodes at the root document network. 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 0f8a5129..17849a6a 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 @@ -405,10 +405,18 @@ impl<'a> MessageHandler> for NodeG { return; }; + // Double-clicking the layer's name area triggers inline rename instead of drilling into the subgraph. + if let Some(layer_id) = network_interface.layer_click_target_from_click(ipp.mouse.position, network_interface::LayerClickTargetTypes::Name, selection_network_path) { + responses.add(NodeGraphMessage::BeginEditLayerName { node_id: layer_id }); + return; + } if let Some(DocumentNodeImplementation::Network(_)) = network_interface.implementation(&node_id, selection_network_path) { responses.add(DocumentMessage::EnterNestedNetwork { node_id }); } } + NodeGraphMessage::BeginEditLayerName { node_id } => { + responses.add(FrontendMessage::TriggerEditLayerNameInGraph { node_id }); + } NodeGraphMessage::ExposeInput { input_connector, set_to_exposed, 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 aa009e74..189118ae 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -6,7 +6,8 @@ use super::document_metadata::{DocumentMetadata, LayerNodeIdentifier, NodeRelati use super::misc::PTZ; use super::nodes::SelectedNodes; use crate::consts::{ - EXPORTS_TO_RIGHT_EDGE_PIXEL_GAP, EXPORTS_TO_TOP_EDGE_PIXEL_GAP, GRID_SIZE, IMPORTS_TO_LEFT_EDGE_PIXEL_GAP, IMPORTS_TO_TOP_EDGE_PIXEL_GAP, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH, STACK_VERTICAL_GAP, + 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}; @@ -2503,7 +2504,7 @@ impl NodeNetworkInterface { return; }; - let node_top_left = node_position.as_dvec2() * 24.; + let node_top_left = node_position.as_dvec2() * GRID_SIZE as f64; let mut port_click_targets = Ports::new(); let document_node_click_targets = if !node_metadata.persistent_metadata.is_layer() { // Create input/output click targets @@ -2528,9 +2529,10 @@ impl NodeNetworkInterface { port_click_targets.insert_node_output(output_index, node_top_left); } - let height = input_row_count.max(number_of_outputs).max(1) as u32 * crate::consts::GRID_SIZE; - let width = 5 * crate::consts::GRID_SIZE; - let node_click_target_top_left = node_top_left + DVec2::new(0., 12.); + let height = input_row_count.max(number_of_outputs).max(1) as u32 * GRID_SIZE; + let width = 5 * GRID_SIZE; + // Offset down by half a grid so the click target sits below the top connector strip. + let node_click_target_top_left = node_top_left + DVec2::new(0., HALF_GRID_SIZE as f64); let node_click_target_bottom_right = node_click_target_top_left + DVec2::new(width as f64, height as f64); let radius = 3.; @@ -2554,37 +2556,90 @@ impl NodeNetworkInterface { log::error!("Could not get layer width in load_node_click_targets"); 0 }); - let width = layer_width_cells * crate::consts::GRID_SIZE; - let height = 2 * crate::consts::GRID_SIZE; + let width = layer_width_cells * GRID_SIZE; + let height = 2 * GRID_SIZE; let locked = self.is_locked(node_id, network_path); + // The layer is `2 * GRID_SIZE` tall, so its vertical center sits one grid unit below `node_top_left.y`. + // Visibility/lock buttons fill a 1-grid-cell square (so half-extents of HALF_GRID_SIZE each side of center). + const LAYER_VERTICAL_CENTER: f64 = GRID_SIZE as f64; + const ICON_HALF_EXTENT: f64 = HALF_GRID_SIZE as f64; + // Update visibility button click target - let visibility_offset = node_top_left + DVec2::new(width as f64, 24.); - let subpath = Subpath::new_rounded_rectangle(DVec2::new(-12., -12.) + visibility_offset, DVec2::new(12., 12.) + visibility_offset, [3.; 4]); + let visibility_offset = node_top_left + DVec2::new(width as f64, LAYER_VERTICAL_CENTER); + let subpath = Subpath::new_rounded_rectangle( + DVec2::new(-ICON_HALF_EXTENT, -ICON_HALF_EXTENT) + visibility_offset, + DVec2::new(ICON_HALF_EXTENT, ICON_HALF_EXTENT) + visibility_offset, + [3.; 4], + ); let visibility_click_target = ClickTarget::new_with_subpath(subpath, 0.); // Update lock button click target, positioned one grid unit to the left of the visibility button (only when locked) let lock_click_target = if locked { - let lock_offset = node_top_left + DVec2::new(width as f64 - GRID_SIZE as f64, 24.); - let subpath = Subpath::new_rounded_rectangle(DVec2::new(-12., -12.) + lock_offset, DVec2::new(12., 12.) + lock_offset, [3.; 4]); + let lock_offset = node_top_left + DVec2::new(width as f64 - GRID_SIZE as f64, LAYER_VERTICAL_CENTER); + let subpath = Subpath::new_rounded_rectangle( + DVec2::new(-ICON_HALF_EXTENT, -ICON_HALF_EXTENT) + lock_offset, + DVec2::new(ICON_HALF_EXTENT, ICON_HALF_EXTENT) + lock_offset, + [3.; 4], + ); Some(ClickTarget::new_with_subpath(subpath, 0.)) } else { None }; - // Update grip button click target, which is positioned to the left of the leftmost icon + // Update grip button click target, which is positioned to the left of the leftmost icon. + // The grip is 8px wide but spans the full layer-vertical-center band. + const GRIP_WIDTH: f64 = 8.; let icons_width = if locked { GRID_SIZE as f64 } else { 0. }; - let grip_offset_right_edge = node_top_left + DVec2::new(width as f64 - (GRID_SIZE as f64) / 2. - icons_width, 24.); - let subpath = Subpath::new_rounded_rectangle(DVec2::new(-8., -12.) + grip_offset_right_edge, DVec2::new(0., 12.) + grip_offset_right_edge, [0.; 4]); + let grip_offset_right_edge = node_top_left + DVec2::new(width as f64 - ICON_HALF_EXTENT - icons_width, LAYER_VERTICAL_CENTER); + let subpath = Subpath::new_rounded_rectangle( + DVec2::new(-GRIP_WIDTH, -ICON_HALF_EXTENT) + grip_offset_right_edge, + DVec2::new(0., ICON_HALF_EXTENT) + grip_offset_right_edge, + [0.; 4], + ); let grip_click_target = ClickTarget::new_with_subpath(subpath, 0.); + // Update display-name text click target, used to detect double-click rename. Sized to the text bounds + // (not the surrounding `.details` area) so the rest of the layer still drills into the subgraph on double-click. + + /// `.layer` margin-left (= 12), for chain layers the negative margin-left and positive padding-left cancel out, keeping content at this same offset + const LAYER_LEFT_MARGIN: f64 = HALF_GRID_SIZE as f64; + /// `.thumbnail` (70px) + its 1px side margins (= 72) + const THUMBNAIL_BLOCK_WIDTH: f64 = 3. * GRID_SIZE as f64; + /// `.details` margin-left + const DETAILS_LEFT_MARGIN: f64 = 8.; + const NAME_LEFT_OFFSET: f64 = LAYER_LEFT_MARGIN + THUMBNAIL_BLOCK_WIDTH + DETAILS_LEFT_MARGIN; + /// Distance from layer's right edge to visibility's left edge (= 12) + const VISIBILITY_INSET_FROM_LAYER_RIGHT: f64 = HALF_GRID_SIZE as f64; + const FONT_SIZE: f64 = 14.; + + let display_name = self.display_name(node_id, network_path); + let name_click_target = if display_name.is_empty() { + None + } else { + 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 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. + let name_top = node_top_left.y + HALF_GRID_SIZE as f64; + let name_bottom = node_top_left.y + GRID_SIZE as f64 + HALF_GRID_SIZE as f64; + let subpath = Subpath::new_rounded_rectangle(DVec2::new(name_left, name_top), DVec2::new(name_right, name_bottom), [3.; 4]); + Some(ClickTarget::new_with_subpath(subpath, 0.)) + } else { + None + } + }; + // Create layer click target, which is contains the layer and the chain background let chain_width_grid_spaces = self.chain_width(node_id, network_path); let node_bottom_right = node_top_left + DVec2::new(width as f64, height as f64); - let chain_top_left = node_top_left - DVec2::new((chain_width_grid_spaces * crate::consts::GRID_SIZE) as f64, 0.); - let radius = 10.; - let subpath = Subpath::new_rounded_rectangle(chain_top_left, node_bottom_right, [radius; 4]); + let chain_top_left = node_top_left - DVec2::new((chain_width_grid_spaces * GRID_SIZE) as f64, 0.); + const CORNER_RADIUS: f64 = 10.; + let subpath = Subpath::new_rounded_rectangle(chain_top_left, node_bottom_right, [CORNER_RADIUS; 4]); let node_click_target = ClickTarget::new_with_subpath(subpath, 0.); DocumentNodeClickTargets { @@ -2594,6 +2649,7 @@ impl NodeNetworkInterface { visibility_click_target, lock_click_target, grip_click_target, + name_click_target, }), } }; @@ -2932,6 +2988,7 @@ impl NodeNetworkInterface { LayerClickTargetTypes::Visibility => layer.visibility_click_target.intersect_point_no_stroke(point).then_some(*node_id), LayerClickTargetTypes::Lock => layer.lock_click_target.as_ref().and_then(|target| target.intersect_point_no_stroke(point).then_some(*node_id)), LayerClickTargetTypes::Grip => layer.grip_click_target.intersect_point_no_stroke(point).then_some(*node_id), + LayerClickTargetTypes::Name => layer.name_click_target.as_ref().and_then(|target| target.intersect_point_no_stroke(point).then_some(*node_id)), } } else { None @@ -6602,6 +6659,10 @@ pub struct LayerClickTargets { pub lock_click_target: Option, /// Cache for the grip icon, which is next to the visibility button. pub grip_click_target: ClickTarget, + /// Cache for the layer's display-name text bounds. Used to detect double-click rename and + /// to skip the drill-into-subgraph behavior when the click lands on the name itself. + /// `None` for layers whose display name is empty. + pub name_click_target: Option, // TODO: Store click target for the preview button, which will appear when the node is a selected/(hovered?) layer node // preview_click_target: ClickTarget, } @@ -6610,6 +6671,7 @@ pub enum LayerClickTargetTypes { Visibility, Lock, Grip, + Name, // Preview, } diff --git a/frontend/src/components/views/Graph.svelte b/frontend/src/components/views/Graph.svelte index 0dabf9b5..5a410106 100644 --- a/frontend/src/components/views/Graph.svelte +++ b/frontend/src/components/views/Graph.svelte @@ -1,5 +1,5 @@