Created
July 28, 2026 06:52
-
-
Save kickbase/782c108a218413712692eb650f079242 to your computer and use it in GitHub Desktop.
[Houdini] [Python] Network editor hook for background drag move
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """Network Editor: hold M and drag the background to move selected nodes. | |
| Install by copying (or linking) this file to: | |
| $HOUDINI_USER_PREF_DIR/python3.11libs/nodegraphhooks.py | |
| Restart Houdini (or open a new Network Editor pane) after installing. | |
| """ | |
| from __future__ import annotations | |
| import ctypes | |
| import sys | |
| import hou | |
| import nodegraphautoscroll as autoscroll | |
| import nodegraphbase as base | |
| import nodegraphstates as states | |
| import nodegraphutils as utils | |
| from canvaseventtypes import * | |
| HOTKEY_CATEGORY = "h.pane.wsheet.custom" | |
| HOTKEY_SYMBOL = HOTKEY_CATEGORY + ".move_selected" | |
| HOTKEY_CONTEXT = "h.pane.wsheet" | |
| HOTKEY_KEY = "M" | |
| # Keep in sync with nodegraph.py's initial setVolatileHotkeys list, plus ours. | |
| _DEFAULT_VOLATILE_HOTKEYS = ( | |
| "h.pane.wsheet.view_mode", | |
| "h.pane.wsheet.select_mode", | |
| "h.pane.wsheet.layout_mode", | |
| "h.pane.wsheet.flag1_mode", | |
| "h.pane.wsheet.flag2_mode", | |
| "h.pane.wsheet.flag3_mode", | |
| "h.pane.wsheet.flag4_mode", | |
| "h.pane.wsheet.bypass_mode", | |
| "h.pane.wsheet.visualize_mode", | |
| "h.pane.wsheet.cut_wires_mode", | |
| "h.pane.wsheet.stitch_mode", | |
| "h.pane.wsheet.drop_on_wire_mode", | |
| HOTKEY_SYMBOL, | |
| ) | |
| # If a drag is abandoned (Escape / M released mid-drag), restore from this. | |
| _active_drag = None | |
| _hotkey_ready = False | |
| _hotkey_defs = None | |
| def _safe_set_position(item, position): | |
| try: | |
| item.setPosition(position) | |
| return True | |
| except (hou.InvalidInput, hou.ObjectWasDeleted, hou.OperationFailed): | |
| return False | |
| def _is_m_physically_down(): | |
| """True if the M key is currently held (Windows fallback only).""" | |
| try: | |
| if sys.platform.startswith("win"): | |
| # VK_M == 0x4D | |
| return bool(ctypes.windll.user32.GetAsyncKeyState(0x4D) & 0x8000) | |
| except Exception: | |
| pass | |
| return False | |
| def _is_move_key_down(editor): | |
| if editor is not None: | |
| try: | |
| if editor.isVolatileHotkeyDown(HOTKEY_SYMBOL): | |
| return True | |
| except (hou.Error, AttributeError): | |
| pass | |
| return _is_m_physically_down() | |
| def _ensure_hotkey(): | |
| """Register Network Editor hotkey bound to M (idempotent).""" | |
| global _hotkey_ready, _hotkey_defs | |
| if _hotkey_ready: | |
| return | |
| installed = False | |
| try: | |
| defs = hou.PluginHotkeyDefinitions() | |
| defs.addCommandCategory( | |
| HOTKEY_CATEGORY, | |
| "Custom Network Editor", | |
| "Custom network editor actions.", | |
| ) | |
| defs.addCommand( | |
| HOTKEY_SYMBOL, | |
| "Move Selected Nodes", | |
| "Hold M and drag the background to move selected network items.", | |
| ) | |
| defs.addContext( | |
| HOTKEY_CATEGORY, | |
| "Custom Network Editor", | |
| "Custom network editor keys.", | |
| ) | |
| defs.addDefaultBinding(HOTKEY_CATEGORY, HOTKEY_SYMBOL, (HOTKEY_KEY,)) | |
| # Also bind in the Network Editor context so pane resolution picks it up. | |
| try: | |
| defs.addDefaultBinding(HOTKEY_CONTEXT, HOTKEY_SYMBOL, (HOTKEY_KEY,)) | |
| except hou.Error: | |
| pass | |
| hou.hotkeys.installDefinitions(defs) | |
| _hotkey_defs = defs | |
| installed = True | |
| except (hou.Error, AttributeError, TypeError, ValueError): | |
| installed = False | |
| if not installed: | |
| # Fallback for older hotkey APIs. | |
| try: | |
| hou.hotkeys.addContext( | |
| HOTKEY_CATEGORY, | |
| "Custom Network Editor", | |
| "Custom network editor keys.", | |
| ) | |
| except (hou.Error, AttributeError): | |
| pass | |
| try: | |
| hou.hotkeys.addCommand( | |
| HOTKEY_SYMBOL, | |
| "Move Selected Nodes", | |
| "Hold M and drag the background to move selected network items.", | |
| [HOTKEY_KEY], | |
| ) | |
| installed = True | |
| except (hou.Error, AttributeError, TypeError): | |
| pass | |
| try: | |
| hou.hotkeys.addAssignment(HOTKEY_CONTEXT, HOTKEY_SYMBOL, HOTKEY_KEY) | |
| installed = True | |
| except (hou.Error, AttributeError, TypeError): | |
| try: | |
| hou.hotkeys.addAssignment(HOTKEY_SYMBOL, HOTKEY_KEY) | |
| installed = True | |
| except (hou.Error, AttributeError, TypeError): | |
| pass | |
| # Only skip future attempts when something actually registered. | |
| _hotkey_ready = installed | |
| def _ensure_volatile(editor): | |
| """Re-apply volatile hotkeys. | |
| nodegraph.py resets the volatile list whenever its event coroutine | |
| restarts (Escape, network change, etc.). Always re-apply so M keeps | |
| receiving keydown/keyup after those events. | |
| """ | |
| try: | |
| editor.setVolatileHotkeys(list(_DEFAULT_VOLATILE_HOTKEYS)) | |
| except (hou.Error, AttributeError): | |
| pass | |
| def _restore_abandoned_drag(): | |
| """Revert positions if a previous drag never got a clean mouseup.""" | |
| global _active_drag | |
| if not _active_drag: | |
| return | |
| start_positions = _active_drag | |
| _active_drag = None | |
| with hou.undos.disabler(): | |
| for item, start_pos in start_positions.items(): | |
| _safe_set_position(item, start_pos) | |
| def _items_to_move(editor): | |
| """Selected movable items, skipping children of selected network boxes.""" | |
| pwd = editor.pwd() | |
| if pwd is None: | |
| return [] | |
| selected = list(pwd.selectedItems()) | |
| selected_boxes = {item for item in selected if isinstance(item, hou.NetworkBox)} | |
| def inside_selected_box(item): | |
| box = item.parentNetworkBox() | |
| while box is not None: | |
| if box in selected_boxes: | |
| return True | |
| box = box.parentNetworkBox() | |
| return False | |
| items = [] | |
| for item in selected: | |
| if not isinstance(item, hou.NetworkMovableItem): | |
| continue | |
| if isinstance(item, hou.NetworkBox): | |
| items.append(item) | |
| elif not inside_selected_box(item): | |
| items.append(item) | |
| return items | |
| class BackgroundMoveSelectedHandler(base.EventHandler): | |
| """LMB-drag handler that translates the current selection.""" | |
| def __init__(self, start_uievent): | |
| global _active_drag | |
| base.EventHandler.__init__(self, start_uievent) | |
| editor = start_uievent.editor | |
| self.start_network_pos = editor.posFromScreen(start_uievent.mousestartpos) | |
| self.items = _items_to_move(editor) | |
| self.start_positions = {item: item.position() for item in self.items} | |
| self.last_delta = hou.Vector2(0, 0) | |
| self.moved = False | |
| self._finished = False | |
| _active_drag = dict(self.start_positions) | |
| def _apply_preview(self, delta): | |
| with hou.undos.disabler(): | |
| for item, start_pos in self.start_positions.items(): | |
| _safe_set_position(item, start_pos + delta) | |
| def _restore_start(self): | |
| with hou.undos.disabler(): | |
| for item, start_pos in self.start_positions.items(): | |
| _safe_set_position(item, start_pos) | |
| def commit(self, editor=None): | |
| """Commit the drag as a single undo step, or revert if not moved.""" | |
| global _active_drag | |
| if self._finished: | |
| return | |
| self._finished = True | |
| _active_drag = None | |
| if not self.moved or not self.items: | |
| self._restore_start() | |
| return | |
| delta = self.last_delta | |
| if delta.length() < 1e-6: | |
| self._restore_start() | |
| return | |
| self._restore_start() | |
| with hou.undos.group("Move Selected Nodes", editor): | |
| for item, start_pos in self.start_positions.items(): | |
| _safe_set_position(item, start_pos + delta) | |
| def cancel(self): | |
| """Abandon the drag and restore original positions.""" | |
| global _active_drag | |
| if self._finished: | |
| return | |
| self._finished = True | |
| _active_drag = None | |
| self._restore_start() | |
| def handleEvent(self, uievent, pending_actions): | |
| if uievent.eventtype == "mousedrag": | |
| if not self.items: | |
| return self | |
| editor = uievent.editor | |
| autoscroll.startAutoScroll(self, uievent, pending_actions) | |
| mouse = editor.screenBounds().closestPoint(uievent.mousepos) | |
| current = editor.posFromScreen(mouse) | |
| delta = current - self.start_network_pos | |
| self.last_delta = delta | |
| # Honor Houdini's drag threshold so tiny jitters don't create undos. | |
| if getattr(uievent, "dragging", False) or delta.length() > 1e-4: | |
| self.moved = True | |
| self._apply_preview(delta) | |
| return self | |
| if uievent.eventtype == "mouseup": | |
| self.commit(uievent.editor) | |
| return None | |
| return self | |
| class MoveSelectedStateHandler(states.VolatileStateHandler): | |
| """Volatile M state: drag on empty background to move the selection.""" | |
| def __init__(self, start_uievent): | |
| states.VolatileStateHandler.__init__(self, start_uievent) | |
| editor = start_uievent.editor | |
| editor.setCursorMap({}) | |
| editor.setDefaultCursor(utils.theCursorArrowAll) | |
| editor.setLocatingEnabled(False) | |
| def getPrompt(self, uievent): | |
| return "Drag to move selected nodes.\nRelease M to exit." | |
| def handleStateCompleted(self): | |
| # M released (or state otherwise ending). Finish any in-progress drag. | |
| if isinstance(self.subhandler, BackgroundMoveSelectedHandler): | |
| self.subhandler.commit(self.start_uievent.editor) | |
| self.subhandler = None | |
| try: | |
| self.start_uievent.editor.setLocatingEnabled(True) | |
| except (hou.Error, AttributeError): | |
| pass | |
| def handleEvent(self, uievent, pending_actions): | |
| if ( | |
| self.subhandler is None | |
| and isinstance(uievent, MouseEvent) | |
| and uievent.mousestate.lmb | |
| and uievent.eventtype == "mousedown" | |
| ): | |
| self.subhandler = BackgroundMoveSelectedHandler(uievent) | |
| return self.sendEventToSubHandler(uievent, pending_actions) | |
| return states.VolatileStateHandler.handleEvent( | |
| self, uievent, pending_actions | |
| ) | |
| def createEventHandler(uievent, pending_actions): | |
| # Escape / context changes can abandon a drag without mouseup. | |
| _restore_abandoned_drag() | |
| _ensure_hotkey() | |
| editor = getattr(uievent, "editor", None) | |
| if editor is not None: | |
| _ensure_volatile(editor) | |
| # Preferred path: M as a volatile state (same pattern as Y / S). | |
| if isinstance(uievent, KeyboardEvent) and uievent.eventtype == "keydown": | |
| if hou.ui.isKeyMatch(uievent.key, HOTKEY_SYMBOL): | |
| return MoveSelectedStateHandler(uievent), True | |
| # Fallback: hold M and LMB-drag on empty background. | |
| # Physical key check is Windows-only; other platforms rely on volatile hotkeys. | |
| if ( | |
| isinstance(uievent, MouseEvent) | |
| and uievent.eventtype == "mousedown" | |
| and uievent.mousestate.lmb | |
| and uievent.selected is not None | |
| and uievent.selected.item is None | |
| and _is_move_key_down(editor) | |
| ): | |
| return BackgroundMoveSelectedHandler(uievent), True | |
| return None, False |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment