git.lucas.co / hou-control
SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git

python3.13libs/hc/hcsettings.py (24.6K)

  1 import copy
  2 import hou
  3 import json
  4 from pathlib import Path
  5 
  6 from PySide6.QtCore import QFileSystemWatcher, QRegularExpression, Qt
  7 from PySide6.QtGui import QRegularExpressionValidator
  8 from PySide6.QtWidgets import (
  9     QCheckBox,
 10     QComboBox,
 11     QDialogButtonBox,
 12     QDoubleSpinBox,
 13     QFormLayout,
 14     QGroupBox,
 15     QHBoxLayout,
 16     QLabel,
 17     QLineEdit,
 18     QPushButton,
 19     QScrollArea,
 20     QSlider,
 21     QSpinBox,
 22     QTabWidget,
 23     QVBoxLayout,
 24     QWidget,
 25 )
 26 
 27 from . import hcschema
 28 from .hcschema import DESKTOP_MODES
 29 
 30 # prefs() sits on the network editor's hot path -- nodegraphhooks reads it on
 31 # every UI event, and a single overlay update reads it several times over. Hold
 32 # the parsed file and re-read only when its mtime/size changes, so an external
 33 # edit (or the settings panel writing it) is still picked up immediately.
 34 _cache_stat = None
 35 _cache_prefs = None
 36 
 37 
 38 def parseHex(value):
 39     """'#rrggbb' (or 'rrggbb') -> an (r, g, b) tuple of floats, or None.
 40 
 41     None means "not a color", and every caller has to decide what to do about
 42     it rather than being handed a silent substitute.
 43     """
 44     text = str(value).strip().lstrip("#")
 45     if len(text) != 6:
 46         return None
 47     try:
 48         return tuple(int(text[i:i + 2], 16) / 255.0 for i in (0, 2, 4))
 49     except ValueError:
 50         return None
 51 
 52 
 53 def formatHex(color):
 54     """An (r, g, b) tuple or a hou.Color -> '#rrggbb'."""
 55     rgb = color.rgb() if hasattr(color, "rgb") else color
 56     return "#" + "".join(f"{max(0, min(255, round(c * 255))):02x}" for c in rgb)
 57 
 58 
 59 def _schemaDefaultRGB(path):
 60     """The declared default of a `color` setting, as an (r, g, b) tuple."""
 61     setting = hcschema.lookup(path)
 62     return parseHex(setting.default if setting else None) or (0.0, 0.0, 0.0)
 63 
 64 
 65 def colorsMatch(a, b, tolerance=1.0 / 255.0):
 66     """True if two hou.Colors are equal to within one 8-bit step.
 67 
 68     Node colors round-trip through float32, so the 0x60/255 we write does not
 69     come back bit-identical and an exact compare never matches.
 70     """
 71     return all(abs(x - y) <= tolerance for x, y in zip(a.rgb(), b.rgb()))
 72 
 73 
 74 def _dig(data, path):
 75     for key in path:
 76         if not isinstance(data, dict):
 77             return None
 78         data = data.get(key)
 79     return data
 80 
 81 
 82 class HCSettings:
 83     # Generated from hcschema.SCHEMA -- add settings there, not here.
 84     DEFAULTS = hcschema.defaults()
 85 
 86     def _path(self):
 87         base = hou.getenv("HC_PATH")
 88         if not base:
 89             return None
 90         return Path(base) / "hc_settings.json"
 91 
 92     @classmethod
 93     def _merged(cls, defaults, overrides):
 94         """Recursively overlay `overrides` onto `defaults`, returning a copy."""
 95         merged = dict(defaults)
 96         for key, value in overrides.items():
 97             base = merged.get(key)
 98             if isinstance(base, dict) and isinstance(value, dict):
 99                 merged[key] = cls._merged(base, value)
100             else:
101                 merged[key] = value
102         return merged
103 
104     def prefs(self):
105         """Settings from disk, overlaid on DEFAULTS.
106 
107         Callers index straight into nested sections (prefs()['keycam']['units']
108         ['delta_r']), so a key missing from the file must never produce a
109         missing key here -- unmerged reads used to raise AttributeError on None
110         in keycam.py and hcguides.py.
111         """
112         global _cache_stat, _cache_prefs
113 
114         path = self._path()
115         if path is None:
116             return copy.deepcopy(self.DEFAULTS)
117         try:
118             st = path.stat()
119         except OSError:
120             return copy.deepcopy(self.DEFAULTS)
121 
122         stat_key = (str(path), st.st_mtime_ns, st.st_size)
123         if _cache_stat == stat_key and _cache_prefs is not None:
124             return _cache_prefs
125 
126         try:
127             data = json.loads(path.read_text())
128         except (OSError, ValueError) as e:
129             print(f"[HCSettings] could not read {path}: {e}")
130             return copy.deepcopy(self.DEFAULTS)
131         if not isinstance(data, dict):
132             return copy.deepcopy(self.DEFAULTS)
133 
134         _cache_prefs = self._merged(self.DEFAULTS, data)
135         _cache_stat = stat_key
136         return _cache_prefs
137 
138     def prefsCopy(self):
139         """A deep copy of prefs(), safe to mutate before writing back."""
140         return copy.deepcopy(self.prefs())
141 
142     def get(self, key, default=None):
143         return self.prefs().get(key, default)
144 
145     def section(self, *path):
146         """A nested section by path, always a dict (never None).
147 
148         Replaces `prefs().get('keycam').get('units')` chains, which raise
149         AttributeError as soon as any level is absent.
150         """
151         node = self.prefs()
152         for key in path:
153             if not isinstance(node, dict):
154                 return {}
155             node = node.get(key)
156             if node is None:
157                 return {}
158         return node if isinstance(node, dict) else {}
159 
160     def set(self, key, value):
161         data = self.prefsCopy()
162         data[key] = value
163         return self.write(data)
164 
165     def write(self, data):
166         path = self._path()
167         if path is None:
168             return False
169         path.write_text(json.dumps(data, indent=4) + "\n")
170         return True
171 
172     # Values in effect when this session started. Held on hou.session, not a
173     # module global, because reloadHC re-imports this module and would lose a
174     # global -- and then every restart-only setting would read as unchanged.
175     _STARTUP_ATTR = "_hc_settings_at_startup"
176 
177     def captureStartupValues(self):
178         """Record the settings this session started with. uiready calls it
179         once the startup dialog in 123.py has had its say."""
180         snapshot = self.prefsCopy()
181         setattr(hou.session, self._STARTUP_ATTR, snapshot)
182         return snapshot
183 
184     def startupValues(self):
185         snapshot = getattr(hou.session, self._STARTUP_ATTR, None)
186         if snapshot is None:
187             snapshot = self.captureStartupValues()
188         return snapshot
189 
190     def restartPending(self):
191         """Paths of restart-only settings whose saved value differs from the
192         value this session started with -- what the panel's notice lists."""
193         current = self.prefs()
194         startup = self.startupValues()
195         pending = []
196         for path in hcschema.restart_paths():
197             was = _dig(startup, path)
198             if was is None:
199                 # A setting added to the schema after this session started
200                 # (a reload after an edit) is not a change the user made.
201                 was = hcschema.lookup(path).default
202             if _dig(current, path) != was:
203                 pending.append(path)
204         return pending
205 
206     def desktopMode(self):
207         return self.get("desktop_mode", self.DEFAULTS["desktop_mode"])
208 
209     def setDesktopMode(self, mode):
210         if mode not in DESKTOP_MODES:
211             raise ValueError(f"invalid desktop mode: {mode}")
212         self.set("desktop_mode", mode)
213 
214     def nodeGraph(self):
215         return self.section("node_graph")
216 
217     def keycam(self, *path):
218         return self.section("keycam", *path)
219 
220     def hcnetcursorEnabled(self):
221         return bool(self.nodeGraph().get("hcnetcursor"))
222 
223     def hcnetcursorColorHex(self):
224         return self.colorHex("node_graph", "hcnetcursor_color")
225 
226     def hcnetcursorMargin(self):
227         """Gap between the drawn cursor box and the nodes it frames, in units."""
228         setting = hcschema.lookup(("node_graph", "hcnetcursor_margin"))
229         try:
230             margin = float(self.nodeGraph().get("hcnetcursor_margin", setting.default))
231         except (TypeError, ValueError):
232             margin = setting.default
233         low, high = setting.range
234         return min(max(margin, low), high)
235 
236     def dropSwapEnabled(self):
237         """Dropping a node on another node's cell swaps their positions."""
238         return bool(self.nodeGraph().get("drop_swap", True))
239 
240     def gridSnapEnabled(self):
241         """Hard grid snapping: the wide snap radius and the post-action sweep."""
242         return bool(self.nodeGraph().get("grid_snap", True))
243 
244     def nodeColoringEnabled(self):
245         """Whether HC colors nodes at all.
246 
247         Governs the automatic half: the colour and tag OnCreated applies, and
248         the maintenance pass in HCSession.updateNodeColors(). Set Node Colors
249         and Reset Node Colors still work -- invoking those is asking for a
250         colour outright.
251         """
252         return bool(self.nodeGraph().get("node_coloring"))
253 
254     def nodeShape(self):
255         return self.nodeGraph().get("node_shape")
256 
257     def colorRGB(self, *path):
258         """A `color` setting as an (r, g, b) tuple, addressed by schema path.
259 
260         An unparseable value falls back to that setting's own declared default,
261         never to a hardcoded stand-in somewhere else -- node_color used to
262         answer with a blue-purple that appears nowhere in hcschema, so a typo
263         in the hex silently produced a color the settings panel never showed.
264         """
265         stored = self.section(*path[:-1]).get(path[-1])
266         return parseHex(stored) or _schemaDefaultRGB(path)
267 
268     def color(self, *path):
269         """A `color` setting as a hou.Color."""
270         return hou.Color(self.colorRGB(*path))
271 
272     def colorHex(self, *path):
273         """A `color` setting normalised to '#rrggbb'."""
274         return formatHex(self.colorRGB(*path))
275 
276     def nodeColorRGB(self):
277         return self.colorRGB("node_graph", "node_color")
278 
279     def nodeColor(self):
280         """The configured default node color as a hou.Color."""
281         return hou.Color(self.nodeColorRGB())
282 
283     def nodeColorHex(self):
284         """The configured default node color, normalised to '#rrggbb'.
285 
286         This is what gets recorded in a node's `hc_custom_color` userdata, so
287         it has to come from the same place as nodeColor().
288         """
289         return formatHex(self.nodeColorRGB())
290 
291 
292 class HCSettingsPanel(QWidget):
293     """The HC Settings form.
294 
295     Built as a plain QWidget, not a QDialog, because it is served to Houdini
296     through python_panels/hc_settings.pypanel: onCreateInterface() returns one
297     of these and Houdini reparents it into a PythonPanel pane tab. That makes
298     HC Settings a real pane tab type -- dockable, splittable, listed in the
299     pane tab menu next to Houdini's own panels -- rather than a floater
300     parented to the main window.
301 
302     Consequences of being a pane tab rather than a dialog: no window title (the
303     tab carries the label), no Qt.Tool flag, no fixed size (the pane decides),
304     and no Close button (the tab's own close does that).
305     """
306 
307     # Must match the interface name in python_panels/hc_settings.pypanel.
308     # There is no OBJECT_NAME any more: the old one existed so openSettings()
309     # could findChild() the dialog under the main window, and Houdini
310     # overwrites objectName on a widget it adopts into a pane tab anyway. The
311     # tab is found by its active interface name instead.
312     INTERFACE_NAME = "hc_settings"
313     SLIDER_STEPS = 1000
314 
315     def __init__(self, parent=None):
316         super().__init__(parent)
317         self.settings = HCSettings()
318 
319         # path tuple, e.g. ("keycam","guides","axis_size") -> widget
320         self._fields = {}
321         # path tuple -> the marker beside a restart-only setting's widget
322         self._restart_marks = {}
323 
324         self.tabs = QTabWidget()
325 
326         buttons = QDialogButtonBox(QDialogButtonBox.Save)
327         buttons.accepted.connect(self._save)
328 
329         # The notice under the tabs: hidden until a restart-only setting has
330         # been saved with a new value, then it names the settings and offers
331         # the restart.
332         self._restart_banner = QLabel()
333         self._restart_banner.setWordWrap(True)
334         self._restart_banner.setStyleSheet("color: #e0a040;")
335         self._restart_button = QPushButton("Restart Houdini")
336         self._restart_button.clicked.connect(self._restart)
337         notice = QHBoxLayout()
338         notice.addWidget(self._restart_banner, 1)
339         notice.addWidget(self._restart_button)
340 
341         layout = QVBoxLayout(self)
342         layout.addWidget(self.tabs)
343         layout.addLayout(notice)
344         layout.addWidget(buttons)
345 
346         self._watcher = QFileSystemWatcher(self)
347         self._watcher.fileChanged.connect(self._onFileChanged)
348         self._startWatching()
349         self._rebuild()
350 
351     def showEvent(self, event):
352         # Only re-arm the watcher here. As a dialog this also rebuilt the form,
353         # which was harmless because show() meant "opening". A pane tab gets a
354         # showEvent every time it becomes the current tab in its pane, and
355         # rebuilding there would throw away unsaved edits on every tab switch.
356         # The watcher runs whether or not the tab is visible, so an external
357         # edit is still picked up without this.
358         self._startWatching()
359         super().showEvent(event)
360 
361     def _startWatching(self):
362         path = self.settings._path()
363         if path is None:
364             return
365         if str(path) not in self._watcher.files():
366             self._watcher.addPath(str(path))
367 
368     def _onFileChanged(self, path):
369         # Some editors replace the file on save, which drops the watch.
370         self._startWatching()
371         self._rebuild()
372 
373 
374     """ Form builder """
375 
376 
377     def _rebuild(self):
378         """Build the form from the schema, filling in values from prefs().
379 
380         Walking the schema rather than the file means every declared setting
381         gets a control even when the file omits it.
382         """
383         self._fields.clear()
384         self._restart_marks.clear()
385         while self.tabs.count():
386             widget = self.tabs.widget(0)
387             self.tabs.removeTab(0)
388             if widget:
389                 widget.deleteLater()
390 
391         values = self.settings.prefs()
392 
393         scalars = {k: v for k, v in hcschema.SCHEMA.items()
394                    if isinstance(v, hcschema.Setting)}
395         sections = {k: v for k, v in hcschema.SCHEMA.items() if isinstance(v, dict)}
396 
397         if scalars:
398             self.tabs.addTab(self._createTabPage(scalars, values, ()), "General")
399         for key, section in sections.items():
400             page = self._createTabPage(section, values.get(key, {}), (key,))
401             self.tabs.addTab(page, hcschema.label_for(key))
402         self._updateRestartIndicators()
403 
404     RESTART_MARK = "\u27f3"  # the clockwise-arrow glyph beside restart-only rows
405 
406     def _updateRestartIndicators(self):
407         """Show which saved restart-only settings differ from this session's
408         startup values, on their rows and in the notice under the tabs."""
409         pending = self.settings.restartPending()
410         for path, mark in self._restart_marks.items():
411             if path in pending:
412                 mark.setText(f"{self.RESTART_MARK} restart required")
413                 mark.setStyleSheet("color: #e0a040; font-weight: bold;")
414             else:
415                 mark.setText(self.RESTART_MARK)
416                 mark.setStyleSheet("color: #808080;")
417         # Nothing under the tabs until a restart is actually owed; the row
418         # glyph and its tooltip carry the explanation the rest of the time.
419         if pending:
420             names = ", ".join(hcschema.label_for(path[-1], hcschema.lookup(path))
421                               for path in pending)
422             self._restart_banner.setText(
423                 f"{self.RESTART_MARK} Restart Houdini to apply: {names}")
424         self._restart_banner.setVisible(bool(pending))
425         self._restart_button.setVisible(bool(pending))
426 
427     def _restart(self):
428         from .hcsession import HCSession
429         HCSession().restartHoudini()
430 
431     def _createTabPage(self, schema, values, path):
432         scroll = QScrollArea()
433         scroll.setWidgetResizable(True)
434         content = QWidget()
435         layout = QVBoxLayout(content)
436         self._addSection(schema, values, layout, path)
437         layout.addStretch()
438         scroll.setWidget(content)
439         return scroll
440 
441     def _addSection(self, schema, values, parent_layout, path):
442         if not isinstance(values, dict):
443             values = {}
444 
445         scalars = {k: v for k, v in schema.items() if isinstance(v, hcschema.Setting)}
446         sections = {k: v for k, v in schema.items() if isinstance(v, dict)}
447 
448         # Ungrouped rows first, then one box per `group` in order of first
449         # appearance. The groups are headings only -- the field paths, and
450         # so the file, are the same as for a flat form.
451         groups = {}
452         for key, setting in scalars.items():
453             groups.setdefault(setting.group, {})[key] = setting
454         for name, members in groups.items():
455             if name is None:
456                 self._addForm(members, values, parent_layout, path)
457         for name, members in groups.items():
458             if name is not None:
459                 group = QGroupBox(name)
460                 group_layout = QVBoxLayout(group)
461                 self._addForm(members, values, group_layout, path)
462                 parent_layout.addWidget(group)
463 
464         for key, section in sections.items():
465             group = QGroupBox(hcschema.label_for(key))
466             group_layout = QVBoxLayout(group)
467             self._addSection(section, values.get(key, {}), group_layout, path + (key,))
468             parent_layout.addWidget(group)
469 
470     def _addForm(self, scalars, values, parent_layout, path):
471         if scalars:
472             form = QFormLayout()
473             parent_layout.addLayout(form)
474             for key, setting in scalars.items():
475                 value = values.get(key, setting.default)
476                 widget = self._makeWidget(setting, value)
477                 self._fields[path + (key,)] = widget
478                 label = hcschema.label_for(key, setting)
479                 tip = setting.help or ""
480                 if setting.restart:
481                     tip = (tip + " " if tip else "") + "Takes effect on next start."
482                 if tip:
483                     widget.setToolTip(tip)
484                 row = widget
485                 if setting.restart:
486                     # The widget stays the field; only the row gets a marker.
487                     row = QWidget()
488                     row_layout = QHBoxLayout(row)
489                     row_layout.setContentsMargins(0, 0, 0, 0)
490                     mark = QLabel()
491                     mark.setToolTip("Read only while Houdini starts.")
492                     row_layout.addWidget(widget, 1)
493                     row_layout.addWidget(mark)
494                     self._restart_marks[path + (key,)] = mark
495                 form.addRow(label + ":", row)
496 
497     def _makeWidget(self, setting, value):
498         """Widget from the declared kind -- never guessed from the value's type."""
499         kind = setting.kind
500 
501         if kind in ("bool", "flag"):
502             widget = QCheckBox()
503             widget.setChecked(bool(value))
504             return widget
505 
506         if kind == "slider":
507             return self._makeSliderWidget(setting, value)
508 
509         if kind == "float":
510             widget = QDoubleSpinBox()
511             widget.setDecimals(setting.decimals)
512             widget.setRange(-1e9, 1e9)
513             widget.setSingleStep(0.1)
514             widget.setValue(float(value))
515             return widget
516 
517         if kind == "int":
518             widget = QSpinBox()
519             widget.setRange(-1000000, 1000000)
520             widget.setValue(int(value))
521             return widget
522 
523         if kind == "color":
524             return self._makeColorWidget(setting, value)
525 
526         if kind == "choice":
527             widget = QComboBox()
528             for label, data in setting.choices:
529                 widget.addItem(label, data)
530             index = widget.findData(value)
531             if index < 0:
532                 # Preserve an unrecognised value rather than silently resetting.
533                 widget.addItem(str(value), value)
534                 index = widget.count() - 1
535             widget.setCurrentIndex(index)
536             return widget
537 
538         return QLineEdit(str(value))
539 
540     def _makeColorWidget(self, setting, value):
541         """A '#rrggbb' field with a swatch that opens Houdini's color editor.
542 
543         `color` was declared in the schema but had no branch here, so it fell
544         through to the bare QLineEdit at the bottom of _makeWidget -- the only
545         way to set the default node color was to type six hex digits, and a
546         typo was accepted and then silently replaced by a fallback.
547         """
548         rgb = parseHex(value) or parseHex(setting.default) or (0.0, 0.0, 0.0)
549 
550         container = QWidget()
551         layout = QHBoxLayout(container)
552         layout.setContentsMargins(0, 0, 0, 0)
553 
554         field = QLineEdit(formatHex(rgb))
555         # Permissive enough to type through: a partial hex is valid mid-edit,
556         # and _readWidget is what refuses to write an incomplete one.
557         field.setValidator(QRegularExpressionValidator(
558             QRegularExpression("#?[0-9A-Fa-f]{0,6}"), field))
559 
560         swatch = QPushButton()
561         swatch.setFixedWidth(44)
562         swatch.setToolTip("Pick a color")
563 
564         def _paint():
565             parsed = parseHex(field.text())
566             # No swatch at all while the text is not a color, so a half-typed
567             # or bad value is visible rather than showing a stale color.
568             swatch.setStyleSheet(
569                 f"background-color: {formatHex(parsed)}; border: 1px solid #202020;"
570                 if parsed else ""
571             )
572 
573         def _pick():
574             chosen = hou.ui.selectColor(hou.Color(parseHex(field.text()) or rgb))
575             if chosen is not None:  # None means the dialog was cancelled
576                 field.setText(formatHex(chosen))
577 
578         field.textChanged.connect(_paint)
579         swatch.clicked.connect(_pick)
580         _paint()
581 
582         layout.addWidget(swatch)
583         layout.addWidget(field, 1)
584 
585         container._hexfield = field
586         return container
587 
588     def _makeSliderWidget(self, setting, value):
589         """A QSlider + QDoubleSpinBox that stay in sync."""
590         vmin, vmax = setting.range
591         decimals = setting.decimals
592         steps = self.SLIDER_STEPS
593 
594         container = QWidget()
595         layout = QHBoxLayout(container)
596         layout.setContentsMargins(0, 0, 0, 0)
597 
598         slider = QSlider(Qt.Horizontal)
599         slider.setMinimum(0)
600         slider.setMaximum(steps)
601         slider.setPageStep(steps // 20)
602 
603         spin = QDoubleSpinBox()
604         spin.setDecimals(decimals)
605         spin.setRange(vmin, vmax)
606         spin.setSingleStep(round((vmax - vmin) / 100, decimals))
607         spin.setValue(float(value))
608 
609         span = (vmax - vmin) or 1.0
610 
611         def _onSliderChanged(pos):
612             spin.blockSignals(True)
613             spin.setValue(round(vmin + (pos / steps) * span, decimals))
614             spin.blockSignals(False)
615 
616         def _onSpinChanged(v):
617             slider.blockSignals(True)
618             slider.setValue(int((v - vmin) / span * steps))
619             slider.blockSignals(False)
620 
621         slider.valueChanged.connect(_onSliderChanged)
622         spin.valueChanged.connect(_onSpinChanged)
623         slider.setValue(int((float(value) - vmin) / span * steps))
624 
625         layout.addWidget(slider, 3)
626         layout.addWidget(spin, 1)
627 
628         container._spinbox = spin
629         return container
630 
631 
632     """ IO """
633 
634 
635     def _readWidget(self, setting, widget):
636         """Read a widget back in the JSON type the schema declares."""
637         if isinstance(widget, QCheckBox):
638             # `flag` settings round-trip as 0/1 ints, `bool` as true/false.
639             return int(widget.isChecked()) if setting.kind == "flag" else widget.isChecked()
640         if hasattr(widget, "_hexfield"):
641             # Never write an unparseable color to the file: nodeColor() would
642             # fall back to the default while the panel kept showing the bad
643             # text, so the scene and the form would disagree with no sign why.
644             parsed = parseHex(widget._hexfield.text())
645             return formatHex(parsed) if parsed else setting.default
646         if hasattr(widget, "_spinbox"):
647             return widget._spinbox.value()
648         if isinstance(widget, QDoubleSpinBox):
649             return widget.value()
650         if isinstance(widget, QSpinBox):
651             return widget.value()
652         if isinstance(widget, QComboBox):
653             data = widget.currentData()
654             return data if data is not None else widget.currentText()
655         if isinstance(widget, QLineEdit):
656             return widget.text()
657         return setting.default
658 
659     def _save(self):
660         data = self.settings.prefsCopy()
661         for path, widget in self._fields.items():
662             setting = hcschema.lookup(path)
663             if setting is None:
664                 continue
665             self._assign(data, path, self._readWidget(setting, widget))
666         self.settings.write(data)
667         # The file watcher rebuilds the form too, but not synchronously.
668         self._updateRestartIndicators()
669 
670     def _assign(self, data, path, value):
671         node = data
672         for key in path[:-1]:
673             if key not in node or not isinstance(node[key], dict):
674                 node[key] = {}
675             node = node[key]
676         node[path[-1]] = value