SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
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)