SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
settings: say which saved settings need a restart to take effect
Setting gains restart=True for values read only while Houdini starts:
desktop_mode and the two startup entries 123.py reads. uiready snapshots
the settings on hou.session (so reloadHC keeps it) and
HCSettings.restartPending() lists the restart-only paths whose saved
value differs from that snapshot.
The panel marks those rows with a glyph, turns it into a bold "restart
required" once the saved value differs, and replaces the static note
under the tabs with one naming the settings, next to a Restart Houdini
button that only appears then. check.py covers the flags and the
pending logic.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
python3.13libs/hc/hcschema.py | 30 ++++++++++--
python3.13libs/hc/hcsettings.py | 102 ++++++++++++++++++++++++++++++++++++++--
python3.13libs/uiready.py | 4 ++
tools/check.py | 23 +++++++++
4 files changed, 151 insertions(+), 8 deletions(-)
diff --git a/python3.13libs/hc/hcschema.py b/python3.13libs/hc/hcschema.py
index 5d16aaf..600e279 100644
--- a/python3.13libs/hc/hcschema.py
+++ b/python3.13libs/hc/hcschema.py
@@ -38,12 +38,17 @@ class Setting:
text -- line edit
choice -- combo box, needs `choices`
color -- "#rrggbb" line edit
+
+ restart marks a value that is read only while Houdini starts (uiready.py,
+ 123.py). The settings panel flags such rows and shows a restart notice
+ once the saved value differs from the one this session started with.
"""
- __slots__ = ("kind", "default", "label", "choices", "range", "decimals", "help")
+ __slots__ = ("kind", "default", "label", "choices", "range", "decimals",
+ "help", "restart")
def __init__(self, kind, default, label=None, choices=None, range=None,
- decimals=2, help=None):
+ decimals=2, help=None, restart=False):
self.kind = kind
self.default = default
self.label = label
@@ -51,6 +56,7 @@ class Setting:
self.range = range
self.decimals = decimals
self.help = help
+ self.restart = restart
# Nested dicts mirror the JSON. A dict value is a section; a Setting is a leaf.
@@ -58,15 +64,18 @@ SCHEMA = {
"desktop_mode": Setting(
"choice", "attached", label="Desktop Mode",
choices=tuple((m.title(), m) for m in DESKTOP_MODES),
- help="Takes effect on next start.",
+ restart=True,
),
"startup": {
"show_prompt": Setting(
"bool", True, label="Show Startup Prompt",
help="The 'Open last file?' dialog shown when Houdini starts with "
"no .hip. With it off, Houdini starts file-less.",
+ restart=True,
+ ),
+ "default_autosave_state": Setting(
+ "bool", True, label="Default Autosave State", restart=True,
),
- "default_autosave_state": Setting("bool", True, label="Default Autosave State"),
},
"keycam": {
"guides": {
@@ -146,6 +155,19 @@ def defaults(schema=None):
return out
+def restart_paths(schema=None, prefix=()):
+ """Tuple paths of every Setting declared restart=True."""
+ if schema is None:
+ schema = SCHEMA
+ out = []
+ for key, value in schema.items():
+ if isinstance(value, dict):
+ out.extend(restart_paths(value, prefix + (key,)))
+ elif value.restart:
+ out.append(prefix + (key,))
+ return out
+
+
def lookup(path, schema=None):
"""Return the Setting at a tuple path, or None if the path is not declared."""
node = schema if schema is not None else SCHEMA
diff --git a/python3.13libs/hc/hcsettings.py b/python3.13libs/hc/hcsettings.py
index 843bdb1..42e701d 100644
--- a/python3.13libs/hc/hcsettings.py
+++ b/python3.13libs/hc/hcsettings.py
@@ -71,6 +71,14 @@ def colorsMatch(a, b, tolerance=1.0 / 255.0):
return all(abs(x - y) <= tolerance for x, y in zip(a.rgb(), b.rgb()))
+def _dig(data, path):
+ for key in path:
+ if not isinstance(data, dict):
+ return None
+ data = data.get(key)
+ return data
+
+
class HCSettings:
# Generated from hcschema.SCHEMA -- add settings there, not here.
DEFAULTS = hcschema.defaults()
@@ -161,6 +169,32 @@ class HCSettings:
path.write_text(json.dumps(data, indent=4) + "\n")
return True
+ # Values in effect when this session started. Held on hou.session, not a
+ # module global, because reloadHC re-imports this module and would lose a
+ # global -- and then every restart-only setting would read as unchanged.
+ _STARTUP_ATTR = "_hc_settings_at_startup"
+
+ def captureStartupValues(self):
+ """Record the settings this session started with. uiready calls it
+ once the startup dialog in 123.py has had its say."""
+ snapshot = self.prefsCopy()
+ setattr(hou.session, self._STARTUP_ATTR, snapshot)
+ return snapshot
+
+ def startupValues(self):
+ snapshot = getattr(hou.session, self._STARTUP_ATTR, None)
+ if snapshot is None:
+ snapshot = self.captureStartupValues()
+ return snapshot
+
+ def restartPending(self):
+ """Paths of restart-only settings whose saved value differs from the
+ value this session started with -- what the panel's notice lists."""
+ current = self.prefs()
+ startup = self.startupValues()
+ return [path for path in hcschema.restart_paths()
+ if _dig(current, path) != _dig(startup, path)]
+
def desktopMode(self):
return self.get("desktop_mode", self.DEFAULTS["desktop_mode"])
@@ -263,15 +297,28 @@ class HCSettingsPanel(QWidget):
# path tuple, e.g. ("keycam","guides","axis_size") -> widget
self._fields = {}
+ # path tuple -> the marker beside a restart-only setting's widget
+ self._restart_marks = {}
self.tabs = QTabWidget()
buttons = QDialogButtonBox(QDialogButtonBox.Save)
buttons.accepted.connect(self._save)
+ # The notice under the tabs: idle it explains the marker, once a
+ # restart-only setting has been saved with a new value it names the
+ # settings and offers the restart.
+ self._restart_banner = QLabel()
+ self._restart_banner.setWordWrap(True)
+ self._restart_button = QPushButton("Restart Houdini")
+ self._restart_button.clicked.connect(self._restart)
+ notice = QHBoxLayout()
+ notice.addWidget(self._restart_banner, 1)
+ notice.addWidget(self._restart_button)
+
layout = QVBoxLayout(self)
layout.addWidget(self.tabs)
- layout.addWidget(QLabel("Some settings (e.g. Desktop Mode) take effect on next start."))
+ layout.addLayout(notice)
layout.addWidget(buttons)
self._watcher = QFileSystemWatcher(self)
@@ -312,6 +359,7 @@ class HCSettingsPanel(QWidget):
gets a control even when the file omits it.
"""
self._fields.clear()
+ self._restart_marks.clear()
while self.tabs.count():
widget = self.tabs.widget(0)
self.tabs.removeTab(0)
@@ -329,6 +377,36 @@ class HCSettingsPanel(QWidget):
for key, section in sections.items():
page = self._createTabPage(section, values.get(key, {}), (key,))
self.tabs.addTab(page, hcschema.label_for(key))
+ self._updateRestartIndicators()
+
+ RESTART_MARK = "\u27f3" # the clockwise-arrow glyph beside restart-only rows
+
+ def _updateRestartIndicators(self):
+ """Show which saved restart-only settings differ from this session's
+ startup values, on their rows and in the notice under the tabs."""
+ pending = self.settings.restartPending()
+ for path, mark in self._restart_marks.items():
+ if path in pending:
+ mark.setText(f"{self.RESTART_MARK} restart required")
+ mark.setStyleSheet("color: #e0a040; font-weight: bold;")
+ else:
+ mark.setText(self.RESTART_MARK)
+ mark.setStyleSheet("color: #808080;")
+ if pending:
+ names = ", ".join(hcschema.label_for(path[-1], hcschema.lookup(path))
+ for path in pending)
+ self._restart_banner.setText(
+ f"{self.RESTART_MARK} Restart Houdini to apply: {names}")
+ self._restart_banner.setStyleSheet("color: #e0a040;")
+ else:
+ self._restart_banner.setText(
+ f"Settings marked {self.RESTART_MARK} take effect on next start.")
+ self._restart_banner.setStyleSheet("color: #808080;")
+ self._restart_button.setVisible(bool(pending))
+
+ def _restart(self):
+ from .hcsession import HCSession
+ HCSession().restartHoudini()
def _createTabPage(self, schema, values, path):
scroll = QScrollArea()
@@ -355,9 +433,23 @@ class HCSettingsPanel(QWidget):
widget = self._makeWidget(setting, value)
self._fields[path + (key,)] = widget
label = hcschema.label_for(key, setting)
- if setting.help:
- widget.setToolTip(setting.help)
- form.addRow(label + ":", widget)
+ tip = setting.help or ""
+ if setting.restart:
+ tip = (tip + " " if tip else "") + "Takes effect on next start."
+ if tip:
+ widget.setToolTip(tip)
+ row = widget
+ if setting.restart:
+ # The widget stays the field; only the row gets a marker.
+ row = QWidget()
+ row_layout = QHBoxLayout(row)
+ row_layout.setContentsMargins(0, 0, 0, 0)
+ mark = QLabel()
+ mark.setToolTip("Read only while Houdini starts.")
+ row_layout.addWidget(widget, 1)
+ row_layout.addWidget(mark)
+ self._restart_marks[path + (key,)] = mark
+ form.addRow(label + ":", row)
for key, section in sections.items():
group = QGroupBox(hcschema.label_for(key))
@@ -535,6 +627,8 @@ class HCSettingsPanel(QWidget):
continue
self._assign(data, path, self._readWidget(setting, widget))
self.settings.write(data)
+ # The file watcher rebuilds the form too, but not synchronously.
+ self._updateRestartIndicators()
def _assign(self, data, path, value):
node = data
diff --git a/python3.13libs/uiready.py b/python3.13libs/uiready.py
index 508cc06..9535eed 100644
--- a/python3.13libs/uiready.py
+++ b/python3.13libs/uiready.py
@@ -46,6 +46,10 @@ def _step(label, fn, *args):
hc_session = HCSession()
+# What this session started with, so the settings panel can say which
+# restart-only settings have since been saved with a different value. After
+# 123.py, whose startup dialog may have just written desktop_mode.
+_step("captureStartupSettings", HCSettings().captureStartupValues)
_step("reloadHotkeys", hc_session.reloadHotkeys)
_step("initializeNetworkEditorsDeferred", hc_session.initializeNetworkEditorsDeferred)
_step("updateNodeColors", hc_session.updateNodeColors)
diff --git a/tools/check.py b/tools/check.py
index 0412e6d..83ac6e3 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -164,6 +164,29 @@ def check_settings():
check("widget kinds are declared, not guessed", widgets_come_from_declared_kinds)
+ def restart_flags():
+ """The panel's restart notice: settings read only at startup are
+ declared restart=True, and restartPending() compares the saved value
+ with the snapshot uiready captured."""
+ paths = hcschema.restart_paths()
+ for expected in (("desktop_mode",), ("startup", "show_prompt"),
+ ("startup", "default_autosave_state")):
+ assert expected in paths, f"{expected} not declared restart-only: {paths}"
+ assert all(hcschema.lookup(p).restart for p in paths)
+ assert not hcschema.lookup(("node_graph", "node_shape")).restart, \
+ "node_shape is read live by OnCreated.py"
+ settings.captureStartupValues()
+ assert settings.restartPending() == [], settings.restartPending()
+ snapshot = settings.startupValues()
+ snapshot["desktop_mode"] = "detached" if snapshot["desktop_mode"] == "attached" else "attached"
+ try:
+ assert settings.restartPending() == [("desktop_mode",)], settings.restartPending()
+ finally:
+ settings.captureStartupValues()
+ return f"{len(paths)} restart-only settings; pending tracks the startup snapshot"
+
+ check("restart-only settings", restart_flags)
+
def check_commands():
print("commands")