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

commit6e15d4e2879dc457daba40b63c1eba8536c2be49
parent77142a3456
authorLucas Galante <[email protected]>
date2026-09-14 12:55
hc: publish view-pane rectangles to the compositor

cce-fx emulates the view tool for a two-finger swipe over Houdini, since
a trackpad gesture never survives Xwayland, and it did so over the whole
window: the parameter editor only scrolled with Ctrl held. The
compositor can now confine that drag to rectangles the app names
(ccectl touchpad-view-regions), so hc.hcviewregions polls the pane
layout four times a second and publishes every visible SceneViewer and
NetworkEditor tab, per top-level window by X11 id, in window-local
pixels. Started from uiready.py, restarted by reloadHC, inert without
ccectl on PATH.

Verified in a cce-shadow session: with hou-control loaded, a finger
scroll over the parameter pane scrolls it, over the viewport and network
editor it still tumbles and pans.

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

 CLAUDE.md                          |   1 +
 python3.13libs/hc/hcsession.py     |   6 ++
 python3.13libs/hc/hcviewregions.py | 122 +++++++++++++++++++++++++++++++++++++
 python3.13libs/uiready.py          |  10 +++
 4 files changed, 139 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index e88e502..4837523 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -62,6 +62,7 @@ HC settings cannot live in Houdini's own **Edit > Preferences** window. That win
 
 These are recognized by Houdini's startup/event system by filename convention:
 - `uiready.py` — runs once when the UI is ready; instantiates `HCSession` and calls `reloadHotkeys` + `toggleStowbars`.
+- `hc/hcviewregions.py` — publishes the window-local rectangles of every SceneViewer and NetworkEditor tab to the user's compositor (`ccectl touchpad-view-regions x11:<id> ...`). `cce-fx` turns a two-finger swipe over Houdini into an emulated view drag (Space + button), because a trackpad gesture never survives Xwayland; the regions confine that drag to the view panes so the parameter editor and every other pane scroll normally. Started from `uiready.py`, restarted by `reloadHC()`, and inert without `ccectl` on `PATH`. Verified headlessly in a `cce-shadow` session by injecting `ccectl pointer-scroll ... finger` over each pane and diffing screenshots.
 - `nodegraphhooks.py` — Houdini's network-editor event hook. Implements `createEventHandler(uievent, pending_actions)` and dispatches `KeyboardEvent`s through a local `keymap` dict that calls into `HCNetworkEditor`. Return `(None, True)` to consume the event, `(None, False)` to let Houdini handle it.
 
 ### Scripts (`scripts/`)
diff --git a/python3.13libs/hc/hcsession.py b/python3.13libs/hc/hcsession.py
index 6377756..048b23d 100644
--- a/python3.13libs/hc/hcsession.py
+++ b/python3.13libs/hc/hcsession.py
@@ -485,6 +485,10 @@ class HCSession:
                 w.hide()
                 w.setParent(None)
                 w.deleteLater()
+        # The view-regions poll is a callback from the module about to be
+        # dropped; take it out first and start a fresh one below.
+        from .hcviewregions import stop as _stop_view_regions
+        _stop_view_regions()
         # Drop every hc.* module so the next import reloads from disk
         removed = [m for m in list(sys.modules) if m == "hc" or m.startswith("hc.")]
         for m in removed:
@@ -494,6 +498,8 @@ class HCSession:
         # Restore the status bar overlay (destroyed above)
         from .hcstatusbar import HCStatusBar
         HCStatusBar().show()
+        from .hcviewregions import HCViewRegions
+        HCViewRegions().start()
         # An open HC Settings tab holds a widget built from the class object of
         # the module we just dropped, so it would keep running pre-reload code.
         # reloadActiveInterface() re-runs the pypanel's onCreateInterface()
diff --git a/python3.13libs/hc/hcviewregions.py b/python3.13libs/hc/hcviewregions.py
new file mode 100644
index 0000000..3f0f421
--- /dev/null
+++ b/python3.13libs/hc/hcviewregions.py
@@ -0,0 +1,122 @@
+"""Tell the compositor where Houdini's view panes are.
+
+Under Xwayland a two-finger swipe never reaches Houdini as a trackpad
+gesture (Qt's X11 backend leaves the pixel deltas empty), so ``cce-fx``
+emulates the view tool instead: for every app in its ``touchpad_view_apps``
+it turns a swipe into a Space + button drag on the window. That is right
+over a 3D viewport or a network editor and wrong everywhere else -- the
+parameter editor, the geometry spreadsheet and every other pane want the
+swipe as the plain scroll it was, and got nothing at all.
+
+So this module publishes the rectangles of the panes that want the drag,
+per top-level window, through ``ccectl touchpad-view-regions``. The
+compositor confines the drag to them and passes a swipe anywhere else
+through untouched. Rectangles are window-local pixels as Houdini measures
+them, which for an X11 window under ``xwayland_hidpi`` are exactly the
+compositor's surface coordinates; the window is named by its X11 id, the
+one identifier the two sides share.
+
+The poll runs on Houdini's event loop and only talks to the compositor
+when a rectangle changed, so its steady-state cost is a few HOM calls
+every quarter second. Without ``ccectl`` on ``PATH`` (or a compositor
+that does not know the command) it is inert: every publish is
+fire-and-forget and errors are dropped.
+"""
+import shutil
+import subprocess
+import time
+
+import hou
+
+#: Pane tab types that want the emulated view drag. Everything else gets
+#: the swipe as a scroll.
+VIEW_TAB_TYPES = ("SceneViewer", "NetworkEditor")
+#: Seconds between polls of the pane layout.
+POLL_INTERVAL = 0.25
+#: Where the running callback is kept, so a reload can find and remove the
+#: one installed by the module it is replacing (see ``HCSession.reloadHC``).
+_SESSION_ATTR = "_hc_view_regions_callback"
+
+
+def ccectl():
+    """Path of the compositor control client, or None when there is none."""
+    return shutil.which("ccectl")
+
+
+def stop():
+    """Remove the poll installed by any earlier ``HCViewRegions.start``."""
+    cb = getattr(hou.session, _SESSION_ATTR, None)
+    if cb is None:
+        return
+    try:
+        hou.ui.removeEventLoopCallback(cb)
+    except Exception:
+        pass
+    setattr(hou.session, _SESSION_ATTR, None)
+
+
+class HCViewRegions:
+    """Publishes the view-pane rectangles of every Houdini window."""
+
+    def __init__(self):
+        self._exe = ccectl()
+        self._last = {}
+        self._next = 0.0
+
+    def start(self):
+        """Install the poll; a no-op without ``ccectl``. Returns whether it ran."""
+        stop()
+        if not self._exe:
+            return False
+        self._last = {}
+        hou.ui.addEventLoopCallback(self._tick)
+        setattr(hou.session, _SESSION_ATTR, self._tick)
+        return True
+
+    def _tick(self):
+        now = time.monotonic()
+        if now < self._next:
+            return
+        self._next = now + POLL_INTERVAL
+        try:
+            regions = self.collect()
+        except Exception:
+            # A pane mid-teardown can raise from any of the calls below; the
+            # next tick sees a settled layout.
+            return
+        for win_id, rects in regions.items():
+            if self._last.get(win_id) != rects:
+                self.publish(win_id, rects)
+        for win_id in set(self._last) - set(regions):
+            self.publish(win_id, [])
+        self._last = regions
+
+    def collect(self):
+        """``{x11 window id: [(x, y, w, h), ...]}`` for every window with a view pane.
+
+        Rectangles are in the window's own pixels. A window whose view panes
+        are all hidden gets an empty list, which the compositor treats as
+        "no drag anywhere" rather than "whole window".
+        """
+        out = {}
+        for tab in hou.ui.paneTabs():
+            if tab.type().name() not in VIEW_TAB_TYPES or not tab.isCurrentTab():
+                continue
+            panel = tab.pane().floatingPanel()
+            widget = hou.qt.floatingPanelWindow(panel) if panel else hou.qt.mainWindow()
+            widget = widget.window()
+            if not widget.isVisible():
+                continue
+            g = tab.qtScreenGeometry()
+            origin = widget.geometry().topLeft()
+            out.setdefault(int(widget.winId()), []).append(
+                (g.x() - origin.x(), g.y() - origin.y(), g.width(), g.height()))
+        return {k: sorted(v) for k, v in out.items()}
+
+    def publish(self, win_id, rects):
+        args = [self._exe, "touchpad-view-regions", f"x11:{win_id}"]
+        args += [",".join(str(int(v)) for v in r) for r in rects] or ["clear"]
+        try:
+            subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+        except OSError:
+            pass
diff --git a/python3.13libs/uiready.py b/python3.13libs/uiready.py
index b89bb2d..508cc06 100644
--- a/python3.13libs/uiready.py
+++ b/python3.13libs/uiready.py
@@ -50,6 +50,16 @@ _step("reloadHotkeys", hc_session.reloadHotkeys)
 _step("initializeNetworkEditorsDeferred", hc_session.initializeNetworkEditorsDeferred)
 _step("updateNodeColors", hc_session.updateNodeColors)
 
+
+def _startViewRegions():
+    from hc.hcviewregions import HCViewRegions
+    HCViewRegions().start()
+
+
+# Tells cce-fx which panes want a two-finger swipe as a view drag, so the
+# others scroll; see hc.hcviewregions. Inert without ccectl.
+_step("viewRegions", _startViewRegions)
+
 desktop_mode = HCSettings().desktopMode()
 
 if desktop_mode == "detached":