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

commit973dac541eb2b0fb3d1142697c348526c3585178
parentecddfa4e29
authorLucas Galante <[email protected]>
date2026-09-15 10:18
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")