SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
python3.13libs/hc/hcsplithandles.py (11.8K)
1 import hou
2 from PySide6.QtCore import QEvent, QObject, QPoint, QRect, QRectF, Qt, QTimer
3 from PySide6.QtGui import QPainterPath, QRegion
4 from PySide6.QtWidgets import QWidget
5
6
7 HCSPLITHANDLE_THICKNESS = 20
8 HCSPLITHANDLE_LENGTH = 80
9 HCSPLITHANDLE_MIN_FRACTION = 0.05
10 HCSPLITHANDLE_MAX_FRACTION = 0.95
11 # How often handles re-check where their boundary actually is.
12 #
13 # This is what keeps them correct. Dragging a handle repins it directly, but a
14 # split moved any other way -- Houdini's own splitters, Contract/Expand Pane,
15 # maximizing a pane, a desktop change -- was seen by nothing, and the handle
16 # stayed at the old boundary indefinitely. Verified against a live session: with
17 # this timer stopped, a setSplitFraction left the handle stale a second later;
18 # restarted, it corrected on the next tick.
19 HCSPLITHANDLE_TRACK_MS = 250
20
21
22 class _SplitHandle(QWidget):
23 # Flat fills, no border-radius: the rounded shape comes from the window
24 # mask in _applyMask, not from the paint. Rounding the paint as well would
25 # antialias the curve against the native surface behind it -- which is the
26 # black these corners used to be -- leaving a dark fringe just inside the
27 # mask. Filling the whole rect instead means every pixel the mask keeps is
28 # fully the handle's colour.
29 REST_STYLE = "background: #3a3a3a;"
30 HOVER_STYLE = "background: #90EE90;"
31
32 def __init__(self, parent, hou_pane, horizontal_boundary, manager):
33 super().__init__(parent)
34 self.hou_pane = hou_pane
35 self.horizontal_boundary = horizontal_boundary
36 self.manager = manager
37 self.setObjectName(f"hc_split_handle_{hou_pane.id()}")
38 self.setCursor(Qt.SplitVCursor if horizontal_boundary else Qt.SplitHCursor)
39 self.setAttribute(Qt.WA_StyledBackground, True)
40 self.setStyleSheet(self.REST_STYLE)
41
42 # Give the handle its own X window, or it can never be clicked.
43 #
44 # Houdini draws its entire UI into one QOpenGLWidget, RE_WindowDrawable,
45 # backed by a real native QWindow. On xcb a native window owns mouse
46 # input across its whole area, and a plain QWidget sibling stacked above
47 # it is only *painted* there -- X still routes the press to the GL
48 # surface underneath, which is where Houdini hit-tests its own split
49 # bars. The handle looked and highlighted correctly while every click
50 # fell through to the splitter behind it.
51 #
52 # raise_() cannot fix this: it reorders Qt's widget stack, not X's.
53 # Neither can any amount of geometry work -- QApplication.widgetAt()
54 # already reported the handle as the widget under the cursor while real
55 # presses were being delivered to RE_WindowDrawable. Measured in a live
56 # session: with this attribute set the handle received 32 MouseMove
57 # events and a release through one drag; without it, zero events ever.
58 self.setAttribute(Qt.WA_NativeWindow, True)
59 self.winId() # create it now rather than lazily on first show
60 self._origin = None
61 self._start_fraction = None
62 self._parent_extent = None
63 self._drag_child = None
64 self._pending_fraction = None
65 self._throttle = QTimer(self)
66 self._throttle.setSingleShot(True)
67 self._throttle.setInterval(33)
68 self._throttle.timeout.connect(self._applyPending)
69
70 def event(self, event):
71 et = event.type()
72 if et == QEvent.Enter:
73 self.setStyleSheet(self.HOVER_STYLE)
74 self.repaint()
75 elif et == QEvent.Leave:
76 self.setStyleSheet(self.REST_STYLE)
77 self.repaint()
78 return super().event(event)
79
80 def resizeEvent(self, event):
81 super().resizeEvent(event)
82 self._applyMask()
83
84 def _applyMask(self):
85 """Shape the window so the rounded corners are really absent.
86
87 border-radius in the stylesheet only limits where the background is
88 *painted*; the widget stays rectangular. For an ordinary child widget
89 that is harmless, because it shares the parent's backing store and the
90 unpainted corner pixels are simply the parent's. This handle is a
91 WA_NativeWindow (it has to be, or X routes every click to the GL
92 surface underneath -- see __init__), and a native window has its own
93 surface with nothing of the parent in it, so those corners came out as
94 the surface's uninitialised black.
95
96 WA_TranslucentBackground does not fix it: X composites alpha for
97 top-level windows, and this is a child. Masking does, because it
98 removes the corner pixels from the window itself rather than trying to
99 blend them -- they stop being drawn and stop taking clicks. The cost is
100 that a mask is a hard region, so the curve is aliased where the painted
101 background was smooth.
102 """
103 radius = HCSPLITHANDLE_THICKNESS / 2
104 path = QPainterPath()
105 path.addRoundedRect(QRectF(self.rect()), radius, radius)
106 self.setMask(QRegion(path.toFillPolygon().toPolygon()))
107
108 def mousePressEvent(self, event):
109 if event.button() != Qt.LeftButton:
110 return
111 self._origin = event.globalPosition().toPoint()
112 self._drag_child = self.hou_pane.getSplitChild(0)
113 self._start_fraction = self._drag_child.getSplitFraction()
114 geom = self.hou_pane.qtScreenGeometry()
115 self._parent_extent = geom.height() if self.horizontal_boundary else geom.width()
116 event.accept()
117
118 def mouseMoveEvent(self, event):
119 if self._origin is None or not self._parent_extent:
120 return
121 delta = event.globalPosition().toPoint() - self._origin
122 pixel_delta = -delta.y() if self.horizontal_boundary else delta.x()
123 fraction_delta = pixel_delta / self._parent_extent
124 new_fraction = max(
125 HCSPLITHANDLE_MIN_FRACTION,
126 min(HCSPLITHANDLE_MAX_FRACTION, self._start_fraction + fraction_delta),
127 )
128 self._pending_fraction = new_fraction
129 if not self._throttle.isActive():
130 self._throttle.start()
131 event.accept()
132
133 def mouseReleaseEvent(self, event):
134 if self._pending_fraction is not None:
135 self._applyPending()
136 self._origin = None
137 self._start_fraction = None
138 self._parent_extent = None
139 self._drag_child = None
140 self._pending_fraction = None
141 self._throttle.stop()
142 self.manager._pin_all()
143
144 def isDragging(self):
145 return self._origin is not None
146
147 def _applyPending(self):
148 if self._pending_fraction is None or self._drag_child is None:
149 return
150 self._drag_child.setSplitFraction(self._pending_fraction)
151 self._pending_fraction = None
152 # Houdini relayouts its panes synchronously here -- qtScreenGeometry()
153 # already reports the new split when this returns, so pinning now is
154 # correct. (Measured over the bridge: setSplitFraction took a child
155 # from w=2459 to w=1299 within the same call.)
156 self.manager._pin(self)
157
158
159 class _ResizeFilter(QObject):
160 def __init__(self, manager):
161 super().__init__(manager.main)
162 self.manager = manager
163
164 def eventFilter(self, obj, event):
165 if event.type() == QEvent.Resize:
166 self.manager._pin_all()
167 return False
168
169
170 class HCSplitHandles:
171 def __init__(self):
172 self.main = hou.qt.mainWindow()
173 self.handles = []
174 self._filter = None
175 self._tracker = None
176
177 def show(self):
178 if self.handles:
179 return
180 for split in self._collectSplits():
181 orientation = self._orientation(split)
182 if orientation is None:
183 continue
184 handle = _SplitHandle(
185 self.main,
186 split,
187 horizontal_boundary=(orientation == "horizontal"),
188 manager=self,
189 )
190 handle.show()
191 handle.raise_()
192 self.handles.append(handle)
193 self._filter = _ResizeFilter(self)
194 self.main.installEventFilter(self._filter)
195
196 # A Resize on the main window is not the only thing that moves a
197 # boundary, so poll as well. _pin only touches a handle whose geometry
198 # actually changed, making an idle tick nearly free.
199 self._tracker = QTimer(self.main)
200 self._tracker.timeout.connect(self._pin_all)
201 self._tracker.start(HCSPLITHANDLE_TRACK_MS)
202
203 self._pin_all()
204
205 def hide(self):
206 if self._tracker is not None:
207 self._tracker.stop()
208 self._tracker.deleteLater()
209 self._tracker = None
210 for handle in self.handles:
211 handle.setParent(None)
212 handle.deleteLater()
213 self.handles = []
214 if self._filter is not None:
215 self.main.removeEventFilter(self._filter)
216 self._filter = None
217
218 def refresh(self):
219 self.hide()
220 self.show()
221
222 def isShowing(self):
223 return bool(self.handles)
224
225 def toggle(self):
226 if self.isShowing():
227 self.hide()
228 else:
229 self.show()
230
231 def _collectSplits(self):
232 splits = {}
233 for leaf in hou.ui.curDesktop().panes():
234 parent = leaf.getSplitParent()
235 while parent is not None:
236 splits[parent.id()] = parent
237 parent = parent.getSplitParent()
238 return splits.values()
239
240 def _orientation(self, split):
241 try:
242 c0 = split.getSplitChild(0)
243 c1 = split.getSplitChild(1)
244 except Exception:
245 return None
246 g0 = c0.qtScreenGeometry()
247 g1 = c1.qtScreenGeometry()
248 if g0.width() <= 0 or g0.height() <= 0 or g1.width() <= 0 or g1.height() <= 0:
249 return None
250 if abs(g0.y() - g1.y()) < 5 and g0.width() > 0:
251 return "vertical"
252 if abs(g0.x() - g1.x()) < 5 and g0.height() > 0:
253 return "horizontal"
254 return None
255
256 def _pin_all(self):
257 for handle in self.handles:
258 # The handle being dragged pins itself from _applyPending, on the
259 # throttle; a tracker tick landing between those would apply the
260 # same result twice for no reason.
261 if handle.isDragging():
262 continue
263 self._pin(handle)
264
265 def _pin(self, handle):
266 split = handle.hou_pane
267 try:
268 c0 = split.getSplitChild(0)
269 c1 = split.getSplitChild(1)
270 except Exception:
271 handle.hide()
272 return
273 g0 = c0.qtScreenGeometry()
274 g1 = c1.qtScreenGeometry()
275 if g0.width() <= 0 or g1.width() <= 0:
276 handle.hide()
277 return
278 split_geom = split.qtScreenGeometry()
279 if handle.horizontal_boundary:
280 y_screen = (g0.y() + g0.height() + g1.y()) // 2
281 x_screen = split_geom.x() + split_geom.width() // 2
282 center = self.main.mapFromGlobal(QPoint(x_screen, y_screen))
283 rect = QRect(
284 center.x() - HCSPLITHANDLE_LENGTH // 2,
285 center.y() - HCSPLITHANDLE_THICKNESS // 2,
286 HCSPLITHANDLE_LENGTH,
287 HCSPLITHANDLE_THICKNESS,
288 )
289 else:
290 x_screen = (g0.x() + g0.width() + g1.x()) // 2
291 y_screen = split_geom.y() + split_geom.height() // 2
292 center = self.main.mapFromGlobal(QPoint(x_screen, y_screen))
293 rect = QRect(
294 center.x() - HCSPLITHANDLE_THICKNESS // 2,
295 center.y() - HCSPLITHANDLE_LENGTH // 2,
296 HCSPLITHANDLE_THICKNESS,
297 HCSPLITHANDLE_LENGTH,
298 )
299
300 # Called on a timer, so do nothing when nothing moved: re-raising every
301 # tick churns the widget stack and repaints for no reason.
302 if handle.geometry() == rect and handle.isVisible():
303 return
304 handle.setGeometry(rect)
305 handle.show()
306 handle.raise_()