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

commitaf0605e4cf3c14f956493db1414e60f69d2f4cc6
parent4a3ae1a323
authorLucas Galante <[email protected]>
date2026-09-11 10:02
hc: pin split handles after Houdini relayouts, and keep them tracking

A dragged handle trailed the boundary it is supposed to sit on, and was left
behind where the drag ended.

_applyPending called setSplitFraction and then _pin in the same event loop
pass. Houdini repositions its panes on the next one, so qtScreenGeometry()
still reported the pre-change layout and the handle was pinned to where the
boundary had just been -- one throttle tick behind for the whole drag.
mouseReleaseEvent pinned synchronously too, so the final position never
landed either. Both now defer with QTimer.singleShot(0, ...).

Separately, nothing repinned a handle unless that handle was dragged or the
main window resized. Contract Pane, Expand Pane, Houdini's own splitters and
maximizing a pane all moved boundaries with the handles left behind, which is
presumably what Refresh Split Handles was for. A 250ms tracker now repins
from wherever the boundary actually is. To make polling free, _pin computes
its rect first and returns without touching the widget when nothing moved,
and _pin_all skips a handle that is mid-drag rather than fighting it with
staler geometry.

tools/check.py grows a split-handles section: fake panes stand in for
hou.Pane, so the pinning maths, the follow-a-moved-boundary regression, the
no-op tick, the mid-drag skip and the post-teardown deferred pin are all
covered. It needs an offscreen QApplication and skips cleanly without one.

Not verified in a running Houdini -- the fix rests on pane geometry being
stale until the next event loop pass, which is what the symptom indicates but
which the fake panes cannot prove.

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

 python3.13libs/hc/hcsplithandles.py |  58 +++++++++++++--
 tools/check.py                      | 136 ++++++++++++++++++++++++++++++++++++
 2 files changed, 189 insertions(+), 5 deletions(-)

diff --git a/python3.13libs/hc/hcsplithandles.py b/python3.13libs/hc/hcsplithandles.py
index 7b30806..cae2198 100644
--- a/python3.13libs/hc/hcsplithandles.py
+++ b/python3.13libs/hc/hcsplithandles.py
@@ -1,5 +1,5 @@
 import hou
-from PySide6.QtCore import QEvent, QObject, QPoint, Qt, QTimer
+from PySide6.QtCore import QEvent, QObject, QPoint, QRect, Qt, QTimer
 from PySide6.QtWidgets import QWidget
 
 
@@ -7,6 +7,11 @@ HCSPLITHANDLE_THICKNESS = 20
 HCSPLITHANDLE_LENGTH = 80
 HCSPLITHANDLE_MIN_FRACTION = 0.05
 HCSPLITHANDLE_MAX_FRACTION = 0.95
+# How often handles re-check where their boundary actually is. A split can move
+# for reasons no handle sees -- Houdini's own splitters, Contract/Expand Pane,
+# maximizing a pane -- and without this the handle sits at the old boundary
+# until something resizes the main window.
+HCSPLITHANDLE_TRACK_MS = 250
 
 
 class _SplitHandle(QWidget):
@@ -76,14 +81,34 @@ class _SplitHandle(QWidget):
         self._drag_child = None
         self._pending_fraction = None
         self._throttle.stop()
-        self.manager._pin_all()
+        # Deferred for the same reason as in _applyPending: the final
+        # setSplitFraction has not been laid out yet at this point, so pinning
+        # now would leave every handle at the second-to-last position.
+        QTimer.singleShot(0, self.manager._pin_all)
+
+    def isDragging(self):
+        return self._origin is not None
 
     def _applyPending(self):
         if self._pending_fraction is None or self._drag_child is None:
             return
         self._drag_child.setSplitFraction(self._pending_fraction)
         self._pending_fraction = None
-        self.manager._pin(self)
+        # Houdini repositions its panes on the next event loop pass, so the
+        # geometry qtScreenGeometry() reports right now is still the pre-change
+        # one. Pinning from it puts the handle where the boundary *was* -- the
+        # handle then trails the split by one throttle tick for the whole drag
+        # and is left behind when the drag ends.
+        QTimer.singleShot(0, self._pinDeferred)
+
+    def _pinDeferred(self):
+        # hide() can tear the handle down between scheduling this and its
+        # firing, leaving a wrapper around a deleted widget.
+        try:
+            if self in self.manager.handles:
+                self.manager._pin(self)
+        except RuntimeError:
+            pass
 
 
 class _ResizeFilter(QObject):
@@ -102,6 +127,7 @@ class HCSplitHandles:
         self.main = hou.qt.mainWindow()
         self.handles = []
         self._filter = None
+        self._tracker = None
 
     def show(self):
         if self.handles:
@@ -121,9 +147,21 @@ class HCSplitHandles:
             self.handles.append(handle)
         self._filter = _ResizeFilter(self)
         self.main.installEventFilter(self._filter)
+
+        # A Resize on the main window is not the only thing that moves a
+        # boundary, so poll as well. _pin only touches a handle whose geometry
+        # actually changed, making an idle tick nearly free.
+        self._tracker = QTimer(self.main)
+        self._tracker.timeout.connect(self._pin_all)
+        self._tracker.start(HCSPLITHANDLE_TRACK_MS)
+
         self._pin_all()
 
     def hide(self):
+        if self._tracker is not None:
+            self._tracker.stop()
+            self._tracker.deleteLater()
+            self._tracker = None
         for handle in self.handles:
             handle.setParent(None)
             handle.deleteLater()
@@ -172,6 +210,10 @@ class HCSplitHandles:
 
     def _pin_all(self):
         for handle in self.handles:
+            # The handle being dragged pins itself from _applyPending; pinning
+            # it from here too would fight that with staler geometry.
+            if handle.isDragging():
+                continue
             self._pin(handle)
 
     def _pin(self, handle):
@@ -192,7 +234,7 @@ class HCSplitHandles:
             y_screen = (g0.y() + g0.height() + g1.y()) // 2
             x_screen = split_geom.x() + split_geom.width() // 2
             center = self.main.mapFromGlobal(QPoint(x_screen, y_screen))
-            handle.setGeometry(
+            rect = QRect(
                 center.x() - HCSPLITHANDLE_LENGTH // 2,
                 center.y() - HCSPLITHANDLE_THICKNESS // 2,
                 HCSPLITHANDLE_LENGTH,
@@ -202,11 +244,17 @@ class HCSplitHandles:
             x_screen = (g0.x() + g0.width() + g1.x()) // 2
             y_screen = split_geom.y() + split_geom.height() // 2
             center = self.main.mapFromGlobal(QPoint(x_screen, y_screen))
-            handle.setGeometry(
+            rect = QRect(
                 center.x() - HCSPLITHANDLE_THICKNESS // 2,
                 center.y() - HCSPLITHANDLE_LENGTH // 2,
                 HCSPLITHANDLE_THICKNESS,
                 HCSPLITHANDLE_LENGTH,
             )
+
+        # Called on a timer, so do nothing when nothing moved: re-raising every
+        # tick churns the widget stack and repaints for no reason.
+        if handle.geometry() == rect and handle.isVisible():
+            return
+        handle.setGeometry(rect)
         handle.show()
         handle.raise_()
diff --git a/tools/check.py b/tools/check.py
index ddb8938..e5bcebd 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -255,6 +255,141 @@ def check_chrome():
     check("chrome overrides chain", overrides_extend_the_base)
 
 
+def check_split_handles():
+    """HCSplitHandles pinning, with fake panes standing in for hou.Pane.
+
+    This is the one bit of pure-Qt UI code with logic worth testing: the
+    handles used to be pinned from pane geometry Houdini had not laid out yet,
+    so a dragged handle trailed its boundary and was left behind at the end.
+    Needs an offscreen QApplication; skipped when Qt cannot start.
+    """
+    print("split handles")
+    os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+    try:
+        from PySide6 import QtWidgets
+        from PySide6.QtCore import QRect
+        app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
+    except Exception as e:
+        print(f"  skip (no Qt: {type(e).__name__})")
+        return
+
+    import hc.hcsplithandles as sh
+
+    class FakePane:
+        _next = [1]
+
+        def __init__(self, rect, children=None):
+            self.rect = rect
+            self.children = children or []
+            self._id = FakePane._next[0]
+            FakePane._next[0] += 1
+
+        def id(self):
+            return self._id
+
+        def qtScreenGeometry(self):
+            return self.rect
+
+        def getSplitChild(self, i):
+            if i < len(self.children):
+                return self.children[i]
+            raise IndexError(i)
+
+    main = QtWidgets.QWidget()
+    main.setGeometry(0, 0, 1000, 800)
+    main.show()
+
+    def centre(handle):
+        # `main` is a real top-level widget wherever the WM put it, so compare
+        # in the screen space _pin computes in.
+        return main.mapToGlobal(handle.geometry().center())
+
+    left = FakePane(QRect(0, 0, 400, 800))
+    right = FakePane(QRect(400, 0, 600, 800))
+    split = FakePane(QRect(0, 0, 1000, 800), [left, right])
+
+    mgr = sh.HCSplitHandles.__new__(sh.HCSplitHandles)
+    mgr.main, mgr.handles, mgr._filter, mgr._tracker = main, [], None, None
+    handle = sh._SplitHandle(main, split, horizontal_boundary=False, manager=mgr)
+    mgr.handles.append(handle)
+
+    def pins_to_the_boundary():
+        mgr._pin(handle)
+        x = centre(handle).x()
+        assert abs(x - 400) <= 1, f"centred at {x}, expected ~400"
+        return f"centre x={x}, boundary at 400"
+
+    check("pins to the boundary", pins_to_the_boundary)
+
+    def follows_a_boundary_moved_elsewhere():
+        before = handle.geometry()
+        left.rect = QRect(0, 0, 700, 800)
+        right.rect = QRect(700, 0, 300, 800)
+        mgr._pin(handle)
+        assert handle.geometry() != before, "handle did not follow the boundary"
+        x = centre(handle).x()
+        assert abs(x - 700) <= 1, f"centred at {x}, expected ~700"
+        return f"followed 400 -> {x}"
+
+    check("follows a boundary moved elsewhere", follows_a_boundary_moved_elsewhere)
+
+    def idle_tick_does_nothing():
+        mgr._pin(handle)
+        before = handle.geometry()
+        calls = []
+        original = handle.setGeometry
+        handle.setGeometry = lambda *a: (calls.append(a), original(*a))[1]
+        for _ in range(10):
+            mgr._pin(handle)
+        handle.setGeometry = original
+        assert not calls, f"setGeometry called {len(calls)}x with nothing changed"
+        assert handle.geometry() == before
+        return "10 ticks, 0 widget writes"
+
+    check("unchanged tick is free", idle_tick_does_nothing)
+
+    def dragging_handle_is_left_alone():
+        handle._origin = object()
+        assert handle.isDragging()
+        left.rect = QRect(0, 0, 200, 800)
+        right.rect = QRect(200, 0, 800, 800)
+        before = handle.geometry()
+        mgr._pin_all()
+        assert handle.geometry() == before, "_pin_all fought a handle mid-drag"
+        handle._origin = None
+        mgr._pin_all()
+        x = centre(handle).x()
+        assert abs(x - 200) <= 1, f"centred at {x}, expected ~200"
+        return "skipped mid-drag, pinned on release"
+
+    check("mid-drag handle is skipped", dragging_handle_is_left_alone)
+
+    def horizontal_boundary():
+        top = FakePane(QRect(0, 0, 1000, 300))
+        bottom = FakePane(QRect(0, 300, 1000, 500))
+        vsplit = FakePane(QRect(0, 0, 1000, 800), [top, bottom])
+        h = sh._SplitHandle(main, vsplit, horizontal_boundary=True, manager=mgr)
+        mgr.handles.append(h)
+        mgr._pin(h)
+        y = centre(h).y()
+        assert abs(y - 300) <= 1, f"centred at y={y}, expected ~300"
+        g = h.geometry()
+        assert g.width() > g.height(), "a horizontal boundary wants a wide handle"
+        return f"centre y={y}, {g.width()}x{g.height()}"
+
+    check("horizontal boundary", horizontal_boundary)
+
+    def deferred_pin_survives_teardown():
+        stale = sh._SplitHandle(main, split, horizontal_boundary=False, manager=mgr)
+        stale._pinDeferred()          # never added to mgr.handles
+        mgr.handles.remove(handle)
+        handle._pinDeferred()         # removed between schedule and fire
+        mgr.handles.append(handle)
+        return "no exception once the handle is gone"
+
+    check("deferred pin after teardown", deferred_pin_survives_teardown)
+
+
 def check_geometry():
     """The visible-geometry walk, after HCNode was removed from under it."""
     print("geometry")
@@ -430,6 +565,7 @@ def main():
     check_commands()
     check_state()
     check_chrome()
+    check_split_handles()
     check_geometry()
     check_node_ops()
     print(f"\n{passed} passed, {failed} failed")