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

commitcc05cc680c774552736c12819777144dfc7d4b62
parente0994fbba5
authorLucas Galante <[email protected]>
date2026-09-11 08:51
hc: serve HC Settings as a Python Panel pane tab

Houdini's own Edit > Preferences window cannot host these settings: it is a
compiled-in pane (h.pane.preferences) with a fixed page list, no HOUDINI_PATH
directory contributes pages to it, and HOM exposes only value access
(getPreference/setPreference/removePreference/savePreferences), never page
registration. A Python Panel is the closest native equivalent, so HC Settings
becomes a real pane tab type: it shows up in the pane tab menu beside the
built-in panels, docks, splits and clones like them.

python_panels/hc_settings.pypanel registers the interface; its
onCreateInterface() returns an HCSettingsPanel, which is now a plain QWidget
rather than a QDialog, since Houdini adopts the widget into a pane tab and a
dialog in a pane tab keeps dialog behaviour.

Two things fall out of no longer being a dialog:

- showEvent no longer rebuilds the form. show() meant "opening" for a dialog,
  but a pane tab gets a showEvent every time it becomes current in its pane,
  and rebuilding there would discard unsaved edits on every tab switch. The
  file watcher runs whether or not the tab is visible, so an external edit is
  still picked up without it.
- OBJECT_NAME is gone. It existed so openSettings() could findChild() the
  dialog under the main window, and Houdini overwrites the objectName of a
  widget it adopts anyway (ours came back as QT_Feel). Tabs are found by
  active interface name instead.

openSettings() keeps its one-keypress behaviour -- focus the open tab, else
float a new one -- via the new settingsTab(), which spans the desktop and the
floating panels so a docked panel is found too. Passing the interface name to
createFloatingPanel also suppresses the Python Panel interface-picker toolbar.

reloadHC() now reloads an open settings tab's interface. Without it the tab
keeps a widget whose class came from the module reloadHC just dropped, and
silently goes on running pre-reload code.

check.py asserts INTERFACE_NAME matches the name in the XML -- they live in
separate files and the drift is silent, openSettings() would simply float a
duplicate every time -- and that the panel has not become a QDialog again.

Verified against a running Houdini over the agent bridge: the interface
registers, the tab builds with no script errors, a second openSettings()
reuses it, reloadHC() rebuilds the widget from the reloaded module, and the
watcher stays armed across an external rewrite of hc_settings.json.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md                         |  4 +++
 python3.13libs/hc/hcsession.py    | 65 +++++++++++++++++++++++++++++++++++----
 python3.13libs/hc/hcsettings.py   | 42 +++++++++++++++++--------
 python_panels/hc_settings.pypanel | 21 +++++++++++++
 tools/check.py                    | 33 ++++++++++++++++++++
 5 files changed, 147 insertions(+), 18 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index e65258a..274f010 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -48,11 +48,14 @@ Key hierarchy:
 - **`hcstate.py`** — all per-pane and per-network state, in one place with one key scheme. Wrappers are stateless (Houdini hands out fresh SWIG wrappers per callback and holding one is a crash), so anything persisting between events goes in a `Store` here. Two scopes: `hcstate.PANE` and `hcstate.NETWORK` (`(pane id, network path)`). Entries for closed panes are swept periodically — pane ids get reused.
 - **`hcschema.py`** — one `Setting` per configurable value: kind, default, label, range. `HCSettings.DEFAULTS` and every control in `HCSettingsPanel` are generated from it.
 - **`HCWidgets`** (`hcwidgets.py`) — PySide6 widgets, notably `SelectionDialog` used for the HC Panel.
+- **`HCSettingsPanel`** (`hcsettings.py`) — the settings form. It is a plain `QWidget`, not a dialog, because it is served as a Python Panel (see `python_panels/` below) and Houdini adopts it into a pane tab. Keep it a `QWidget`: a `QDialog` in a pane tab keeps dialog behaviour, and Houdini overwrites the `objectName` of a widget it adopts, so don't try to find it by object name.
 
 When adding a new command: implement it on the appropriate wrapper (`HCSession`/`HCPane`/`HC*Tab`) and decorate it with `@command("Label")`. That is the whole registration — there is no separate map to update. If it should be hotkey-bound, add a Houdini symbol entry to `hc_hotkeys.json`; menus still need their own `scriptItem` in the XML.
 
 When adding a new setting: add a `Setting` to `hcschema.SCHEMA`. Defaults, the settings panel control, and the widget type all follow from it.
 
+HC settings cannot live in Houdini's own **Edit > Preferences** window. That window is a compiled-in pane (`h.pane.preferences`) with a fixed page list; no `HOUDINI_PATH` directory contributes pages to it, and HOM exposes only value access (`hou.getPreference` / `setPreference` / `removePreference` / `savePreferences`), no page registration. The Python Panel above is the closest native equivalent.
+
 ### Non-package Python (`python3.13libs/`)
 
 These are recognized by Houdini's startup/event system by filename convention:
@@ -84,6 +87,7 @@ These are reloaded via `HCSession.reloadColorSchemes()` (`hou.ui.reloadColorSche
 
 - `otls/` — HDAs (Houdini Digital Assets). `otls/backup/` is gitignored.
 - `desktop/hc_attached.desk` — main desktop layout (single-window, multi-pane). `desktop/hc_detached.desk` — minimal layout (single SceneViewer pane) used when `desktop_mode` is `detached` and floating panels handle the rest.
+- `python_panels/` — Python Panel interface definitions. `hc_settings.pypanel` registers **HC Settings** as a real pane tab type, so it appears in the pane tab menu, docks and splits like any built-in panel. Its `onCreateInterface()` returns an `HCSettingsPanel`. `HCSession.openSettings()` finds an open one by active interface name (`HCSettingsPanel.INTERFACE_NAME`, which must match the `name` attribute in the XML — `tools/check.py` asserts this) and otherwise floats a new one. Note Houdini scans `python_panels/` only at **startup**: a newly added `.pypanel` needs a restart, or `hou.pypanel.installFile(path)` — `reloadHC()` will not pick it up. `reloadHC()` does call `reloadActiveInterface()` on an open settings tab, so the widget is rebuilt from the reloaded module rather than running stale code.
 - `radialmenu/`, `toolbar/`, `presets/`, `vex/`, `help/` — Houdini conventional subdirectories loaded by path.
 - `MainMenuCommon.xml`, `OPmenu.xml`, `PARMmenu.xmlx`, `ParmGearMenu.xml` — menu definitions. Each `scriptItem` typically does `from hc import HCSession; HCSession().someMethod()`.
 
diff --git a/python3.13libs/hc/hcsession.py b/python3.13libs/hc/hcsession.py
index 08d7e89..0a6a2df 100644
--- a/python3.13libs/hc/hcsession.py
+++ b/python3.13libs/hc/hcsession.py
@@ -205,15 +205,60 @@ class HCSession:
             bar.overlay.show()
             bar.overlay.raise_()
 
+    def settingsTab(self):
+        """The open HC Settings pane tab, or None.
+
+        A PythonPanel tab is identified by its active interface, not by its
+        type -- every Python Panel is a hou.paneTabType.PythonPanel, so the
+        interface name is the only thing that distinguishes ours from any
+        other. allPanes() already spans the desktop and the floating panels,
+        so a settings tab the user has docked somewhere is found too.
+        """
+        from .hcsettings import HCSettingsPanel
+        for pane in self.allPanes():
+            for hou_tab in pane.hou_pane.tabs():
+                if hou_tab.type() != hou.paneTabType.PythonPanel:
+                    continue
+                interface = hou_tab.activeInterface()
+                if interface is not None and interface.name() == HCSettingsPanel.INTERFACE_NAME:
+                    return hou_tab
+        return None
+
     @command("Open HC Settings")
     def openSettings(self):
+        """Show the HC Settings panel, creating a floating one if none is open.
+
+        The panel is a registered Python Panel interface
+        (python_panels/hc_settings.pypanel), so the user can also just add an
+        "HC Settings" tab to any pane. This command keeps the old one-keypress
+        behaviour: focus the existing tab, or float a new one.
+        """
         from .hcsettings import HCSettingsPanel
-        existing = hou.qt.mainWindow().findChild(HCSettingsPanel, HCSettingsPanel.OBJECT_NAME)
-        if existing is None:
-            existing = HCSettingsPanel()
-        existing.show()
-        existing.raise_()
-        existing.activateWindow()
+
+        hou_tab = self.settingsTab()
+        if hou_tab is None:
+            if hou.pypanel.interfaceByName(HCSettingsPanel.INTERFACE_NAME) is None:
+                hou.ui.setStatusMessage(
+                    "HC Settings panel is not registered -- is "
+                    "python_panels/hc_settings.pypanel on HOUDINI_PATH?",
+                    hou.severityType.Error)
+                return None
+            # Passing the interface name (rather than setting it afterwards)
+            # also suppresses the Python Panel interface-picker toolbar.
+            panel = self.desktop().createFloatingPanel(
+                hou.paneTabType.PythonPanel,
+                size=(720, 600),
+                python_panel_interface=HCSettingsPanel.INTERFACE_NAME,
+                immediate=True)
+            hou_tab = panel.paneTabs()[0]
+
+        hou_tab.setIsCurrentTab()
+        window = hou_tab.qtParentWindow()
+        if window is not None:
+            window.show()
+            window.raise_()
+            window.activateWindow()
+        return hou_tab
 
     def splitHandles(self):
         from .hcsplithandles import HCSplitHandles
@@ -349,6 +394,14 @@ class HCSession:
         # Restore the status bar overlay (destroyed above)
         from .hcstatusbar import HCStatusBar
         HCStatusBar().show()
+        # An open HC Settings tab holds a widget built from the class object of
+        # the module we just dropped, so it would keep running pre-reload code.
+        # reloadActiveInterface() re-runs the pypanel's onCreateInterface()
+        # against the freshly imported module. Unsaved edits in it are lost,
+        # which is the same trade the status bar teardown above makes.
+        hou_tab = self.settingsTab()
+        if hou_tab is not None:
+            hou_tab.reloadActiveInterface()
 
     @command("Reload Hotkeys")
     def reloadHotkeys(self):
diff --git a/python3.13libs/hc/hcsettings.py b/python3.13libs/hc/hcsettings.py
index f1f9352..66ee14f 100644
--- a/python3.13libs/hc/hcsettings.py
+++ b/python3.13libs/hc/hcsettings.py
@@ -7,7 +7,6 @@ from PySide6.QtCore import QFileSystemWatcher, Qt
 from PySide6.QtWidgets import (
     QCheckBox,
     QComboBox,
-    QDialog,
     QDialogButtonBox,
     QDoubleSpinBox,
     QFormLayout,
@@ -159,16 +158,31 @@ class HCSettings:
         return hou.Color((r, g, b))
 
 
-class HCSettingsPanel(QDialog):
-    OBJECT_NAME = "hc_settings_panel"
+class HCSettingsPanel(QWidget):
+    """The HC Settings form.
+
+    Built as a plain QWidget, not a QDialog, because it is served to Houdini
+    through python_panels/hc_settings.pypanel: onCreateInterface() returns one
+    of these and Houdini reparents it into a PythonPanel pane tab. That makes
+    HC Settings a real pane tab type -- dockable, splittable, listed in the
+    pane tab menu next to Houdini's own panels -- rather than a floater
+    parented to the main window.
+
+    Consequences of being a pane tab rather than a dialog: no window title (the
+    tab carries the label), no Qt.Tool flag, no fixed size (the pane decides),
+    and no Close button (the tab's own close does that).
+    """
+
+    # Must match the interface name in python_panels/hc_settings.pypanel.
+    # There is no OBJECT_NAME any more: the old one existed so openSettings()
+    # could findChild() the dialog under the main window, and Houdini
+    # overwrites objectName on a widget it adopts into a pane tab anyway. The
+    # tab is found by its active interface name instead.
+    INTERFACE_NAME = "hc_settings"
     SLIDER_STEPS = 1000
 
-    def __init__(self):
-        super().__init__(hou.qt.mainWindow())
-        self.setObjectName(self.OBJECT_NAME)
-        self.setWindowTitle("HC Settings")
-        self.setWindowFlags(Qt.Tool)
-        self.resize(720, 600)
+    def __init__(self, parent=None):
+        super().__init__(parent)
         self.settings = HCSettings()
 
         # path tuple, e.g. ("keycam","guides","axis_size") -> widget
@@ -176,9 +190,8 @@ class HCSettingsPanel(QDialog):
 
         self.tabs = QTabWidget()
 
-        buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Close)
+        buttons = QDialogButtonBox(QDialogButtonBox.Save)
         buttons.accepted.connect(self._save)
-        buttons.rejected.connect(self.close)
 
         layout = QVBoxLayout(self)
         layout.addWidget(self.tabs)
@@ -191,7 +204,12 @@ class HCSettingsPanel(QDialog):
         self._rebuild()
 
     def showEvent(self, event):
-        self._rebuild()
+        # Only re-arm the watcher here. As a dialog this also rebuilt the form,
+        # which was harmless because show() meant "opening". A pane tab gets a
+        # showEvent every time it becomes the current tab in its pane, and
+        # rebuilding there would throw away unsaved edits on every tab switch.
+        # The watcher runs whether or not the tab is visible, so an external
+        # edit is still picked up without this.
         self._startWatching()
         super().showEvent(event)
 
diff --git a/python_panels/hc_settings.pypanel b/python_panels/hc_settings.pypanel
new file mode 100644
index 0000000..46ea1bb
--- /dev/null
+++ b/python_panels/hc_settings.pypanel
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<pythonPanelDocument>
+  <!-- HC Settings as a real pane tab type. Houdini scans python_panels/ on
+   every HOUDINI_PATH entry, so this registers the interface as soon as the
+   hou-control package is on the path. HCSession.openSettings() looks the
+   interface up by the name below, so the two must stay in sync; the name
+   also lives on HCSettingsPanel.INTERFACE_NAME. -->
+  <interface name="hc_settings" label="HC Settings" icon="MISC_python" showNetworkNavigationBar="false" help_url="">
+    <script><![CDATA[
+from hc.hcsettings import HCSettingsPanel
+
+def onCreateInterface():
+    return HCSettingsPanel()
+]]></script>
+    <includeInPaneTabMenu menu_position="900" create_separator="true"/>
+    <help><![CDATA[
+Settings for the hou-control package, generated from hcschema.SCHEMA and
+stored in hc_settings.json. Edits to that file on disk are picked up live.
+]]></help>
+  </interface>
+</pythonPanelDocument>
diff --git a/tools/check.py b/tools/check.py
index 736f52c..ddb8938 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -392,8 +392,41 @@ def check_node_ops():
     check("pwd returns a hou.Node", pwd_is_a_real_node)
 
 
+def check_panel():
+    print("settings panel")
+
+    def pypanel_name_matches():
+        """The interface name lives in two files. If they drift, the pypanel
+        still registers and the tab still opens from the pane tab menu, but
+        HCSession.openSettings() silently stops finding it and floats a second
+        copy on every invocation."""
+        import xml.etree.ElementTree as ET
+        from hc.hcsettings import HCSettingsPanel
+        path = ROOT / "python_panels" / "hc_settings.pypanel"
+        assert path.exists(), f"missing {path}"
+        names = [e.get("name") for e in ET.parse(path).getroot().iter("interface")]
+        assert HCSettingsPanel.INTERFACE_NAME in names, \
+            f"INTERFACE_NAME {HCSettingsPanel.INTERFACE_NAME!r} not in {names}"
+        return f"{HCSettingsPanel.INTERFACE_NAME!r} declared in both"
+
+    check("pypanel interface name", pypanel_name_matches)
+
+    def panel_is_not_a_dialog():
+        """A QDialog reparented into a pane tab keeps dialog behaviour (it can
+        swallow Esc and close itself out of the tab). onCreateInterface() has
+        to hand Houdini a plain QWidget."""
+        from PySide6.QtWidgets import QDialog, QWidget
+        from hc.hcsettings import HCSettingsPanel
+        assert issubclass(HCSettingsPanel, QWidget), "panel is not a QWidget"
+        assert not issubclass(HCSettingsPanel, QDialog), "panel is a QDialog again"
+        return "HCSettingsPanel is a plain QWidget"
+
+    check("panel base class", panel_is_not_a_dialog)
+
+
 def main():
     check_settings()
+    check_panel()
     check_commands()
     check_state()
     check_chrome()