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

commit9ec40f7e956b1db1cd431a8322895c422c26e927
parentbfb73d1a55
authorLucas Galante <[email protected]>
date2026-09-16 12:26
hc: scope panel commands by class, with a predicate where that varies

Path was missing from network editors for the same reason Pin was: the
@command tabs= filter listed .type() strings, a second scoping mechanism
beside the class tree that drifted from it. It is gone. Where a command
shows is now only the class it is declared on; a command only some
instances of a class can run names a predicate, available="...", and
bind() leaves it out where that is false. Path uses it, since the channel
editor and the parameter spreadsheet have a path bar and a shell does not.

convertTab wraps any hou.PathBasedPaneTab (tree view, context viewer,
APEX editor...) as HCPathTab, so those get Pin as well. The Pin comment
claimed only path tabs have isPin(); HOM gives it to every pane tab, but
only a path tab has a path for the pin to hold.

check.py binds commands on wrappers around a stub hou tab so predicates
can be evaluated headlessly, and asserts Path follows hasNetworkControls.

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

 CLAUDE.md                       |  2 +-
 python3.13libs/hc/hccommands.py | 67 ++++++++++++++++++++++++-----------------
 python3.13libs/hc/hcmaps.py     |  7 +++--
 python3.13libs/hc/hcpane.py     |  4 +++
 python3.13libs/hc/hcpathtab.py  |  8 ++---
 python3.13libs/hc/hctab.py      | 27 +++++++++--------
 tools/check.py                  | 36 ++++++++++++++++------
 7 files changed, 95 insertions(+), 56 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ae0f4ad..52183c4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,7 +43,7 @@ Key hierarchy:
 - **`HCPane`** wraps `hou.Pane`; knows how to split, resize, convert its current `hou.PaneTab` into the right `HC*` subclass via `HCPane.convertTab`.
 - **`HCTab`** → **`HCPathTab`** → **`HCNetworkEditor`** / **`HCSceneViewer`**. `convertTab` dispatches on `hou.paneTabType` to the correct subclass; `HCPathTab` is used for Parm and DetailsView tabs. Tab type is also identified via a string `.type()` method (e.g. `'HCNetworkEditor'`) used in `isinstance`-style branching throughout `HCSession` and `HCMaps`.
 - **`HCBindings`** — loads hotkey assignments by reading `hc_hotkeys.json` and calling `hou.hotkeys.addAssignment`. Before each assignment, `hou.hotkeys.findConflicts` identifies any ancestor/descendant bindings using the same key and clears them. The JSON keys are Houdini symbol paths like `h.pane.gview.foo`; the context is derived via `symbol.rpartition('.')[0]`.
-- **`hccommands.py`** — the `@command("Label")` decorator and the registry that reads it. A command's panel label lives on the method itself. `tabs=(...)` restricts a command to certain tab `.type()` strings, needed for the few commands defined on `HCTab` that only make sense on some tabs.
+- **`hccommands.py`** — the `@command("Label")` decorator and the registry that reads it. A command's panel label lives on the method itself. Where a command appears is the class it is defined on, so put it on the narrowest class whose every instance can run it. When that varies within a class, name a predicate method with `available="hasNetworkControls"`, and `bind()` leaves the command out of tabs where it returns false. There is no list of tab type strings to keep in step with the class tree.
 - **`HCMaps`** (`hcmaps.py`) — generated, not hand-written. `commands(session, pane, tab)` binds every `@command` reachable from those three objects, filtered by tab type. `HCSession.hcPanel()` calls it and hands the result to `SelectionDialog`.
 - **`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.
 - **`hcnetcursorimage.py`** — paints the network cursor's picture (a `hou.NetworkImage` outline) on demand into `$HOUDINI_TEMP_DIR/hc_hcnetcursor/`, one file per cell size and colour. Houdini cannot tint a background image, which is why the `hcnetcursor_color` setting needs a painted file; this replaced 144 static PNGs in `config/hcnetcursor_assets`, a path old hips may still carry in their background image lists (`is_cursor_image` matches on basename so they are filtered out).
diff --git a/python3.13libs/hc/hccommands.py b/python3.13libs/hc/hccommands.py
index 23be6b0..bee4a0c 100644
--- a/python3.13libs/hc/hccommands.py
+++ b/python3.13libs/hc/hccommands.py
@@ -16,10 +16,23 @@ forget. Hotkeys (hc_hotkeys.json) and the XML menus still carry their own
 entries, because Houdini owns those formats -- but `symbols()` lets a check
 verify they point at commands that exist.
 
-`scope` controls where a command shows up. Commands on HCSession and HCPane
-are always available; commands on a tab class appear whenever the current tab
-is an instance of it, so a method on HCTab shows everywhere and one on
-HCNetworkEditor only in network editors.
+Where a command shows up is the class it is defined on. Commands on
+HCSession and HCPane are always available; commands on a tab class appear
+whenever the current tab is an instance of it, so a method on HCTab shows
+everywhere and one on HCNetworkEditor only in network editors. Put a command
+on the narrowest class whose every instance can run it.
+
+When that varies within a class, name a predicate the same way a getter is
+named:
+
+    @command("Path", state="isShowingPath", available="hasNetworkControls")
+    def toggleNetworkControls(self): ...
+
+The panel leaves the command out of a tab whose predicate returns false.
+There used to be a `tabs=("HCPathTab", ...)` filter of .type() strings
+instead -- a second scoping mechanism beside the class tree, which drifted
+from it: Pin and Path were both missing from network editors because the
+list named the two types someone had in front of them at the time.
 
 A command can also describe the control the panel should draw for it. There
 are three kinds:
@@ -58,15 +71,17 @@ KINDS = ("action", "toggle", "choice")
 
 
 class Command:
-    __slots__ = ("label", "tabs", "help", "state", "choices", "kind")
+    __slots__ = ("label", "help", "state", "choices", "available", "kind")
 
-    def __init__(self, label, tabs=None, help=None, state=None, choices=None):
+    def __init__(self, label, help=None, state=None, choices=None,
+                 available=None):
         self.label = label
-        # Tab .type() strings this command applies to, or None for all. Needed
-        # because a few commands live on HCTab (every tab has the method) but
-        # only make sense on some tab types.
-        self.tabs = tuple(tabs) if tabs else None
         self.help = help
+        # Name of a method on the instance that says whether this particular
+        # tab can run the command, or None when every instance of the class
+        # can. hasNetworkControls() is the worked example: some tabs of the
+        # plain HCTab kind have a path bar and some do not.
+        self.available = available
         # Name of the method that reads the current value, on the same
         # instance the command is bound to. Its return is a bool for toggles
         # and one of the choice values for choices.
@@ -85,18 +100,15 @@ class Command:
         else:
             self.kind = "action"
 
-    def appliesTo(self, tab_type):
-        return self.tabs is None or tab_type in self.tabs
-
     def __repr__(self):
         return f"<Command {self.label!r} {self.kind}>"
 
 
-def command(label, tabs=None, help=None, state=None, choices=None):
+def command(label, help=None, state=None, choices=None, available=None):
     """Expose the decorated method in the HC Panel under `label`."""
     def decorate(fn):
-        setattr(fn, ATTR, Command(label, tabs=tabs, help=help, state=state,
-                                  choices=choices))
+        setattr(fn, ATTR, Command(label, help=help, state=state,
+                                  choices=choices, available=available))
         return fn
     return decorate
 
@@ -179,17 +191,18 @@ class Bound:
         return f"<Bound {self.label!r} {self.kind} on {type(self.instance).__name__}>"
 
 
-def bind(instance, tab_type=None):
+def bind(instance):
     """{label: Bound} for the commands on `instance` that apply.
 
-    `tab_type` is the current tab's .type() string; commands scoped to other
-    tab types are left out.
+    A command whose `available` predicate returns false on this instance is
+    left out. A predicate that raises is left to raise, like a state getter:
+    the caller sees the failure instead of a command quietly missing.
     """
     if instance is None:
         return {}
     bound = {}
     for label, (name, spec) in declared(type(instance)).items():
-        if tab_type is not None and not spec.appliesTo(tab_type):
+        if spec.available is not None and not getattr(instance, spec.available)():
             continue
         method = getattr(instance, name, None)
         if method is not None:
@@ -204,17 +217,17 @@ def labels(cls):
 def verify(cls):
     """Problems with the controls declared on `cls`, as a list of strings.
 
-    Empty when every state getter names a callable on the class and every
-    choice command's method accepts the chosen value. tools/check.py runs
-    this over every wrapper class.
+    Empty when every state getter and availability predicate names a
+    callable on the class and every choice command's method accepts the
+    chosen value. tools/check.py runs this over every wrapper class.
     """
     problems = []
     for label, (name, spec) in declared(cls).items():
-        if spec.state is not None:
-            getter = getattr(cls, spec.state, None)
-            if not callable(getter):
+        for role, getter_name in (("state getter", spec.state),
+                                  ("availability predicate", spec.available)):
+            if getter_name is not None and not callable(getattr(cls, getter_name, None)):
                 problems.append(f"{cls.__name__}.{name} ({label!r}): "
-                                f"state getter {spec.state!r} does not exist")
+                                f"{role} {getter_name!r} does not exist")
         method = getattr(cls, name)
         try:
             params = [p for p in inspect.signature(method).parameters.values()
diff --git a/python3.13libs/hc/hcmaps.py b/python3.13libs/hc/hcmaps.py
index 1c77b9d..22cb127 100644
--- a/python3.13libs/hc/hcmaps.py
+++ b/python3.13libs/hc/hcmaps.py
@@ -7,7 +7,9 @@ so the command silently did nothing.
 
 The map is now derived. The four tab_map_* methods that callers composed by
 hand are one call: every command the session, the pane and the current tab
-expose, with tab-scoped commands filtered to the tab types they declare.
+expose. Which tab kinds see a command is which class it is declared on; a
+command that only some instances of a class can run declares an `available`
+predicate and bind() leaves it out where that is false.
 """
 
 from . import hccommands
@@ -16,8 +18,7 @@ from . import hccommands
 class HCMaps:
     def commands(self, session, pane, tab):
         """{label: bound method} for the current session, pane and tab."""
-        tab_type = tab.type() if tab is not None else None
         merged = {}
         for instance in (session, pane, tab):
-            merged.update(hccommands.bind(instance, tab_type))
+            merged.update(hccommands.bind(instance))
         return dict(sorted(merged.items()))
diff --git a/python3.13libs/hc/hcpane.py b/python3.13libs/hc/hcpane.py
index 5545133..222ce44 100644
--- a/python3.13libs/hc/hcpane.py
+++ b/python3.13libs/hc/hcpane.py
@@ -155,6 +155,10 @@ class HCPane:
             return HCPathTab(hou_tab)
         elif hou_tab_type == hou.paneTabType.Parm:
             return HCParameterTab(hou_tab)
+        elif isinstance(hou_tab, hou.PathBasedPaneTab):
+            # Tree view, context viewer, APEX editor, render gallery...:
+            # anything with a pwd() gets the path commands (Pin among them).
+            return HCPathTab(hou_tab)
         else:
             return HCTab(hou_tab)
 
diff --git a/python3.13libs/hc/hcpathtab.py b/python3.13libs/hc/hcpathtab.py
index 345f278..d497019 100644
--- a/python3.13libs/hc/hcpathtab.py
+++ b/python3.13libs/hc/hcpathtab.py
@@ -24,10 +24,10 @@ class HCPathTab(HCTab):
 
     """ Pin """
 
-    # Pinning is a hou.PathBasedPaneTab feature, so it belongs here and not
-    # on HCTab: a Python shell or a help browser has no isPin(). Every path
-    # tab -- Parm, DetailsView, NetworkEditor, SceneViewer -- inherits the
-    # command, which is why it carries no tabs= filter.
+    # HOM puts isPin()/setPin() on every hou.PaneTab, but the pin holds a
+    # tab's *path* against the pane's link group, which only a path-based
+    # tab has -- so the command lives here, not on HCTab, and every subclass
+    # (Parm, DetailsView, NetworkEditor, SceneViewer) inherits it.
 
     def isPin(self):
         return self.hou_tab.isPin()
diff --git a/python3.13libs/hc/hctab.py b/python3.13libs/hc/hctab.py
index 50198e7..b78ac09 100644
--- a/python3.13libs/hc/hctab.py
+++ b/python3.13libs/hc/hctab.py
@@ -130,14 +130,25 @@ class HCTab():
         return initialized
 
 
-    """ UI """
+    """ Chrome """
+
+    # "Chrome" is whatever bars and controls a tab draws around its content.
+    # HCSession.toggleMenus and isVisibleMenus used to switch on tab.type()
+    # and call a different set of methods in each branch; each tab class now
+    # answers for itself and those two methods just iterate.
+    #
+    # The path bar ("network controls" to HOM) is the one piece of chrome
+    # every kind of tab may have -- network editors, viewers and parameter
+    # editors do, and so do the channel editor and the parameter spreadsheet,
+    # which wrap as plain HCTab -- while shells and browsers have none. So the
+    # Path toggle lives here and asks the tab, rather than living on a
+    # subclass or naming tab types.
 
     def hasNetworkControls(self):
         return self.hou_tab.hasNetworkControls()
 
     def isShowingNetworkControls(self):
-        value = self.hou_tab.isShowingNetworkControls()
-        return value
+        return self.hou_tab.isShowingNetworkControls()
 
     def showNetworkControls(self, value):
         self.hou_tab.showNetworkControls(value)
@@ -145,19 +156,11 @@ class HCTab():
     def isShowingPath(self):
         return bool(self.hasNetworkControls() and self.isShowingNetworkControls())
 
-    @command("Path", tabs=("HCPathTab", "HCParameterTab"), state="isShowingPath")
+    @command("Path", state="isShowingPath", available="hasNetworkControls")
     def toggleNetworkControls(self):
         if self.hasNetworkControls():
             self.showNetworkControls(not self.isShowingNetworkControls())
 
-
-    """ Chrome """
-
-    # "Chrome" is whatever bars and controls a tab draws around its content.
-    # HCSession.toggleMenus and isVisibleMenus used to switch on tab.type()
-    # and call a different set of methods in each branch; each tab class now
-    # answers for itself and those two methods just iterate.
-
     def isChromeVisible(self):
         return bool(self.hasNetworkControls() and self.isShowingNetworkControls())
 
diff --git a/tools/check.py b/tools/check.py
index 19e363d..4bd06d4 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -17,6 +17,7 @@ by hand -- but a failure here is always a real one.
 import os
 import sys
 import time
+import types
 from pathlib import Path
 
 ROOT = Path(__file__).resolve().parent.parent
@@ -75,6 +76,18 @@ def blank(cls):
     return cls.__new__(cls)
 
 
+def withTab(cls, **hou_attrs):
+    """A blank wrapper around a fake hou tab answering `hou_attrs`.
+
+    Binding commands evaluates each `available` predicate on the instance,
+    and those read self.hou_tab -- which a blank() has not got.
+    """
+    instance = blank(cls)
+    instance.hou_tab = types.SimpleNamespace(
+        **{name: (lambda v=value: v) for name, value in hou_attrs.items()})
+    return instance
+
+
 class nodeColoring:
     """Pin the node_coloring switch for one check.
 
@@ -213,7 +226,7 @@ def check_commands():
         ("other tabs", HCTab),
     ):
         def binds(c=cls):
-            commands = maps.commands(session, pane, blank(c))
+            commands = maps.commands(session, pane, withTab(c, hasNetworkControls=True))
             assert commands, "no commands bound"
             for name, method in commands.items():
                 assert callable(method), f"{name} is not callable"
@@ -226,20 +239,25 @@ def check_commands():
         check(f"{label} panel binds", binds)
 
     def scoping_holds():
-        """Path lives on HCTab but only applies to path-like tabs; Pin lives
-        on HCPathTab so every pinnable tab inherits it and nothing else does."""
-        network = maps.commands(session, pane, blank(HCNetworkEditor))
-        parm = maps.commands(session, pane, blank(HCParameterTab))
-        other = maps.commands(session, pane, blank(HCTab))
+        """Where a command shows is the class it is declared on: Pin on
+        HCPathTab reaches every path tab and no other; Replace Node stays in
+        network editors. Path is the one command whose scope is a predicate,
+        because plain HCTab tabs differ in whether they have a path bar."""
         for cls in (HCPathTab, HCParameterTab, HCNetworkEditor, HCSceneViewer):
-            bound = maps.commands(session, pane, blank(cls))
+            bound = maps.commands(session, pane, withTab(cls, hasNetworkControls=True))
             assert "Pin" in bound, f"{cls.__name__} lost Pin"
             assert bound["Pin"].kind == "toggle", f"{cls.__name__}: Pin is not a toggle"
+            assert "Path" in bound, f"{cls.__name__} lost Path"
+        other = maps.commands(session, pane, withTab(HCTab, hasNetworkControls=True))
         assert "Pin" not in other, "Pin leaked into tabs without a path"
-        assert "Path" in parm and "Path" not in other, "Path scoping broke"
+        assert "Path" in other, "a plain tab with a path bar lost Path"
+        bare = maps.commands(session, pane, withTab(HCTab, hasNetworkControls=False))
+        assert "Path" not in bare, "Path offered on a tab with no path bar"
+        network = maps.commands(session, pane, withTab(HCNetworkEditor, hasNetworkControls=True))
+        parm = maps.commands(session, pane, withTab(HCParameterTab, hasNetworkControls=True))
         assert "Replace Node" in network, "network editors lost Replace Node"
         assert "Replace Node" not in parm, "Replace Node leaked into parameter tabs"
-        return "tab-scoped commands stay scoped"
+        return "class scope and the Path predicate hold"
 
     check("command scoping", scoping_holds)