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