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