git.lucas.co / hou-control
SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git

commit8a4fdb6490783d6d47795844caf4619b5aa861a1
parent4db009cd5f
authorLucas Galante <[email protected]>
date2026-09-14 14:47
neteditor: keyboard moves swap with the node they land on

translateSelectedNodes (the alt+hjkl moves) now runs the same swap a mouse
drop does: a lone node stepped onto another node's cell sends that node to
the cell it left, and a directly wired pair trades places in the chain.
Group moves only move, as with the drag. check.py steps a node onto a
wired neighbour and asserts the swap, then steps two together and asserts
none.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 python3.13libs/hc/hcnetworkeditor.py | 14 ++++++-
 python3.13libs/hc/hcschema.py        |  7 ++--
 tools/check.py                       | 72 ++++++++++++++++++++++++++++++++++++
 3 files changed, 88 insertions(+), 5 deletions(-)

diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index d67d911..4d367bd 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -917,11 +917,12 @@ class HCNetworkEditor(HCPathTab):
             None, f"Snapped {moved} of {len(nodes)} nodes", 1.0)
 
     def swapDroppedNode(self, node_path, start_pos):
-        """Finish a drag that dropped one node onto another: swap positions.
+        """Finish a move that put one node onto another: swap positions.
 
         nodegraphhooks records the node under the mouse and its position at
         mousedown and calls this on the mouseup that ends the drag, after
-        Houdini's move handler has written the new position. If the dragged
+        Houdini's move handler has written the new position;
+        translateSelectedNodes calls it after a keyboard step. If the moved
         node's centre now sits in another node's grid cell, that node moves
         into the cell the dragged one vacated. When the two are wired directly
         to each other they also trade places in the chain (see
@@ -1108,6 +1109,12 @@ class HCNetworkEditor(HCPathTab):
         before_rect = self._selectedNodesRect()
         moved_any = False
         offset = self._nodeOffset()
+        # A lone node stepped onto another node's cell swaps with it, exactly
+        # as a mouse drop does. Several nodes moving together do not: which
+        # of them landed on what is ambiguous, the same rule as the drag.
+        swap_start = None
+        if len(final_nodes) == 1:
+            swap_start = next(iter(final_nodes)).position()
         for hou_node in final_nodes:
             pos = hou_node.position()
             center = pos + offset
@@ -1118,6 +1125,9 @@ class HCNetworkEditor(HCPathTab):
             else:
                 hou_node.setPosition(G + delta - offset)
             moved_any = True
+        if swap_start is not None:
+            lone = next(iter(final_nodes))
+            self.swapDroppedNode(lone.path(), (swap_start[0], swap_start[1]))
         if moved_any:
             after_rect = self._selectedNodesRect()
             if before_rect is None or after_rect is None:
diff --git a/python3.13libs/hc/hcschema.py b/python3.13libs/hc/hcschema.py
index d0a91e1..5d16aaf 100644
--- a/python3.13libs/hc/hcschema.py
+++ b/python3.13libs/hc/hcschema.py
@@ -117,9 +117,10 @@ SCHEMA = {
         ),
         "drop_swap": Setting(
             "bool", True, label="Drop to Swap",
-            help="Dragging a node onto another node's cell moves that node "
-                 "into the cell the dragged one left. Two nodes wired directly "
-                 "to each other also trade places in the chain.",
+            help="Dragging a node onto another node's cell, or stepping it "
+                 "there with the move keys, moves that node into the cell the "
+                 "first one left. Two nodes wired directly to each other also "
+                 "trade places in the chain.",
         ),
         "grid_snap": Setting(
             "bool", True, label="Hard Grid Snap",
diff --git a/tools/check.py b/tools/check.py
index 70a9795..0412e6d 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -951,6 +951,78 @@ def check_node_ops():
 
     check("linked swap trades chain places", linked_swap_trades_chain_places)
 
+    def keyboard_step_swaps_too():
+        """translateSelectedNodes (the alt+hjkl moves): a lone selected node
+        stepped onto a neighbour's cell swaps with it like a drop does; two
+        nodes stepping together only move."""
+        from hc import hcnetworkeditor as ne
+
+        geo = hou.node("/obj").createNode("geo")
+        try:
+            a = geo.createNode("null", "a")
+            b = geo.createNode("null", "b")
+            c = geo.createNode("null", "c")
+            b.setInput(0, a)
+
+            class FakePane:
+                def id(self):
+                    return 987660
+
+            class FakeTab:
+                def __init__(self):
+                    self.view = hou.BoundingRect(-20.0, -20.0, 20.0, 20.0)
+
+                def pane(self):
+                    return FakePane()
+
+                def setPref(self, name, value):
+                    pass
+
+                def pwd(self):
+                    return geo
+
+                def itemRect(self, node):
+                    pos = node.position()
+                    return hou.BoundingRect(pos[0], pos[1], pos[0] + 1.0, pos[1] + 0.3)
+
+                def visibleBounds(self):
+                    return hou.BoundingRect(self.view)
+
+                def setVisibleBounds(self, bounds, *args):
+                    self.view = hou.BoundingRect(bounds)
+
+                def flashMessage(self, *args):
+                    pass
+
+            editor = ne.HCNetworkEditor(FakeTab())
+            editor.updateCurrentNodeOverlay = lambda force=False: None
+            step = editor._gridStep()
+            for node, cell in ((a, (0, 1)), (b, (0, 0)), (c, (2, 0))):
+                node.setPosition(hou.Vector2(cell[0] * step[0], cell[1] * step[1]))
+                editor.snapToGrid(node)
+            a_start, b_start, c_start = a.position(), b.position(), c.position()
+
+            a.setSelected(True, clear_all_selected=True)
+            editor.translateSelectedNodes("down")  # a steps onto b
+            assert a.position().isAlmostEqual(b_start), f"a should be on b's old cell, is at {a.position()}"
+            assert b.position().isAlmostEqual(a_start), f"b should take a's old cell, is at {b.position()}"
+            assert a.inputs() == (b,) and not b.inputs(), "linked pair did not trade chain places"
+            assert c.position().isAlmostEqual(c_start), "a bystander moved"
+
+            # Two nodes stepping together: no swap, both just move.
+            a.setSelected(True, clear_all_selected=True)
+            b.setSelected(True)
+            a_pos, b_pos = a.position(), b.position()
+            editor.translateSelectedNodes("right")
+            assert a.position().isAlmostEqual(a_pos + hou.Vector2(step[0], 0.0))
+            assert b.position().isAlmostEqual(b_pos + hou.Vector2(step[0], 0.0))
+            assert c.position().isAlmostEqual(c_start), "a group move swapped with a bystander"
+            return "lone step onto a neighbour swaps cells and chain; a group step only moves"
+        finally:
+            geo.destroy()
+
+    check("keyboard step swaps too", keyboard_step_swaps_too)
+
     def pans_reach_the_editor():
         """hou.NetworkEditor.setVisibleBounds drops a change that keeps the
         zoom unless set_center_when_scale_rejected is passed (the docs say