Skip to content

Instantly share code, notes, and snippets.

@kickbase
Created July 28, 2026 06:52
Show Gist options
  • Select an option

  • Save kickbase/782c108a218413712692eb650f079242 to your computer and use it in GitHub Desktop.

Select an option

Save kickbase/782c108a218413712692eb650f079242 to your computer and use it in GitHub Desktop.
[Houdini] [Python] Network editor hook for background drag move
"""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