git.lucas.co / hou-control
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)