SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
python3.13libs/hc/hcwidgets.py (13.7K)
1 import hou
2 import traceback
3 from fuzzyfinder import fuzzyfinder
4 from importlib import reload
5 from PySide6 import QtWidgets
6 from PySide6.QtCore import QEvent, Qt
7
8 class HCWidgets:
9
10 class Button(QtWidgets.QPushButton):
11 def __init__(self, text, parent=None):
12 super().__init__(text, parent)
13 self.setStyleSheet("text-align: left; padding: 0 0 0 0")
14 self.setFlat(1)
15 self.setFixedSize(120, 16)
16
17
18 class Menu(QtWidgets.QPushButton):
19 def __init__(self, label, parent=None):
20 super().__init__(label, parent)
21 self.setStyleSheet("text-align: left; padding: 0 0 0 0")
22 self.setFlat(1)
23
24
25 class ControlRow(QtWidgets.QWidget):
26 """A panel row for a toggle or choice command: label left, control right.
27
28 The control shows the command's current state and can be worked in
29 place -- ticking the checkbox or picking from the dropdown runs the
30 command and leaves the panel open. Clicks on the label fall through to
31 the list, so the row still selects and runs like a plain entry.
32
33 `on_change(bound, fn)` is the dialog's hook: it runs `fn` with error
34 reporting and then refreshes every row, since one command can change
35 another's state (Bars flips every bar).
36 """
37
38 OBJECT_NAME = "hc_panel_row"
39
40 def __init__(self, bound, on_change, parent=None):
41 super().__init__(parent)
42 self.bound = bound
43 self._on_change = on_change
44 self._extra = None
45 # Transparent, so the list's selection highlight shows through.
46 self.setObjectName(self.OBJECT_NAME)
47 self.setStyleSheet(f"#{self.OBJECT_NAME}, #{self.OBJECT_NAME} QLabel"
48 " { background: transparent; }")
49
50 layout = QtWidgets.QHBoxLayout(self)
51 layout.setContentsMargins(4, 0, 4, 0)
52 self.label = QtWidgets.QLabel(bound.label)
53 layout.addWidget(self.label, 1)
54
55 if bound.kind == "toggle":
56 self.control = QtWidgets.QCheckBox()
57 # clicked, not toggled: refresh() sets the box programmatically
58 # and must not run the command again.
59 self.control.clicked.connect(self._clicked)
60 else:
61 self.control = QtWidgets.QComboBox()
62 for text, value in bound.choices:
63 self.control.addItem(text, value)
64 self.control.activated.connect(self._activated)
65 # Keyboard focus stays in the filter line; the controls are worked
66 # with the mouse or via Enter on the row.
67 self.control.setFocusPolicy(Qt.NoFocus)
68 layout.addWidget(self.control, 0)
69
70 def _clicked(self, checked):
71 self._on_change(self.bound, self.bound)
72
73 def _activated(self, index):
74 value = self.control.itemData(index)
75 self._on_change(self.bound, lambda: self.bound.set(value))
76
77 def open(self):
78 """What Enter does on this row: pop the dropdown, or run the toggle."""
79 if isinstance(self.control, QtWidgets.QComboBox):
80 self.control.showPopup()
81 return False
82 return True
83
84 def refresh(self):
85 """Re-read the command's state into the control.
86
87 A getter that raises disables the control and shows the error as
88 the tooltip, instead of leaving a checkbox that lies.
89 """
90 try:
91 value = self.bound.state()
92 except Exception as e:
93 traceback.print_exc()
94 self.control.setEnabled(False)
95 self.setToolTip(f"{type(e).__name__}: {e}")
96 return
97 self.control.setEnabled(True)
98 self.setToolTip("")
99 self.control.blockSignals(True)
100 try:
101 if isinstance(self.control, QtWidgets.QCheckBox):
102 self.control.setChecked(bool(value))
103 else:
104 self._select(value)
105 finally:
106 self.control.blockSignals(False)
107
108 def _select(self, value):
109 combo = self.control
110 if self._extra is not None:
111 combo.removeItem(self._extra)
112 self._extra = None
113 index = combo.findData(value)
114 if index < 0 and value is not None:
115 # A value outside the declared choices -- a shading mode
116 # picked from Houdini's own menu, say. Show it rather than
117 # a blank box; picking it again is a no-op.
118 combo.addItem(str(value), value)
119 index = self._extra = combo.count() - 1
120 combo.setCurrentIndex(index)
121
122
123 class SelectionDialog(QtWidgets.QDialog):
124 def __init__(self, window_title, list_dict, anchor_geometry=None):
125 super().__init__(hou.qt.mainWindow())
126 self.resize(900, 600)
127 self.setWindowTitle(window_title)
128 self.setWindowFlags(Qt.Tool | Qt.WindowStaysOnTopHint)
129 self._anchor_geometry = anchor_geometry
130
131 self.list = self.List()
132 self.list_dict = list_dict
133 self.list_items = []
134 self.rows = {}
135 for item in list_dict:
136 self.list_items.append(item)
137 self.populate(self.list_items)
138 self.list.setIndex(0)
139
140 self.list.itemClicked.connect(self.execute)
141 self.list.setSelectionMode(QtWidgets.QListWidget.SingleSelection)
142
143 """ Input line """
144 self.input_line = QtWidgets.QLineEdit()
145 self.input_line.returnPressed.connect(self.execute)
146 self.input_line.textEdited.connect(self.list.filter)
147 for widget in (self, self.list, self.input_line):
148 widget.installEventFilter(self)
149
150 """ Layout """
151 self.layout = QtWidgets.QVBoxLayout()
152 self.layout.addWidget(self.list)
153 self.layout.addWidget(self.input_line)
154 self.setLayout(self.layout)
155
156 """ Do this last """
157 self._positionNearAnchor()
158 self.input_line.setFocus()
159
160 def _positionNearAnchor(self):
161 if self._anchor_geometry is None:
162 return
163 anchor = self._anchor_geometry
164 frame = self.frameGeometry()
165 x = int(anchor.x() + (anchor.width() - frame.width()) / 2)
166 y = int(anchor.y() + (anchor.height() - frame.height()) / 2)
167 self.move(x, y)
168
169 def closeEvent(self, event):
170 self.setParent(None)
171
172 # Keys the dialog handles itself, even with focus in the line edit.
173 #
174 # Houdini runs its hotkey manager off Qt's ShortcutOverride event: a
175 # widget that accepts the override for a key gets the KeyPress, and
176 # one that does not sees Houdini claim the key first. QLineEdit
177 # accepts the override for printable characters, which is why typing
178 # into the filter works, but not for the arrows or Escape -- so the
179 # old keyPressEvent below the line edit never saw them and the panel
180 # neither moved its selection nor closed. Claiming the override here
181 # is what makes those keys reach the dialog.
182 def _navigation(self, event):
183 key = event.key()
184 ctrl = event.modifiers() == Qt.ControlModifier
185 if key == Qt.Key_Up or (ctrl and key == Qt.Key_P):
186 return self.list.selectPrev
187 if key == Qt.Key_Down or (ctrl and key == Qt.Key_N):
188 return self.list.selectNext
189 if key == Qt.Key_Escape:
190 return self.close
191 return None
192
193 def eventFilter(self, watched, event):
194 if event.type() == QEvent.ShortcutOverride:
195 if self._navigation(event) is not None:
196 event.accept()
197 return True
198 elif event.type() == QEvent.KeyPress:
199 action = self._navigation(event)
200 if action is not None:
201 action()
202 return True
203 return super().eventFilter(watched, event)
204
205 def keyPressEvent(self, event):
206 action = self._navigation(event)
207 if action is not None:
208 action()
209 return
210 super().keyPressEvent(event)
211
212 def execute(self, *args):
213 item = self.list.currentItem()
214 if item is None:
215 return
216 label = self.list.labelOf(item)
217 action = self.list_dict.get(label)
218 if action is None:
219 return
220 row = self.rows.get(label)
221 if row is not None and not row.open():
222 # A dropdown row: Enter opened the popup, the pick applies in
223 # place and the panel stays up.
224 return
225 self.accept()
226 self._run(label, action)
227
228 def _run(self, label, action):
229 # Without this, any exception raised by the command is swallowed by
230 # Qt's slot dispatch and the user just sees "nothing happens" -- the
231 # single worst thing to debug in this codebase. Surface it instead.
232 try:
233 action()
234 except Exception as e:
235 traceback.print_exc()
236 hou.ui.setStatusMessage(
237 f"{label}: {type(e).__name__}: {e}",
238 hou.severityType.Error,
239 )
240
241 def _runInPlace(self, bound, action):
242 """A control was worked: run, then show every row its new state."""
243 self._run(bound.label, action)
244 self.refreshStates()
245 self.input_line.setFocus()
246
247 def refreshStates(self):
248 for row in self.rows.values():
249 row.refresh()
250
251 def populate(self, item_list):
252 control_items = []
253 for label in item_list:
254 item = QtWidgets.QListWidgetItem()
255 item.setData(Qt.UserRole, label)
256 entry = self.list_dict.get(label)
257 kind = getattr(entry, "kind", "action")
258 if kind == "action":
259 # Plain entries stay plain text: a widget per row would
260 # make the Replace Node picker, with every node type in
261 # it, take seconds to open.
262 item.setText(label)
263 self.list.addItem(item)
264 continue
265 row = HCWidgets.ControlRow(entry, self._runInPlace)
266 self.list.addItem(item)
267 self.list.setItemWidget(item, row)
268 self.rows[label] = row
269 control_items.append((item, row))
270
271 # A control row is as tall as its control wants -- Houdini's
272 # stylesheet makes a checkbox 35px and a combo box 45px, and a
273 # fixed row height squashed both -- but never shorter than a
274 # plain text row, so the list does not ripple.
275 base = 0
276 for i in range(self.list.count()):
277 if self.list.item(i).text():
278 base = self.list.sizeHintForRow(i)
279 break
280 for item, row in control_items:
281 hint = row.sizeHint()
282 hint.setHeight(max(hint.height(), base))
283 item.setSizeHint(hint)
284 row.refresh()
285
286
287 class List(QtWidgets.QListWidget):
288
289 @staticmethod
290 def labelOf(item):
291 """The entry label. Rows with a control keep their text in
292 UserRole so the item's own text does not paint under the widget."""
293 label = item.data(Qt.UserRole)
294 return label if label is not None else item.text()
295
296 def allItems(self):
297 items = []
298 for i in range(self.count()):
299 items.append(self.item(i))
300 return items
301
302 def currentItem(self):
303 selected = self.selectedItems()
304 return selected[0] if selected else None
305
306 def visibleItems(self):
307 items = []
308 for i in range(self.count()):
309 item = self.item(i)
310 if not item.isHidden():
311 items.append(item)
312 return(items)
313
314 def filter(self, query):
315 all_items = self.allItems()
316 all_item_names = [self.labelOf(item) for item in all_items]
317 matches = set(fuzzyfinder(query, all_item_names))
318 for item in all_items:
319 item.setHidden(self.labelOf(item) not in matches)
320 self.setIndex(0)
321
322 def selectNext(self):
323 items = self.visibleItems()
324 if not items:
325 return
326 try:
327 current = self.selectedItems()[0]
328 index = items.index(current)
329 index = (index + 1) % len(items)
330 except (IndexError, ValueError):
331 index = 0
332 self.setIndex(index)
333
334 def selectPrev(self):
335 items = self.visibleItems()
336 if not items:
337 return
338 try:
339 current = self.selectedItems()[0]
340 index = items.index(current)
341 index = (index - 1) % len(items)
342 except (IndexError, ValueError):
343 index = len(items) - 1
344 self.setIndex(index)
345
346 def setIndex(self, index):
347 counter = 0
348 for item in self.visibleItems():
349 if counter == index:
350 self.setCurrentItem(item)
351 counter += 1