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

commit6bf2061a3d68adfe62156ce8bc5ed1aba23e36af
parent7ca8ec846a
authorLucas Galante <[email protected]>
date2026-09-12 09:55
hc: spell out the currentNode staleness in the docstrings and check

What testing Rename Node on a real SOP settled: which cases the editor's
answer is actually wrong in, and which of the ten callers each case
reached. The docstrings carried a rougher account of that.

check.py gains the headless stand-in for the staleness -- children
present, none of them current, answer None rather than a leftover -- and
the guard check is renamed, since `current.parent() != pwd()` is not
always true: it fires only in the fallback case.

Co-Authored-By: Claude Opus 5 <[email protected]>

 python3.13libs/hc/hcnetworkeditor.py | 42 ++++++++++++++-----------
 tools/check.py                       | 59 +++++++++++++++++++++++++++---------
 2 files changed, 68 insertions(+), 33 deletions(-)

diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index 3bfd86b..9f7d63d 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -641,24 +641,30 @@ class HCNetworkEditor(HCPathTab):
         self.expandHcnetcursor(direction)
 
     def currentNode(self):
-        """The current node *inside* the displayed network, or None.
-
-        hou.NetworkEditor.currentNode() does not mean this: it returns the
-        network being displayed, the same object as pwd(). So every caller here
-        was handed the network itself, and the `current.parent() == pwd()`
-        guards written around it -- pwd().parent() == pwd() -- were never true.
-        Half the callers silently fell back to something else; the other half
-        acted on the network. Rename Node renamed the network you were inside.
-
-        Houdini exposes the real current node only as a per-child isCurrent()
-        flag, so it has to be searched for. Being current implies being
-        selected -- selecting another node moves current with it, deselecting
-        clears it -- so selectedChildren(), one call returning a short list,
-        finds it every time in practice. tools/check.py asserts that invariant;
-        the children() scan below is the safety net for if it ever stops
-        holding, not the normal path. It matters because this runs from the
-        overlay on every network editor UI event, and scanning a large network
-        on every mouse move would not be free.
+        """The node that is current *now*, inside the displayed network, or None.
+
+        hou.NetworkEditor.currentNode() does not answer that. It answers with
+        the editor's last-known current node, which goes stale two ways: it
+        keeps naming a node after that node has stopped being current, and when
+        the editor has no current child in the displayed network it returns the
+        network itself. Measured in a live session: with nothing current it
+        still named the node that used to be, and at /obj it named /obj.
+
+        Callers guarded the second case with `current.parent() == pwd()` --
+        which is `pwd().parent() == pwd()`, so when it fired it discarded the
+        answer entirely, and the off-screen arrow never drew at /obj at all.
+        Nothing guarded the first, so Rename Node and the flag toggles would
+        act on whatever had been current a moment ago.
+
+        hou.Node.isCurrent() is authoritative and immediate, but it is a
+        per-child flag, so the node has to be searched for. Being current
+        implies being selected -- selecting another node moves current with it,
+        deselecting clears it -- so selectedChildren(), one call returning a
+        short list, finds it every time in practice. tools/check.py asserts
+        that invariant; the children() scan below is the safety net for if it
+        stops holding, not the normal path. It matters because this runs from
+        the overlay on every network editor UI event, and scanning a large
+        network on every mouse move would not be free.
         """
         network = self.hou_tab.pwd()
         for node in network.selectedChildren():
diff --git a/tools/check.py b/tools/check.py
index 78bc770..70c45cc 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -837,15 +837,21 @@ def check_node_colors():
 
 
 def check_current_node():
-    """HCNetworkEditor.currentNode() has to mean the current node *inside* the
-    network, not the network itself.
-
-    hou.NetworkEditor.currentNode() returns pwd(), so the wrapper used to hand
-    every caller the network: Rename Node renamed the network you were inside,
-    the flag toggles flagged it, and the off-screen arrow -- whose guard read
-    `current.parent() != pwd()`, i.e. `pwd().parent() != pwd()` -- never drew
-    at all. currentNode() only touches hou_tab.pwd(), so a stub tab is enough
-    to drive it without a pane.
+    """HCNetworkEditor.currentNode() has to answer with the node that is
+    current *now*, inside the displayed network.
+
+    hou.NetworkEditor.currentNode() answers with the editor's last-known
+    current node instead. It keeps naming a node after that node stops being
+    current, and it falls back to the network itself when the editor has no
+    current child there. So Rename Node and the flag toggles could act on
+    whatever had been current a moment ago, and at /obj -- where the fallback
+    applies -- the off-screen arrow never drew at all, its guard
+    `current.parent() != pwd()` reducing to `pwd().parent() != pwd()`.
+
+    The staleness lives in the editor, so it cannot be reproduced without a
+    pane. What is checkable here is the rule the fix rests on: read isCurrent()
+    off the children, and answer None when none of them is current. currentNode()
+    only touches hou_tab.pwd(), so a stub tab is enough to drive it.
     """
     print("current node")
 
@@ -918,17 +924,40 @@ def check_current_node():
 
     check("currentNode on an empty network", empty_network_is_none)
 
-    def no_dead_parent_guards():
-        """`current.parent() != pwd()` is always true -- it must not come back."""
+    def nothing_current_is_none():
+        """The headless stand-in for the editor's staleness: children exist,
+        none of them is current, so the answer is None rather than a leftover."""
+        geo = hou.node("/obj").createNode("geo")
+        node = geo.createNode("box")
+        editor = blank(HCNetworkEditor)
+        editor.hou_tab = _StubTab(geo)
+
+        node.setCurrent(True, clear_all_selected=True)
+        assert editor.currentNode() == node, "precondition: the node is current"
+
+        node.setSelected(False)  # also clears current
+        assert not node.isCurrent(), "precondition: nothing is current now"
+        assert editor.currentNode() is None, \
+            "answered with a node that is no longer current"
+
+        geo.destroy()
+        return "None once nothing is current"
+
+    check("currentNode goes None", nothing_current_is_none)
+
+    def guards_stay_gone():
+        """The `current.parent()` guards only fired in the fallback case, and
+        threw the answer away when they did. currentNode() now returns a child
+        of pwd() or None, so there is nothing left for them to check."""
         source = (ROOT / "python3.13libs" / "hc" / "hcnetworkeditor.py").read_text()
-        for dead in ("current.parent() != ed.pwd()",
+        for gone in ("current.parent() != ed.pwd()",
                      "current.parent() == self.hou_tab.pwd()"):
-            assert dead not in source, f"the always-{'true' if '!=' in dead else 'false'} guard {dead!r} is back"
+            assert gone not in source, f"the guard {gone!r} is back"
         assert "self.hou_tab.currentNode()" not in source, \
-            "a caller bypasses the wrapper and gets the network again"
+            "a caller bypasses the wrapper and gets the editor's stale answer"
         return "no caller bypasses the wrapper"
 
-    check("no dead guards", no_dead_parent_guards)
+    check("guards stay gone", guards_stay_gone)
 
 
 def check_startup_script():