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