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
This commit is contained in:
Keavon Chambers
2026-04-28 17:30:25 -07:00
committed by GitHub
parent 6b11b47753
commit fc7348d08a
6 changed files with 173 additions and 26 deletions

View File

@@ -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;

View File

@@ -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,
},

View File

@@ -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.

View File

@@ -405,10 +405,18 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> 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,

View File

@@ -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<ClickTarget>,
/// 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<ClickTarget>,
// 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,
}