SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
python3.13libs/hc/hcviewregions.py (10.8K)
1 """Tell the compositor where Houdini's view panes are.
2
3 Under Xwayland a two-finger swipe never reaches Houdini as a trackpad
4 gesture (Qt's X11 backend leaves the pixel deltas empty), so ``cce-fx``
5 emulates the view tool instead: for every app in its ``touchpad_view_apps``
6 it turns a swipe into a Space + button drag on the window. That is right
7 over a 3D viewport or a network editor and wrong everywhere else -- the
8 parameter editor, the geometry spreadsheet and every other pane want the
9 swipe as the plain scroll it was, and got nothing at all.
10
11 So this module publishes the rectangles of the panes that want the drag,
12 per top-level window, through ``ccectl touchpad-view-regions``. The
13 compositor confines the drag to them and passes a swipe anywhere else
14 through untouched. Rectangles are window-local pixels as Houdini measures
15 them, which for an X11 window under ``xwayland_hidpi`` are exactly the
16 compositor's surface coordinates; the window is named by its X11 id, the
17 one identifier the two sides share.
18
19 The poll runs on Houdini's event loop and only talks to the compositor
20 when a rectangle changed, so its steady-state cost is a few HOM calls
21 every quarter second. Without ``ccectl`` on ``PATH`` (or a compositor
22 that does not know the command) it is inert: every publish is
23 fire-and-forget and errors are dropped.
24
25 Fire-and-forget still has to reap. ``ccectl`` answers in ten milliseconds,
26 but a child nobody waits on stays a zombie under houdini-bin until
27 something calls ``waitpid`` -- and CPython only does that for a dropped
28 ``Popen`` when the *next* ``Popen`` is made, which here is the next
29 rectangle change, minutes later. So publishes are kept and polled on the
30 tick (never waited on: the main thread must not block on the compositor's
31 socket), and one that outlives ``PUBLISH_TIMEOUT`` is killed rather than
32 left to pile up behind a stuck compositor.
33 """
34 import shutil
35 import subprocess
36 import time
37
38 import hou
39
40 #: Pane tab types that want the emulated view drag. Everything else gets
41 #: the swipe as a scroll.
42 VIEW_TAB_TYPES = ("SceneViewer", "NetworkEditor")
43 #: Seconds between polls of the pane layout.
44 POLL_INTERVAL = 0.25
45 #: Seconds a ``ccectl`` child may run before it is killed. It normally
46 #: returns in ten milliseconds; this only matters with a hung compositor.
47 PUBLISH_TIMEOUT = 5.0
48 #: Where the running callback is kept, so a reload can find and remove the
49 #: one installed by the module it is replacing (see ``HCSession.reloadHC``).
50 _SESSION_ATTR = "_hc_view_regions_callback"
51
52
53 def ccectl():
54 """Path of the compositor control client, or None when there is none."""
55 return shutil.which("ccectl")
56
57
58 #: Seconds to wait on the two ``ccectl`` calls a pointer move makes. These
59 #: are waited on, unlike the region publishes: the caller wants the pointer
60 #: moved before it returns, and the alternative is a warp nobody sees.
61 POINTER_TIMEOUT = 1.0
62
63
64 def move_pointer(point):
65 """Put the pointer at a Qt global ``point``, and say whether it moved.
66
67 ``QCursor.setPos`` is not enough under Xwayland: it moves X's idea of the
68 pointer, so Qt and ``hou.ui.paneUnderCursor`` both report the new spot,
69 but the cursor on screen stays where it was and the next real motion
70 snaps X back to it. Only the compositor moves the one the user sees, so
71 this goes through ``ccectl pointer-move-to`` when there is a compositor,
72 and falls back to the X warp otherwise.
73
74 ``ccectl`` speaks layout pixels, and the compositor pans the desktop
75 under Houdini, so the window's layout position is read fresh each time
76 from ``ccectl windows --json`` (one line per window, ``x11`` carrying
77 the X window id). The scale between the two coordinate spaces is the
78 window's Qt width over its layout width.
79 """
80 from PySide6.QtGui import QCursor
81 exe = ccectl()
82 if exe is None or not _move_pointer_via_compositor(exe, point):
83 QCursor.setPos(point)
84 return False
85 return True
86
87
88 def _move_pointer_via_compositor(exe, point):
89 import json
90 widget = _window_at(point)
91 if widget is None:
92 return False
93 try:
94 listing = subprocess.run(
95 [exe, "windows", "--json"], stdin=subprocess.DEVNULL,
96 capture_output=True, text=True, timeout=POINTER_TIMEOUT)
97 except (OSError, subprocess.SubprocessError):
98 return False
99 win_id = int(widget.winId())
100 entry = None
101 for line in listing.stdout.splitlines():
102 try:
103 data = json.loads(line)
104 except ValueError:
105 continue
106 if data.get("x11") == win_id:
107 entry = data
108 break
109 if entry is None or not entry.get("w"):
110 return False
111 origin = widget.geometry().topLeft()
112 scale = widget.geometry().width() / float(entry["w"])
113 x = entry["x"] + (point.x() - origin.x()) / scale
114 y = entry["y"] + (point.y() - origin.y()) / scale
115 try:
116 done = subprocess.run(
117 [exe, "pointer-move-to", f"{x:.1f}", f"{y:.1f}"],
118 stdin=subprocess.DEVNULL, capture_output=True,
119 timeout=POINTER_TIMEOUT)
120 except (OSError, subprocess.SubprocessError):
121 return False
122 return done.returncode == 0
123
124
125 def _window_at(point):
126 """The process's toplevel widget whose frame holds ``point``, or None."""
127 for widget in HCViewRegions.windows():
128 if widget.frameGeometry().contains(point):
129 return widget
130 return None
131
132
133 def stop():
134 """Remove the poll installed by any earlier ``HCViewRegions.start``."""
135 cb = getattr(hou.session, _SESSION_ATTR, None)
136 if cb is None:
137 return
138 try:
139 hou.ui.removeEventLoopCallback(cb)
140 except Exception:
141 pass
142 setattr(hou.session, _SESSION_ATTR, None)
143
144
145 class HCViewRegions:
146 """Publishes the view-pane rectangles of every Houdini window."""
147
148 def __init__(self):
149 self._exe = ccectl()
150 self._last = {}
151 self._next = 0.0
152 #: ``[(Popen, monotonic start), ...]`` of publishes not yet reaped.
153 self._pending = []
154
155 def start(self):
156 """Install the poll; a no-op without ``ccectl``. Returns whether it ran."""
157 stop()
158 if not self._exe:
159 return False
160 self._last = {}
161 hou.ui.addEventLoopCallback(self._tick)
162 setattr(hou.session, _SESSION_ATTR, self._tick)
163 return True
164
165 def _tick(self):
166 now = time.monotonic()
167 if now < self._next:
168 return
169 self._next = now + POLL_INTERVAL
170 self.reap(now)
171 try:
172 regions = self.collect()
173 except Exception:
174 # A pane mid-teardown can raise from any of the calls below; the
175 # next tick sees a settled layout.
176 return
177 for win_id, rects in regions.items():
178 if self._last.get(win_id) != rects:
179 self.publish(win_id, rects)
180 # A window that has gone is forgotten by the compositor with it;
181 # nothing to send.
182 self._last = regions
183
184 def collect(self):
185 """``{x11 window id: [(x, y, w, h), ...]}`` for every visible window.
186
187 Rectangles are in the window's own pixels. Every toplevel the process
188 shows is listed, most with no rectangles at all: the compositor
189 gives a window it has heard nothing about the whole-window drag, and
190 the HC Panel, HC Status, a floating settings tab and every dialog are
191 separate X11 windows of the same app -- so a swipe over any of them
192 was a drag too, and a list only selected the row under the pointer
193 instead of scrolling. Popups and tooltips are skipped; they come and
194 go faster than the poll.
195 """
196 out = {}
197 for widget in self.windows():
198 out[int(widget.winId())] = []
199 for tab in hou.ui.paneTabs():
200 if tab.type().name() not in VIEW_TAB_TYPES or not tab.isCurrentTab():
201 continue
202 panel = tab.pane().floatingPanel()
203 widget = hou.qt.floatingPanelWindow(panel) if panel else hou.qt.mainWindow()
204 widget = widget.window()
205 if not widget.isVisible():
206 continue
207 g = tab.qtScreenGeometry()
208 origin = widget.geometry().topLeft()
209 out.setdefault(int(widget.winId()), []).append(
210 (g.x() - origin.x(), g.y() - origin.y(), g.width(), g.height()))
211 return {k: sorted(v) for k, v in out.items()}
212
213 @staticmethod
214 def windows():
215 """The process's visible toplevel windows, popups and tooltips aside."""
216 from PySide6.QtCore import Qt
217 from PySide6.QtWidgets import QApplication
218 skip = (Qt.Popup, Qt.ToolTip, Qt.SplashScreen)
219 found = []
220 for widget in QApplication.topLevelWidgets():
221 if not widget.isVisible() or widget.windowHandle() is None:
222 continue
223 if widget.windowFlags() & Qt.WindowType_Mask in skip:
224 continue
225 # Houdini keeps some fifty 0x0 placeholder toplevels "visible";
226 # nothing can be swiped over them.
227 if widget.width() <= 0 or widget.height() <= 0:
228 continue
229 found.append(widget)
230 return found
231
232 @staticmethod
233 def wire(rects):
234 """The rectangle arguments for one window.
235
236 No rectangles has to mean "drag nowhere", and the compositor's
237 ``clear`` word means the opposite -- back to the whole window. A
238 rectangle of no area is inside nothing (the test is half-open), so
239 it is the wire form of an empty list.
240 """
241 if not rects:
242 return ["0,0,0,0"]
243 return [",".join(str(int(v)) for v in r) for r in rects]
244
245 def publish(self, win_id, rects):
246 """Start one ``ccectl`` call; it is reaped by a later ``reap``."""
247 args = [self._exe, "touchpad-view-regions", f"x11:{win_id}"] + self.wire(rects)
248 try:
249 proc = subprocess.Popen(
250 args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
251 stderr=subprocess.DEVNULL)
252 except OSError:
253 return
254 self._pending.append((proc, time.monotonic()))
255
256 def reap(self, now=None):
257 """Collect finished publishes without blocking; kill the overdue.
258
259 ``poll`` is a ``waitpid(WNOHANG)``, so a finished child is gone
260 from the process table after this and a running one is left alone.
261 A killed child is still running as far as this call is concerned
262 and is collected on the next one. Returns how many are still out.
263 """
264 if now is None:
265 now = time.monotonic()
266 keep = []
267 for proc, started in self._pending:
268 if proc.poll() is not None:
269 continue
270 if now - started > PUBLISH_TIMEOUT:
271 try:
272 proc.kill()
273 except OSError:
274 pass
275 keep.append((proc, started))
276 self._pending = keep
277 return len(keep)