SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
python3.13libs/hc/hccommands.py (9.3K)
1 """Command registry: the HC Panel's label for a method lives on the method.
2
3 A command used to be declared in two places -- the method, and a hand-written
4 ``label -> bound method`` entry in HCMaps -- with nothing checking they agreed.
5 Renaming the method silently emptied the panel entry, and the panel swallowed
6 the resulting AttributeError, so it looked like the command simply did nothing.
7
8 Now the label is a decorator on the method:
9
10 @command("Replace Node")
11 def replaceNode(self):
12 ...
13
14 and HCMaps is generated by scanning the classes. There is no second place to
15 forget. Hotkeys (hc_hotkeys.json) and the XML menus still carry their own
16 entries, because Houdini owns those formats -- but `symbols()` lets a check
17 verify they point at commands that exist.
18
19 Where a command shows up is the class it is defined on. Commands on
20 HCSession and HCPane are always available; commands on a tab class appear
21 whenever the current tab is an instance of it, so a method on HCTab shows
22 everywhere and one on HCNetworkEditor only in network editors. Put a command
23 on the narrowest class whose every instance can run it.
24
25 When that varies within a class, name a predicate the same way a getter is
26 named:
27
28 @command("Path", state="isShowingPath", available="hasNetworkControls")
29 def toggleNetworkControls(self): ...
30
31 The panel leaves the command out of a tab whose predicate returns false.
32 There used to be a `tabs=("HCPathTab", ...)` filter of .type() strings
33 instead -- a second scoping mechanism beside the class tree, which drifted
34 from it: Pin and Path were both missing from network editors because the
35 list named the two types someone had in front of them at the time.
36
37 A command can also describe the control the panel should draw for it. There
38 are three kinds:
39
40 action -- a plain row. Enter runs the method and closes the panel.
41
42 toggle -- a row with a checkbox showing the current state. Declare the
43 getter that reads it:
44
45 @command("Grid", state="isGridVisible")
46 def toggleGrid(self): ...
47
48 Enter still runs the method and closes; clicking the checkbox
49 runs it and leaves the panel open.
50
51 choice -- a row with a dropdown. Declare the choices and the getter; the
52 method takes the chosen value:
53
54 @command("Grid Mode", state="gridMode",
55 choices=(("No Grid", "0"), ("Grid Points", "1")))
56 def setGridMode(self, mode): ...
57
58 Enter opens the dropdown; picking an entry applies it and
59 leaves the panel open.
60
61 The getter is named, not passed, for the same reason the label is a
62 decorator: it has to resolve on the same instance the method is bound to, and
63 naming it lets tools/check.py verify it exists.
64 """
65
66 import inspect
67
68 ATTR = "_hc_command"
69
70 KINDS = ("action", "toggle", "choice")
71
72
73 class Command:
74 __slots__ = ("label", "help", "state", "choices", "available", "kind")
75
76 def __init__(self, label, help=None, state=None, choices=None,
77 available=None):
78 self.label = label
79 self.help = help
80 # Name of a method on the instance that says whether this particular
81 # tab can run the command, or None when every instance of the class
82 # can. hasNetworkControls() is the worked example: some tabs of the
83 # plain HCTab kind have a path bar and some do not.
84 self.available = available
85 # Name of the method that reads the current value, on the same
86 # instance the command is bound to. Its return is a bool for toggles
87 # and one of the choice values for choices.
88 self.state = state
89 # ((label, value), ...) for choice commands.
90 self.choices = tuple(tuple(c) for c in choices) if choices else None
91 if self.choices is not None:
92 if self.state is None:
93 raise ValueError(f"{label!r}: choices need a state getter")
94 for choice in self.choices:
95 if len(choice) != 2:
96 raise ValueError(f"{label!r}: choices are (label, value) pairs")
97 self.kind = "choice"
98 elif self.state is not None:
99 self.kind = "toggle"
100 else:
101 self.kind = "action"
102
103 def __repr__(self):
104 return f"<Command {self.label!r} {self.kind}>"
105
106
107 def command(label, help=None, state=None, choices=None, available=None):
108 """Expose the decorated method in the HC Panel under `label`."""
109 def decorate(fn):
110 setattr(fn, ATTR, Command(label, help=help, state=state,
111 choices=choices, available=available))
112 return fn
113 return decorate
114
115
116 def declared(cls):
117 """{label: (method name, Command)} for commands on `cls`, inherited included."""
118 found = {}
119 for name in dir(cls):
120 if name.startswith("__"):
121 continue
122 try:
123 attr = getattr(cls, name)
124 except AttributeError:
125 continue
126 spec = getattr(attr, ATTR, None)
127 if isinstance(spec, Command):
128 found[spec.label] = (name, spec)
129 return found
130
131
132 def asBool(value):
133 """Coerce a state getter's return to a bool.
134
135 Houdini prefs come back as the strings '0' and '1', and bool('0') is
136 True -- which is how a checkbox would end up ticked for every toggle
137 that reads a pref. Only the text of the string counts.
138 """
139 if isinstance(value, str):
140 return value.strip().lower() not in ("", "0", "false", "off", "no")
141 return bool(value)
142
143
144 class Bound:
145 """A command bound to an instance.
146
147 Calling it runs the method, exactly like the bound method it replaces --
148 HCMaps used to hand out bare bound methods and the panel just called
149 them. On top of that it can read the current state for the panel's
150 control, and apply a chosen value.
151 """
152
153 __slots__ = ("label", "spec", "instance", "method")
154
155 def __init__(self, label, spec, instance, method):
156 self.label = label
157 self.spec = spec
158 self.instance = instance
159 self.method = method
160
161 @property
162 def kind(self):
163 return self.spec.kind
164
165 @property
166 def choices(self):
167 return self.spec.choices
168
169 def __call__(self, *args, **kwargs):
170 return self.method(*args, **kwargs)
171
172 def state(self):
173 """The current value, or None for an action.
174
175 A bool for a toggle, one of the choice values for a choice. A getter
176 that raises is left to raise: the panel disables the control and
177 reports it, which beats a checkbox quietly showing the wrong state.
178 """
179 if self.spec.state is None:
180 return None
181 value = getattr(self.instance, self.spec.state)()
182 if self.kind == "toggle":
183 return asBool(value)
184 return value
185
186 def set(self, value):
187 """Apply a choice value."""
188 return self.method(value)
189
190 def __repr__(self):
191 return f"<Bound {self.label!r} {self.kind} on {type(self.instance).__name__}>"
192
193
194 def bind(instance):
195 """{label: Bound} for the commands on `instance` that apply.
196
197 A command whose `available` predicate returns false on this instance is
198 left out. A predicate that raises is left to raise, like a state getter:
199 the caller sees the failure instead of a command quietly missing.
200 """
201 if instance is None:
202 return {}
203 bound = {}
204 for label, (name, spec) in declared(type(instance)).items():
205 if spec.available is not None and not getattr(instance, spec.available)():
206 continue
207 method = getattr(instance, name, None)
208 if method is not None:
209 bound[label] = Bound(label, spec, instance, method)
210 return bound
211
212
213 def labels(cls):
214 return sorted(declared(cls))
215
216
217 def verify(cls):
218 """Problems with the controls declared on `cls`, as a list of strings.
219
220 Empty when every state getter and availability predicate names a
221 callable on the class and every choice command's method accepts the
222 chosen value. tools/check.py runs this over every wrapper class.
223 """
224 problems = []
225 for label, (name, spec) in declared(cls).items():
226 for role, getter_name in (("state getter", spec.state),
227 ("availability predicate", spec.available)):
228 if getter_name is not None and not callable(getattr(cls, getter_name, None)):
229 problems.append(f"{cls.__name__}.{name} ({label!r}): "
230 f"{role} {getter_name!r} does not exist")
231 method = getattr(cls, name)
232 try:
233 params = [p for p in inspect.signature(method).parameters.values()
234 if p.name != "self"]
235 except (TypeError, ValueError):
236 continue
237 required = [p for p in params if p.default is p.empty
238 and p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)]
239 if spec.kind == "choice":
240 positional = [p for p in params
241 if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)]
242 if len(required) > 1 or not positional:
243 problems.append(f"{cls.__name__}.{name} ({label!r}): "
244 f"a choice command takes exactly one value")
245 elif required:
246 problems.append(f"{cls.__name__}.{name} ({label!r}): "
247 f"the panel calls it with no arguments")
248 return problems