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

commit883ef7881e4387554acb64a9680a91a050ff8002
parenta3b251e282
authorLucas Galante <[email protected]>
date2026-09-15 21:35
viewregions: publish every toplevel so a swipe over the HC Panel scrolls

The compositor gives a window it has heard nothing about the whole-window
view drag, and the HC Panel, HC Status, floating settings tabs and every
dialog are separate X11 windows with the same app id. A two-finger swipe
over the panel was therefore a Space+button drag, which only picked the
row under the pointer. Every visible toplevel of the process is now
published, most with a zero-area rectangle; `clear` cannot be used for
that, since to the compositor it means the whole window again. Houdini's
fifty-odd 0x0 placeholder toplevels are skipped.

Verified live: the compositor logs the panel's zero-area region within a
quarter second of it opening, and an injected 300px two-finger swipe over
it scrolled the list with the highlight unmoved and no view drag begun.

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

 CLAUDE.md                          |  2 +-
 python3.13libs/hc/hcviewregions.py | 56 ++++++++++++++++++++++++++++++++------
 tools/check.py                     | 25 +++++++++++++++++
 3 files changed, 73 insertions(+), 10 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a059c14..ae0f4ad 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -63,7 +63,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.
+- `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. Every visible toplevel of the process is published, most with a zero-area rectangle: a window the compositor has not heard about gets the whole-window drag, and the HC Panel, HC Status and every dialog are separate X11 windows of the same app (`clear` means whole-window too, so an empty list is sent as `0,0,0,0`). 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/hcviewregions.py b/python3.13libs/hc/hcviewregions.py
index 3f0f421..548398a 100644
--- a/python3.13libs/hc/hcviewregions.py
+++ b/python3.13libs/hc/hcviewregions.py
@@ -87,18 +87,25 @@ class HCViewRegions:
         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, [])
+        # A window that has gone is forgotten by the compositor with it;
+        # nothing to send.
         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".
+        """``{x11 window id: [(x, y, w, h), ...]}`` for every visible window.
+
+        Rectangles are in the window's own pixels. Every toplevel the process
+        shows is listed, most with no rectangles at all: the compositor
+        gives a window it has heard nothing about the whole-window drag, and
+        the HC Panel, HC Status, a floating settings tab and every dialog are
+        separate X11 windows of the same app -- so a swipe over any of them
+        was a drag too, and a list only selected the row under the pointer
+        instead of scrolling. Popups and tooltips are skipped; they come and
+        go faster than the poll.
         """
         out = {}
+        for widget in self.windows():
+            out[int(widget.winId())] = []
         for tab in hou.ui.paneTabs():
             if tab.type().name() not in VIEW_TAB_TYPES or not tab.isCurrentTab():
                 continue
@@ -113,9 +120,40 @@ class HCViewRegions:
                 (g.x() - origin.x(), g.y() - origin.y(), g.width(), g.height()))
         return {k: sorted(v) for k, v in out.items()}
 
+    @staticmethod
+    def windows():
+        """The process's visible toplevel windows, popups and tooltips aside."""
+        from PySide6.QtCore import Qt
+        from PySide6.QtWidgets import QApplication
+        skip = (Qt.Popup, Qt.ToolTip, Qt.SplashScreen)
+        found = []
+        for widget in QApplication.topLevelWidgets():
+            if not widget.isVisible() or widget.windowHandle() is None:
+                continue
+            if widget.windowFlags() & Qt.WindowType_Mask in skip:
+                continue
+            # Houdini keeps some fifty 0x0 placeholder toplevels "visible";
+            # nothing can be swiped over them.
+            if widget.width() <= 0 or widget.height() <= 0:
+                continue
+            found.append(widget)
+        return found
+
+    @staticmethod
+    def wire(rects):
+        """The rectangle arguments for one window.
+
+        No rectangles has to mean "drag nowhere", and the compositor's
+        ``clear`` word means the opposite -- back to the whole window. A
+        rectangle of no area is inside nothing (the test is half-open), so
+        it is the wire form of an empty list.
+        """
+        if not rects:
+            return ["0,0,0,0"]
+        return [",".join(str(int(v)) for v in r) for r in rects]
+
     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"]
+        args = [self._exe, "touchpad-view-regions", f"x11:{win_id}"] + self.wire(rects)
         try:
             subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
         except OSError:
diff --git a/tools/check.py b/tools/check.py
index f278b97..d64ea31 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -1790,6 +1790,30 @@ def check_cursor_image():
     check("cursor settings", settings_declared)
 
 
+def check_view_regions():
+    """What hcviewregions sends the compositor for one window."""
+    print("view regions")
+    from hc.hcviewregions import HCViewRegions
+
+    def empty_is_drag_nowhere():
+        """No view panes must not be sent as `clear`: to the compositor that
+        word restores the whole-window drag, which is what turned a swipe
+        over the HC Panel into a row pick."""
+        args = HCViewRegions.wire([])
+        assert args == ["0,0,0,0"], args
+        assert "clear" not in args
+        return "zero-area rect, not clear"
+
+    check("no panes sends a zero-area rect", empty_is_drag_nowhere)
+
+    def rects_are_integers():
+        args = HCViewRegions.wire([(10.6, 20.2, 300.9, 400.1), (0, 0, 5, 5)])
+        assert args == ["10,20,300,400", "0,0,5,5"], args
+        return "x,y,w,h per rect"
+
+    check("rect wire format", rects_are_integers)
+
+
 def check_startup_script():
     """scripts/123.py reads hc_settings.json without importing hc.
 
@@ -1879,6 +1903,7 @@ def main():
     check_current_node()
     check_disable_switches()
     check_cursor_image()
+    check_view_regions()
     check_startup_script()
     print(f"\n{passed} passed, {failed} failed")
     return 1 if failed else 0