Implement dragging to reorder Properties panel node sections (#4299)

* Implement dragging to reorder Properties panel node sections

* Code review
This commit is contained in:
Keavon Chambers
2026-07-01 03:39:52 -07:00
committed by GitHub
parent 8a38af5be7
commit 1648e33c1f
9 changed files with 410 additions and 22 deletions

View File

@@ -370,14 +370,23 @@ pub struct WidgetTable {
pub unstyled: bool,
}
/// A collapsible Properties panel section for a single node: a header with its name and pin, delete, and visibility controls, above its input parameter widgets.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct WidgetSection {
/// The node's name shown in the section header: its given alias with the implementation name in parentheses, or just the implementation name.
pub name: String,
/// The node definition's description, shown as the header's tooltip (empty when it has none).
pub description: String,
/// Whether the node is visible rather than hidden (shown as the eye icon).
pub visible: bool,
/// Whether the node is pinned, keeping its section shown in the Properties panel when nothing is selected (shown as the pin icon).
pub pinned: bool,
/// Whether this section can be dragged to reorder it within its layer's chain of nodes (true for a layer chain's nodes, but not its terminal layer node).
pub draggable: bool,
/// The ID of the node whose properties this section displays.
pub id: u64,
/// The node's properties content, rendered as the section's body when expanded.
pub layout: Layout,
}
@@ -411,6 +420,7 @@ impl LayoutGroup {
description: description.into(),
visible,
pinned,
draggable: false,
id,
layout,
})
@@ -507,6 +517,7 @@ impl Diffable for LayoutGroup {
description: current_description,
visible: current_visible,
pinned: current_pinned,
draggable: current_draggable,
id: current_id,
layout: current_layout,
}),
@@ -515,6 +526,7 @@ impl Diffable for LayoutGroup {
description: new_description,
visible: new_visible,
pinned: new_pinned,
draggable: new_draggable,
id: new_id,
layout: new_layout,
}),
@@ -526,6 +538,7 @@ impl Diffable for LayoutGroup {
|| *current_description != new_description
|| *current_visible != new_visible
|| *current_pinned != new_pinned
|| *current_draggable != new_draggable
|| *current_id != new_id
{
// Update self to reflect new changes
@@ -533,6 +546,7 @@ impl Diffable for LayoutGroup {
current_description.clone_from(&new_description);
*current_visible = new_visible;
*current_pinned = new_pinned;
*current_draggable = new_draggable;
*current_id = new_id;
current_layout.clone_from(&new_layout);
@@ -542,6 +556,7 @@ impl Diffable for LayoutGroup {
description: new_description,
visible: new_visible,
pinned: new_pinned,
draggable: new_draggable,
id: new_id,
layout: new_layout,
})

View File

@@ -103,6 +103,10 @@ pub enum DocumentMessage {
parent: LayerNodeIdentifier,
insert_index: usize,
},
ReorderPropertiesSection {
node_id: NodeId,
insert_index: usize,
},
MoveSelectedLayersToGroup {
parent: LayerNodeIdentifier,
},

View File

@@ -726,6 +726,39 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
DocumentMessage::MoveSelectedLayersTo { parent, insert_index } => {
self.move_selected_layers_to(parent, insert_index, responses);
}
DocumentMessage::ReorderPropertiesSection { node_id, insert_index } => {
// The Properties panel shows draggable sections in two cases, disambiguated by the current selection:
// a single selected layer (reorder within its node chain) or no selection (reorder the pinned nodes).
let selected_nodes = self.network_interface.selected_nodes_in_nested_network(&self.selection_network_path);
let Some(selected_nodes) = selected_nodes else { return };
let (mut layers, mut nodes) = (Vec::new(), Vec::new());
for selected in selected_nodes.selected_nodes() {
if self.network_interface.is_layer(selected, &self.selection_network_path) {
layers.push(*selected);
} else {
nodes.push(*selected);
}
}
layers.sort();
layers.dedup();
if layers.len() == 1 {
// Reorder a node within the selected layer's chain by rewiring the graph
responses.add(DocumentMessage::AddTransaction);
responses.add(NodeGraphMessage::ReorderChainNode { node_id, insert_index });
responses.add(NodeGraphMessage::RunDocumentGraph);
responses.add(NodeGraphMessage::SendGraph);
responses.add(PropertiesPanelMessage::Refresh);
} else if layers.is_empty() && nodes.is_empty() {
// Reorder a pinned node, which is purely a Properties panel display order (no graph rerender needed)
responses.add(DocumentMessage::AddTransaction);
responses.add(NodeGraphMessage::ReorderPinnedNode { node_id, insert_index });
responses.add(PropertiesPanelMessage::Refresh);
}
}
DocumentMessage::MoveSelectedLayersToGroup { parent } => {
// Group all shallowest unique selected layers in order
let all_layers_to_group_sorted = self.network_interface.shallowest_unique_layers_sorted(&self.selection_network_path);

View File

@@ -94,6 +94,14 @@ pub enum NodeGraphMessage {
node_id: NodeId,
parent: LayerNodeIdentifier,
},
ReorderChainNode {
node_id: NodeId,
insert_index: usize,
},
ReorderPinnedNode {
node_id: NodeId,
insert_index: usize,
},
SetChainPosition {
node_id: NodeId,
},

View File

@@ -186,6 +186,7 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
on: EventMessage::SelectionChanged,
send: Box::new(NodeGraphMessage::SelectedNodesUpdated.into()),
});
network_interface.load_structure();
collapsed.0.retain(|path| path.iter().all(|&node_id| network_interface.document_network().nodes.contains_key(&node_id)));
}
@@ -753,6 +754,12 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
NodeGraphMessage::MoveNodeToChainStart { node_id, parent } => {
network_interface.move_node_to_chain_start(&node_id, parent, selection_network_path, false);
}
NodeGraphMessage::ReorderChainNode { node_id, insert_index } => {
network_interface.reorder_chain_node(node_id, insert_index, selection_network_path);
}
NodeGraphMessage::ReorderPinnedNode { node_id, insert_index } => {
network_interface.reorder_pinned_node(node_id, insert_index, selection_network_path);
}
NodeGraphMessage::SetChainPosition { node_id } => {
network_interface.set_chain_position(&node_id, selection_network_path);
}
@@ -2525,25 +2532,18 @@ impl NodeGraphMessageHandler {
Separator::new(SeparatorStyle::Related).widget_instance(),
])];
let Some(network) = context.network_interface.nested_network(context.selection_network_path) else {
warn!("No network in collate_properties");
return Vec::new();
};
// And if no nodes are selected, show properties for all pinned nodes
let pinned_node_properties = network
.nodes
.keys()
.cloned()
.collect::<Vec<_>>()
.iter()
.filter_map(|node_id| {
if context.network_interface.is_pinned(node_id, context.selection_network_path) {
Some(node_properties::generate_node_properties(*node_id, context))
} else {
None
}
})
// And if no nodes are selected, show properties for all pinned nodes (in the user's saved order, each draggable to reorder)
let mut pinned_node_properties = context
.network_interface
.ordered_pinned_nodes(context.selection_network_path)
.into_iter()
.map(|node_id| node_properties::generate_node_properties(node_id, context))
.collect::<Vec<_>>();
for pinned_section in pinned_node_properties.iter_mut() {
if let LayoutGroup::Section(section) = pinned_section {
section.draggable = true;
}
}
properties.extend(pinned_node_properties);
properties
@@ -2605,7 +2605,7 @@ impl NodeGraphMessageHandler {
])];
// Iterate through all the upstream nodes, but stop when we reach another layer (since that's a point where we switch from horizontal to vertical flow)
let node_properties = context
let mut node_properties = context
.network_interface
.upstream_flow_back_from_nodes(vec![layer], context.selection_network_path, network_interface::FlowType::HorizontalFlow)
.enumerate()
@@ -2622,6 +2622,13 @@ impl NodeGraphMessageHandler {
.map(|node_id| node_properties::generate_node_properties(node_id, context))
.collect::<Vec<_>>();
// Mark each node in the layer's chain (but not the layer node itself, which is first) as draggable so its section can be reordered within the chain from the Properties panel
for chain_node_section in node_properties.iter_mut().skip(1) {
if let LayoutGroup::Section(section) = chain_node_section {
section.draggable = true;
}
}
layer_properties.extend(node_properties);
layer_properties
}

View File

@@ -1054,6 +1054,35 @@ impl NodeNetworkInterface {
node_metadata.persistent_metadata.pinned
}
/// The given network's pinned nodes in display order: pinning appends, dragging rearranges, and any not yet recorded go last.
pub fn ordered_pinned_nodes(&self, network_path: &[NodeId]) -> Vec<NodeId> {
let Some(network) = self.nested_network(network_path) else { return Vec::new() };
let order = self
.network_metadata(network_path)
.map(|network_metadata| network_metadata.persistent_metadata.pinned_node_order.clone())
.unwrap_or_default();
// Follow the saved order, keeping only nodes that still exist and are still pinned
let mut pinned_nodes = order
.iter()
.copied()
.filter(|node_id| network.nodes.contains_key(node_id) && self.is_pinned(node_id, network_path))
.collect::<Vec<_>>();
// Append any pinned nodes missing from the saved order at the end
let mut unordered = network
.nodes
.keys()
.copied()
.filter(|node_id| self.is_pinned(node_id, network_path) && !order.contains(node_id))
.collect::<Vec<_>>();
unordered.sort();
pinned_nodes.extend(unordered);
pinned_nodes
}
pub fn is_visible(&self, node_id: &NodeId, network_path: &[NodeId]) -> bool {
let Some(node) = self.document_node(node_id, network_path) else {
log::error!("Could not get node in is_visible");
@@ -4542,6 +4571,15 @@ impl NodeNetworkInterface {
self.set_chain_position(&previous_chain_node, network_path);
}
}
// Prune this network's pinned display order down to the nodes that still exist, dropping any that were actually removed
let surviving_nodes = self.nested_network(network_path).map(|network| network.nodes.keys().copied().collect::<HashSet<_>>());
if let Some(surviving_nodes) = surviving_nodes
&& let Some(network_metadata) = self.network_metadata_mut(network_path)
{
network_metadata.persistent_metadata.pinned_node_order.retain(|node_id| surviving_nodes.contains(node_id));
}
self.unload_all_nodes_bounding_box(network_path);
// Instead of unloaded all node click targets, just unload the nodes upstream from the deleted nodes. unload_upstream_node_click_targets will not work since the nodes have been deleted.
self.unload_all_nodes_click_targets(network_path);
@@ -4724,6 +4762,43 @@ impl NodeNetworkInterface {
};
node_metadata.persistent_metadata.pinned = pinned;
// Track the node in this network's pinned display order: append when newly pinned, prune when unpinned
if let Some(network_metadata) = self.network_metadata_mut(network_path) {
let order = &mut network_metadata.persistent_metadata.pinned_node_order;
if pinned {
if !order.contains(node_id) {
order.push(*node_id);
}
} else {
order.retain(|id| id != node_id);
}
}
self.transaction_modified();
}
/// Reorders a pinned node within its network's Properties panel display order so it ends up at `insert_index` among the
/// pinned nodes (0 being the topmost). Rebuilds the order from the list as currently shown, which also drops stale entries.
pub fn reorder_pinned_node(&mut self, node_id: NodeId, insert_index: usize, network_path: &[NodeId]) {
let shown = self.ordered_pinned_nodes(network_path);
let Some(from) = shown.iter().position(|id| *id == node_id) else { return };
let to = (if insert_index > from { insert_index - 1 } else { insert_index }).min(shown.len().saturating_sub(1));
if to == from {
return;
}
let mut new_order = shown;
let moved = new_order.remove(from);
new_order.insert(to, moved);
let Some(network_metadata) = self.network_metadata_mut(network_path) else {
log::error!("Could not get network_metadata in reorder_pinned_node");
return;
};
network_metadata.persistent_metadata.pinned_node_order = new_order;
self.transaction_modified();
}
@@ -6002,6 +6077,59 @@ impl NodeNetworkInterface {
self.force_set_upstream_to_chain(node_id, network_path);
}
}
/// Reorders a node within its layer's horizontal chain so it ends up at `insert_index` among the chain's nodes,
/// where index 0 is the node closest to the layer. The connection feeding the top (most-upstream end) of the chain
/// is preserved, as are each node's other (non-primary) inputs.
pub fn reorder_chain_node(&mut self, node_id: NodeId, insert_index: usize, network_path: &[NodeId]) {
let Some(layer) = self.downstream_layer_for_chain_node(&node_id, network_path) else {
log::error!("Could not find downstream layer for chain node {node_id} in reorder_chain_node");
return;
};
// The nodes in the layer's chain, ordered from closest-to-layer outward, stopping at the next layer
let chain = self
.upstream_flow_back_from_nodes(vec![layer], network_path, FlowType::HorizontalFlow)
.skip(1)
.take_while(|upstream_id| !self.is_layer(upstream_id, network_path))
.collect::<Vec<_>>();
let Some(from) = chain.iter().position(|id| *id == node_id) else {
log::error!("Node {node_id} is not part of its layer's chain in reorder_chain_node");
return;
};
// The drop gap is measured against the chain that still includes the dragged node, so shift it down by one if the node is being removed from before the gap
let to = (if insert_index > from { insert_index - 1 } else { insert_index }).min(chain.len() - 1);
if to == from {
return;
}
let mut new_order = chain.clone();
new_order.remove(from);
new_order.insert(to, node_id);
// Preserve whatever feeds the most-upstream chain node (a value, an import, or an upstream layer) so it stays at the top
let Some(tail_input) = self.input_from_connector(&InputConnector::node(*chain.last().unwrap(), 0), network_path).cloned() else {
log::error!("Could not get the upstream input of the chain in reorder_chain_node");
return;
};
// Disconnect the existing internal chain wiring first so the rewiring below can't transiently form a cycle
for &chain_node in &chain {
self.disconnect_input(&InputConnector::node(chain_node, 0), network_path);
}
// Rewire in the new order: layer's secondary input -> new_order[0] -> ... -> new_order[last] -> preserved tail input
self.set_input(&InputConnector::node(layer, 1), NodeInput::node(new_order[0], 0), network_path);
for pair in new_order.windows(2) {
self.set_input(&InputConnector::node(pair[0], 0), NodeInput::node(pair[1], 0), network_path);
}
self.set_input(&InputConnector::node(*new_order.last().unwrap(), 0), tail_input, network_path);
// Re-establish chain positioning for the reordered nodes
self.force_set_upstream_to_chain(&new_order[0], network_path);
}
}
#[derive(PartialEq)]
@@ -6346,6 +6474,9 @@ pub struct NodeNetworkPersistentMetadata {
/// Node metadata must exist for every document node in the network
#[serde(serialize_with = "graphene_std::vector::serialize_hashmap", deserialize_with = "graphene_std::vector::deserialize_hashmap")]
pub node_metadata: HashMap<NodeId, DocumentNodeMetadata>,
/// The display order of pinned nodes in the Properties panel (shown when nothing is selected in this network), keyed by node ID.
#[serde(default)]
pub pinned_node_order: Vec<NodeId>,
/// Cached metadata for each node, which is calculated when adding a node to node_metadata
/// Indicates whether the network is currently rendered with a particular node that is previewed, and if so, which connection should be restored when the preview ends.
pub previewing: Previewing,