SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
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")