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

commitbd52603652bc92b066f1e5426533e0a6994ce33a
parente0ab0e3cab
authorLucas Galante <[email protected]>
date2026-09-15 21:06
settings: group the Node Graph tab into Nodes, Navigation and Grid

Setting gains a `group` heading the panel draws rows under. It is visual
only: the JSON keys stay flat, so every reader of node_graph.* is untouched.
A nested schema dict would have grouped the panel too, but it renames the
keys in the file.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 python3.13libs/hc/hcschema.py   | 47 ++++++++++++++++++++++++-----------------
 python3.13libs/hc/hcsettings.py | 29 +++++++++++++++++++------
 2 files changed, 51 insertions(+), 25 deletions(-)

diff --git a/python3.13libs/hc/hcschema.py b/python3.13libs/hc/hcschema.py
index 47a8238..b7a48b1 100644
--- a/python3.13libs/hc/hcschema.py
+++ b/python3.13libs/hc/hcschema.py
@@ -42,13 +42,18 @@ class Setting:
     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.
+
+    group is a heading the settings panel draws the row under. It is purely
+    visual: the JSON stays flat, so readers of e.g. node_graph.grid_x_step
+    are untouched. A nested dict in SCHEMA would have grouped the panel too,
+    but it also renames every key in the file.
     """
 
     __slots__ = ("kind", "default", "label", "choices", "range", "decimals",
-                 "help", "restart")
+                 "help", "restart", "group")
 
     def __init__(self, kind, default, label=None, choices=None, range=None,
-                 decimals=2, help=None, restart=False):
+                 decimals=2, help=None, restart=False, group=None):
         self.kind = kind
         self.default = default
         self.label = label
@@ -57,6 +62,7 @@ class Setting:
         self.decimals = decimals
         self.help = help
         self.restart = restart
+        self.group = group
 
 
 # Nested dicts mirror the JSON. A dict value is a section; a Setting is a leaf.
@@ -114,40 +120,43 @@ SCHEMA = {
         "drag_sensitivity": Setting("slider", 0.25, range=(0.0, 1.0)),
     },
     "node_graph": {
-        "node_shape":  Setting("choice", "rect", choices=tuple((s, s) for s in NODE_SHAPES)),
+        # Groups are panel headings only; the keys stay flat in the file.
+        "node_shape":  Setting("choice", "rect", choices=tuple((s, s) for s in NODE_SHAPES),
+                               group="Nodes"),
         "node_coloring": Setting(
-            "bool", True, label="Custom Node Coloring",
+            "bool", True, label="Custom Node Coloring", group="Nodes",
             help="Color new nodes with the colour below and keep them in sync. "
                  "Off leaves every node the colour Houdini gives it.",
         ),
-        "node_color":  Setting("color", "#607070"),
-        "zoom_center": Setting("choice", "mouse_cursor", choices=ZOOM_CENTERS),
+        "node_color":  Setting("color", "#607070", group="Nodes"),
+        "zoom_center": Setting("choice", "mouse_cursor", choices=ZOOM_CENTERS,
+                               group="Navigation"),
         "hcnetcursor": Setting(
-            "bool", True, label="hcnetcursor",
+            "bool", True, label="hcnetcursor", group="Navigation",
             help="The grid cursor in the network editor. Off also returns the "
                  "unmodified f key to Houdini's own frame-selection.",
         ),
         "current_node_arrow_color": Setting(
-            "color", "#618f8f", label="Current Node Arrow Color",
+            "color", "#618f8f", label="Current Node Arrow Color", group="Navigation",
             help="The off-screen current-node arrow drawn over the network editor.",
         ),
+        "grid_snap": Setting(
+            "bool", True, label="Hard Grid Snap", group="Grid",
+            help="Every drag, Tab-menu placement and box resize lands on the "
+                 "grid. Off restores Houdini's magnetic snap radius and its "
+                 "node-to-node alignment guides.",
+        ),
         "drop_swap": Setting(
-            "bool", True, label="Drop to Swap",
+            "bool", True, label="Drop to Swap", group="Grid",
             help="Dragging a node onto another node's cell, or stepping it "
                  "there with the move keys, moves that node into the cell the "
                  "first one left. Two nodes wired directly to each other also "
                  "trade places in the chain.",
         ),
-        "grid_snap": Setting(
-            "bool", True, label="Hard Grid Snap",
-            help="Every drag, Tab-menu placement and box resize lands on the "
-                 "grid. Off restores Houdini's magnetic snap radius and its "
-                 "node-to-node alignment guides.",
-        ),
-        "grid_x_step": Setting("slider", 2.0, range=(0.25, 8.0)),
-        "grid_y_step": Setting("slider", 1.0, range=(0.25, 8.0)),
-        "node_center_offset_x": Setting("slider", 0.5, range=(0.0, 2.0)),
-        "node_center_offset_y": Setting("slider", 0.15, range=(0.0, 2.0)),
+        "grid_x_step": Setting("slider", 2.0, range=(0.25, 8.0), group="Grid"),
+        "grid_y_step": Setting("slider", 1.0, range=(0.25, 8.0), group="Grid"),
+        "node_center_offset_x": Setting("slider", 0.5, range=(0.0, 2.0), group="Grid"),
+        "node_center_offset_y": Setting("slider", 0.15, range=(0.0, 2.0), group="Grid"),
     },
 }
 
diff --git a/python3.13libs/hc/hcsettings.py b/python3.13libs/hc/hcsettings.py
index 4d61e1f..5354730 100644
--- a/python3.13libs/hc/hcsettings.py
+++ b/python3.13libs/hc/hcsettings.py
@@ -432,6 +432,29 @@ class HCSettingsPanel(QWidget):
         scalars = {k: v for k, v in schema.items() if isinstance(v, hcschema.Setting)}
         sections = {k: v for k, v in schema.items() if isinstance(v, dict)}
 
+        # Ungrouped rows first, then one box per `group` in order of first
+        # appearance. The groups are headings only -- the field paths, and
+        # so the file, are the same as for a flat form.
+        groups = {}
+        for key, setting in scalars.items():
+            groups.setdefault(setting.group, {})[key] = setting
+        for name, members in groups.items():
+            if name is None:
+                self._addForm(members, values, parent_layout, path)
+        for name, members in groups.items():
+            if name is not None:
+                group = QGroupBox(name)
+                group_layout = QVBoxLayout(group)
+                self._addForm(members, values, group_layout, path)
+                parent_layout.addWidget(group)
+
+        for key, section in sections.items():
+            group = QGroupBox(hcschema.label_for(key))
+            group_layout = QVBoxLayout(group)
+            self._addSection(section, values.get(key, {}), group_layout, path + (key,))
+            parent_layout.addWidget(group)
+
+    def _addForm(self, scalars, values, parent_layout, path):
         if scalars:
             form = QFormLayout()
             parent_layout.addLayout(form)
@@ -458,12 +481,6 @@ class HCSettingsPanel(QWidget):
                     self._restart_marks[path + (key,)] = mark
                 form.addRow(label + ":", row)
 
-        for key, section in sections.items():
-            group = QGroupBox(hcschema.label_for(key))
-            group_layout = QVBoxLayout(group)
-            self._addSection(section, values.get(key, {}), group_layout, path + (key,))
-            parent_layout.addWidget(group)
-
     def _makeWidget(self, setting, value):
         """Widget from the declared kind -- never guessed from the value's type."""
         kind = setting.kind