SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
python3.13libs/nodegraphhooks.py (11.6K)
1 import hou
2 from canvaseventtypes import *
3 import nodegraphbase as base
4 import nodegraphdisplay as display
5 from hc import HCNetworkEditor
6 from hc import HCSettings
7 from hc import hcstate
8
9 # Modifier state belongs to the pane; the selection signature is compared
10 # against the hcnetcursor, which is per network, so it must match that scope.
11 _modifiers = hcstate.Store("nodegraph_modifiers", hcstate.PANE)
12 _selections = hcstate.Store("nodegraph_selection", hcstate.NETWORK)
13
14
15 def _syncSelection(hc_editor, editor):
16 """Refit the hcnetcursor when the selection envelope has changed."""
17 # Guard here rather than only at the call sites: this also runs from
18 # _PendingSelectionSyncAction, queued earlier and run later, so a call site
19 # check alone leaves the delayed path refitting a cursor nobody can see.
20 if not HCSettings().hcnetcursorEnabled():
21 return
22 signature = hc_editor.selectedNodesEnvelopeSignature()
23 if signature == _selections.get(editor):
24 return
25 _selections.set(editor, signature)
26 if signature:
27 hc_editor.fitHcnetcursorToSelectedNodes()
28 else:
29 # Preserve the current hcnetcursor size when the selection is cleared.
30 # Manual reset remains available via the reset command.
31 hc_editor.updateCurrentNodeOverlay()
32
33
34 class _PendingSelectionSyncAction(base.PendingDelayedAction):
35 def runDelayedAction(self):
36 _syncSelection(HCNetworkEditor(self.editor), self.editor)
37
38
39 # Houdini's event loop finishes pending actions with
40 #
41 # for action in pending_actions:
42 # if action.completeAction(uievent):
43 # pending_actions.remove(action)
44 #
45 # -- removal while iterating, which skips the entry after the one removed. Two
46 # hc actions completing on the same event therefore never both run on it: the
47 # second lingers and fires on the next such event with stale data. Everything
48 # hc wants done after a mouse action goes through the one class below, in
49 # order, and tools/check.py holds it to that.
50
51
52 class _PendingSnapAction(base.PendingAction):
53 """Sweep the network onto the grid once the action a key hit started has
54 finished.
55
56 The hook only sees an event when no handler is active, and the pending
57 actions run after the handler on every later event. A key hit is handled
58 to completion inside the same event, so this completes on the first call,
59 after layoutChildren or paste has placed its nodes.
60 """
61
62 def __init__(self, editor):
63 base.PendingAction.__init__(self)
64 self.editor = editor
65
66 def completeAction(self, uievent):
67 HCNetworkEditor(self.editor).sweepToGrid()
68 return True
69
70
71 class _PendingMouseAction(base.PendingAction):
72 """Everything hc does once the mouse action a mousedown started has ended.
73
74 Queued on the mousedown, the last event the hook sees before Houdini's
75 handler takes over; completes on the mouseup that handler consumes, after
76 applyAdjustments has written the new positions. First the grid sweep, then
77 -- when the press was a plain drag of one node -- the drop swap, which
78 must see the swept, final position, then the hcnetcursor refit.
79
80 The refit has to be here: a click selects its node inside Houdini's
81 NodeClickHandler on the mouseup, which the hook never sees, and the
82 delayed sync queued on the mousedown fires before that. Without this the
83 cursor only caught up on the next event to reach the hook -- the first
84 mouse move after the click.
85 """
86
87 def __init__(self, editor, swap=None):
88 base.PendingAction.__init__(self)
89 self.editor = editor
90 self.swap = swap # (node path, start position) or None
91
92 def completeAction(self, uievent):
93 if not (isinstance(uievent, MouseEvent) and uievent.eventtype == 'mouseup'):
94 return False
95 hc_editor = HCNetworkEditor(self.editor)
96 hc_editor.sweepToGrid()
97 # Alt was checked at mousedown too when this was a copy drag; this
98 # catches a modifier pressed mid-drag as well.
99 if self.swap is not None and not getattr(uievent.modifierstate, "alt", False):
100 hc_editor.swapDroppedNode(*self.swap)
101 _syncSelection(hc_editor, self.editor)
102 return True
103
104
105 # The located names Houdini's own NodeClickHandler treats as the node body;
106 # a press on an input, output or flag starts a wire or a toggle, not a move.
107 _NODE_BODY_SELECTORS = ('node', 'connectorarea', 'preview', 'footer',
108 'containerinput', 'containeroutput')
109
110
111 def _dropSwapFor(editor, uievent):
112 """(node path, start position) when this mousedown is a plain drag of one
113 node that may end in a swap, else None."""
114 if not HCSettings().dropSwapEnabled():
115 return None
116 mods = uievent.modifierstate
117 if getattr(mods, "alt", False) or getattr(mods, "ctrl", False) or getattr(mods, "shift", False):
118 return None # copy, tree or selection-modifying drags: not a plain move
119 if not getattr(uievent.mousestate, "lmb", False):
120 return None
121 selected = uievent.selected
122 if selected is None or selected.name not in _NODE_BODY_SELECTORS:
123 return None
124 node = selected.item
125 if not isinstance(node, hou.Node) or node.parent() != editor.pwd():
126 return None
127 # A press on one of several selected nodes drags the whole selection;
128 # which of them "landed on" what is ambiguous, so only a lone node swaps.
129 if node.isSelected() and len(editor.pwd().selectedItems()) > 1:
130 return None
131 pos = node.position()
132 return (node.path(), (pos[0], pos[1]))
133
134
135 def _queueMouseAction(editor, uievent, pending_actions):
136 for action in pending_actions:
137 if isinstance(action, _PendingMouseAction) and action.editor == editor:
138 return
139 pending_actions.append(_PendingMouseAction(editor, _dropSwapFor(editor, uievent)))
140
141
142 def _queueSnapSweep(editor, pending_actions):
143 if not HCSettings().gridSnapEnabled():
144 return
145 for action in pending_actions:
146 if isinstance(action, _PendingSnapAction) and action.editor == editor:
147 return
148 pending_actions.append(_PendingSnapAction(editor))
149
150
151 def _queueSelectionSync(editor, pending_actions):
152 for action in pending_actions:
153 if isinstance(action, _PendingSelectionSyncAction) and action.editor == editor:
154 return
155 # PendingDelayedAction takes (editor, delay) and gives delay no default, so
156 # it has to be passed. Dropping it raised TypeError out of
157 # createEventHandler on every mousedown and mouseup, and Houdini discards
158 # the event when the hook raises -- clicking a node did nothing at all.
159 pending_actions.append(_PendingSelectionSyncAction(editor, 0.0))
160
161
162 def createEventHandler(uievent, pending_actions):
163 editor = getattr(uievent, "editor", None)
164
165 # An editor can hand out an event while it has no network -- seen live as
166 # pwd() returning None while floating pane tabs were being created and
167 # closed. Everything below reads the network, and a traceback out of this
168 # hook makes Houdini drop the event, so let it have the event untouched.
169 if editor is not None and editor.pwd() is None:
170 return None, False
171
172 if editor is not None:
173 hc_editor = HCNetworkEditor(editor)
174 modifierstate = getattr(uievent, "modifierstate", None)
175 current_state = (
176 bool(getattr(modifierstate, "ctrl", False)),
177 bool(getattr(modifierstate, "shift", False)),
178 bool(getattr(modifierstate, "alt", False)),
179 )
180 if current_state != _modifiers.get(editor, (False, False, False)):
181 # Houdini repaints the graph when a modifier changes, which can
182 # drop the cursor's background image; force it back on.
183 hc_editor.refreshHcnetcursor()
184 _modifiers.set(editor, current_state)
185 else:
186 # Runs on every event, mouse moves included -- this is a no-op
187 # unless the drawn overlay would actually differ.
188 hc_editor.updateCurrentNodeOverlay()
189 # A node the Tab menu inserted on top of another needs the row below
190 # it moved out of the way; a dict lookup when nothing is pending.
191 hc_editor.finishPendingInsert(uievent)
192
193 if isinstance(uievent, MouseEvent):
194 if editor is not None and uievent.eventtype == 'mousedown':
195 _queueMouseAction(editor, uievent, pending_actions)
196 # Everything below drives the hcnetcursor. With it off there is nothing
197 # to move or refit: doing it anyway kept an invisible cursor tracking
198 # the mouse and the selection, so turning it back on jumped it to
199 # wherever you had last clicked.
200 if editor is not None and HCSettings().hcnetcursorEnabled():
201 if uievent.eventtype == 'mousedown' and not uievent.located:
202 hc_editor.moveHcnetcursorToPosition(uievent.mousepos)
203 _syncSelection(hc_editor, editor)
204 if uievent.eventtype in ('mousedown', 'mouseup', 'mousedoubleclick'):
205 _queueSelectionSync(editor, pending_actions)
206 # Ctrl+scroll zoom disabled
207 return None, False
208
209 if isinstance(uievent, KeyboardEvent):
210 # Force the unmodified f key to frame the hcnetcursor instead of
211 # Houdini's selection-based home/zoom behavior. This lives in the
212 # event hook (not only hc_hotkeys.json) so it takes effect immediately
213 # after Reload HC and does not depend on NetworkViewMenu.xml symbols
214 # being rebuilt by a Houdini restart.
215 key = str(getattr(uievent, "key", "")).lower()
216 rawkey = str(getattr(uievent, "rawkey", "")).lower()
217 eventtype = str(getattr(uievent, "eventtype", "")).lower()
218 if editor is not None and eventtype == 'keyhit':
219 _queueSnapSweep(editor, pending_actions)
220 if (
221 editor is not None
222 and (key == "f" or rawkey == "f")
223 # Without the cursor there is nothing to frame, and swallowing f
224 # here left the key doing nothing at all rather than falling back
225 # to Houdini's frame-selection.
226 and HCSettings().hcnetcursorEnabled()
227 ):
228 modifierstate = getattr(uievent, "modifierstate", None)
229 has_modifier = (
230 bool(getattr(modifierstate, "ctrl", False)) or
231 bool(getattr(modifierstate, "shift", False)) or
232 bool(getattr(modifierstate, "alt", False))
233 )
234 if not has_modifier and not (eventtype.endswith("up") or "release" in eventtype):
235 hc_editor.frameHcnetcursor()
236 return None, True
237 # Unmodified Tab opens the Tab menu for the hc cursor cell instead of
238 # the mouse position (NetworkViewMenu.xml's hc_add_node_at_cursor
239 # does the same once a restart has registered it; here it works
240 # straight after Reload HC). Any modifier, or the cursor turned off,
241 # leaves Tab to Houdini.
242 if (
243 editor is not None
244 and eventtype == 'keyhit'
245 and (key == "tab" or rawkey == "tab")
246 and HCSettings().hcnetcursorEnabled()
247 ):
248 modifierstate = getattr(uievent, "modifierstate", None)
249 has_modifier = (
250 bool(getattr(modifierstate, "ctrl", False)) or
251 bool(getattr(modifierstate, "shift", False)) or
252 bool(getattr(modifierstate, "alt", False))
253 )
254 if not has_modifier:
255 hc_editor.addNodeAtCursor(key=getattr(uievent, "rawkey", "Tab"))
256 return None, True
257 # Keyboard events are primarily handled via hc_hotkeys.json and
258 # NetworkViewMenu.xml, but this hook remains for future
259 # context-sensitive overrides.
260 return None, False
261
262 return None, False