git.lucas.co / hou-control
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