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