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

commit30f407eaa4e878b722a1b6a3d32cbed15fe9075c
parentccc592f8bb
authorLucas Galante <[email protected]>
date2026-09-17 14:15
pane cycling: warp the pointer through the compositor

QCursor.setPos only moves X's idea of the pointer under Xwayland: Qt and
hou.ui.paneUnderCursor agreed the pointer was in the next pane while the
cursor on screen never moved, and the next real motion snapped it back.
hcviewregions.move_pointer goes through ccectl pointer-move-to instead,
mapping the Qt global point to layout pixels via the window's entry in
ccectl windows --json (matched on its x11 id, scaled by Qt width over
layout width, read fresh because the desktop pans under Houdini). The X
warp remains the fallback without a compositor.

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

 python3.13libs/hc/hcsession.py     |  6 ++-
 python3.13libs/hc/hcviewregions.py | 75 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 79 insertions(+), 2 deletions(-)

diff --git a/python3.13libs/hc/hcsession.py b/python3.13libs/hc/hcsession.py
index 55b0029..044aa08 100644
--- a/python3.13libs/hc/hcsession.py
+++ b/python3.13libs/hc/hcsession.py
@@ -1102,8 +1102,10 @@ class HCSession:
         else:
             index = 0 if step > 0 else len(panes) - 1
         target = panes[index]
-        from PySide6.QtGui import QCursor
-        QCursor.setPos(target.qtScreenGeometry().center())
+        # Through the compositor: an X warp alone leaves the visible cursor
+        # where it was (see hcviewregions.move_pointer).
+        from .hcviewregions import move_pointer
+        move_pointer(target.qtScreenGeometry().center())
         return target
 
     @command("Next Pane")
diff --git a/python3.13libs/hc/hcviewregions.py b/python3.13libs/hc/hcviewregions.py
index 49e964b..a1803c9 100644
--- a/python3.13libs/hc/hcviewregions.py
+++ b/python3.13libs/hc/hcviewregions.py
@@ -55,6 +55,81 @@ def ccectl():
     return shutil.which("ccectl")
 
 
+#: Seconds to wait on the two ``ccectl`` calls a pointer move makes. These
+#: are waited on, unlike the region publishes: the caller wants the pointer
+#: moved before it returns, and the alternative is a warp nobody sees.
+POINTER_TIMEOUT = 1.0
+
+
+def move_pointer(point):
+    """Put the pointer at a Qt global ``point``, and say whether it moved.
+
+    ``QCursor.setPos`` is not enough under Xwayland: it moves X's idea of the
+    pointer, so Qt and ``hou.ui.paneUnderCursor`` both report the new spot,
+    but the cursor on screen stays where it was and the next real motion
+    snaps X back to it. Only the compositor moves the one the user sees, so
+    this goes through ``ccectl pointer-move-to`` when there is a compositor,
+    and falls back to the X warp otherwise.
+
+    ``ccectl`` speaks layout pixels, and the compositor pans the desktop
+    under Houdini, so the window's layout position is read fresh each time
+    from ``ccectl windows --json`` (one line per window, ``x11`` carrying
+    the X window id). The scale between the two coordinate spaces is the
+    window's Qt width over its layout width.
+    """
+    from PySide6.QtGui import QCursor
+    exe = ccectl()
+    if exe is None or not _move_pointer_via_compositor(exe, point):
+        QCursor.setPos(point)
+        return False
+    return True
+
+
+def _move_pointer_via_compositor(exe, point):
+    import json
+    widget = _window_at(point)
+    if widget is None:
+        return False
+    try:
+        listing = subprocess.run(
+            [exe, "windows", "--json"], stdin=subprocess.DEVNULL,
+            capture_output=True, text=True, timeout=POINTER_TIMEOUT)
+    except (OSError, subprocess.SubprocessError):
+        return False
+    win_id = int(widget.winId())
+    entry = None
+    for line in listing.stdout.splitlines():
+        try:
+            data = json.loads(line)
+        except ValueError:
+            continue
+        if data.get("x11") == win_id:
+            entry = data
+            break
+    if entry is None or not entry.get("w"):
+        return False
+    origin = widget.geometry().topLeft()
+    scale = widget.geometry().width() / float(entry["w"])
+    x = entry["x"] + (point.x() - origin.x()) / scale
+    y = entry["y"] + (point.y() - origin.y()) / scale
+    try:
+        done = subprocess.run(
+            [exe, "pointer-move-to", f"{x:.1f}", f"{y:.1f}"],
+            stdin=subprocess.DEVNULL, capture_output=True,
+            timeout=POINTER_TIMEOUT)
+    except (OSError, subprocess.SubprocessError):
+        return False
+    return done.returncode == 0
+
+
+def _window_at(point):
+    """The process's toplevel widget whose frame holds ``point``, or None."""
+    for widget in HCViewRegions.windows():
+        if widget.frameGeometry().contains(point):
+            return widget
+    return None
+
+
 def stop():
     """Remove the poll installed by any earlier ``HCViewRegions.start``."""
     cb = getattr(hou.session, _SESSION_ATTR, None)