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

commit8a870a0e75c88fc4b3130fb96e5be5e2fbe588fa
parent36cb108bb1
authorLucas Galante <[email protected]>
date2026-09-11 12:51
hc: give split handles their own X window so they can be clicked

The handles could be seen and could not be used. Cursor events passed straight
through them to the split bar behind, exactly as reported.

Houdini draws its entire UI into one QOpenGLWidget, RE_WindowDrawable, backed
by a real native QWindow. On xcb a native window owns mouse input across its
whole area, so a plain QWidget sibling stacked above it is only painted there:
X routes the press to the GL surface underneath, which is where Houdini
hit-tests its own split bars. Setting WA_NativeWindow puts the handle in X's
stacking order and input reaches it.

Measured in a live session before fixing anything: over one hover, the handle
with a native window received 56 events (Enter, HoverEnter, HoverMove, Leave)
and the one without received zero. After, a drag on the native handle
delivered 32 MouseMove events and a release, moving the split from 0.6213 to
0.6715.

Two things this rules out, both of which I had proposed and neither of which
was the cause. raise_() reorders Qt's widget stack, not X's, so it could never
have helped. And the geometry was never wrong: QApplication.widgetAt() already
named the handle as the widget under the cursor while real presses were being
delivered to RE_WindowDrawable.

Worth recording, because it made this hard to see: synthesised events bypass X
routing entirely. QApplication.sendEvent() drove a complete, correct drag
through a handle that no real mouse could reach, so the drag logic tested
perfectly while being completely unreachable.

Qt also reports the main window at x=3340 on a 3840-wide screen, which is not
where it is; widgetAt puts the true origin near x=75. _pin survives that only
because Houdini's qtScreenGeometry() carries the same error and the two cancel
in the mapFromGlobal round trip. Left alone -- it is self-consistent and the
handles land correctly -- but it is a latent trap.

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

 python3.13libs/hc/hcsplithandles.py | 19 +++++++++++++++++++
 tools/check.py                      | 16 ++++++++++++++++
 2 files changed, 35 insertions(+)

diff --git a/python3.13libs/hc/hcsplithandles.py b/python3.13libs/hc/hcsplithandles.py
index c473f5c..af795e8 100644
--- a/python3.13libs/hc/hcsplithandles.py
+++ b/python3.13libs/hc/hcsplithandles.py
@@ -31,6 +31,25 @@ class _SplitHandle(QWidget):
         self.setCursor(Qt.SplitVCursor if horizontal_boundary else Qt.SplitHCursor)
         self.setAttribute(Qt.WA_StyledBackground, True)
         self.setStyleSheet(self.REST_STYLE)
+
+        # Give the handle its own X window, or it can never be clicked.
+        #
+        # Houdini draws its entire UI into one QOpenGLWidget, RE_WindowDrawable,
+        # backed by a real native QWindow. On xcb a native window owns mouse
+        # input across its whole area, and a plain QWidget sibling stacked above
+        # it is only *painted* there -- X still routes the press to the GL
+        # surface underneath, which is where Houdini hit-tests its own split
+        # bars. The handle looked and highlighted correctly while every click
+        # fell through to the splitter behind it.
+        #
+        # raise_() cannot fix this: it reorders Qt's widget stack, not X's.
+        # Neither can any amount of geometry work -- QApplication.widgetAt()
+        # already reported the handle as the widget under the cursor while real
+        # presses were being delivered to RE_WindowDrawable. Measured in a live
+        # session: with this attribute set the handle received 32 MouseMove
+        # events and a release through one drag; without it, zero events ever.
+        self.setAttribute(Qt.WA_NativeWindow, True)
+        self.winId()  # create it now rather than lazily on first show
         self._origin = None
         self._start_fraction = None
         self._parent_extent = None
diff --git a/tools/check.py b/tools/check.py
index b52a9ce..a9ef5df 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -364,6 +364,22 @@ def check_split_handles():
 
     check("mid-drag handle is skipped", dragging_handle_is_left_alone)
 
+    def handle_is_a_native_window():
+        """Without its own X window the handle can be seen but never clicked.
+
+        Houdini renders its UI into one QOpenGLWidget backed by a native
+        QWindow, and on xcb a native window owns mouse input across its area.
+        A plain QWidget sibling painted on top receives nothing -- the press
+        goes to the GL surface, where Houdini hit-tests its own split bars.
+        """
+        from PySide6.QtCore import Qt
+        assert handle.testAttribute(Qt.WA_NativeWindow), \
+            "split handle is not a native window; clicks will fall through to Houdini"
+        assert handle.internalWinId(), "native window was never created"
+        return f"native, winId set"
+
+    check("handle owns an X window", handle_is_a_native_window)
+
     def horizontal_boundary():
         top = FakePane(QRect(0, 0, 1000, 300))
         bottom = FakePane(QRect(0, 300, 1000, 500))