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