Skip to content

Instantly share code, notes, and snippets.

@kickbase
Created March 14, 2026 06:22
Show Gist options
  • Select an option

  • Save kickbase/1fda7f71770edb9f354ed8f8f9135bb1 to your computer and use it in GitHub Desktop.

Select an option

Save kickbase/1fda7f71770edb9f354ed8f8f9135bb1 to your computer and use it in GitHub Desktop.
[Houdini] [Python] Shelf tool for searching and adding hidden and deprecated nodes in the current context
import difflib
import hou
# Houdini に同梱の PySide を使う(PySide2 or PySide6)
try:
from PySide6 import QtCore, QtGui, QtWidgets
except ImportError:
from PySide2 import QtCore, QtGui, QtWidgets
def _filter_and_sort_hidden_list(query, hidden_list):
"""検索窓の文字列で候補を絞り込み、difflib で関連度順にソートする。
hidden_list は (name, description, deprecated, icon_name, replacement_text) のタプルのリスト。
返り値は同形式のリスト。
"""
query = query.strip().lower()
if not query:
return hidden_list
filtered = []
for item in hidden_list:
name, display_text, dep, _, _ = item
if query in name.lower() or query in (display_text or "").lower():
filtered.append(item)
if not filtered:
return []
def ratio(item):
_, display_text, _, _, _ = item
return difflib.SequenceMatcher(
None, query, (display_text or "").lower()
).ratio()
filtered.sort(key=ratio, reverse=True)
return filtered
def _get_hidden_node_list(category):
"""指定カテゴリでタブメニューに表示されないノードタイプの一覧を取得する。
全コンテキスト(SOP / Object / DOP 等)に対応。
各要素は (name, description, deprecated, icon_name, replacement_text) のタプル。
"""
if category is None:
return []
result = []
try:
node_types = category.nodeTypes()
except Exception:
return []
for name, node_type in node_types.items():
if not (hasattr(node_type, "hidden") and callable(node_type.hidden)):
continue
if not node_type.hidden():
continue
desc = node_type.description() if hasattr(node_type, "description") else ""
dep = node_type.deprecated() if hasattr(node_type, "deprecated") else False
icon_name = ""
if hasattr(node_type, "resolvedIcon") and callable(node_type.resolvedIcon):
icon_name = node_type.resolvedIcon() or ""
elif hasattr(node_type, "icon") and callable(node_type.icon):
icon_name = node_type.icon() or ""
replacement_text = ""
if (
dep
and hasattr(node_type, "deprecationInfo")
and callable(node_type.deprecationInfo)
):
try:
info = node_type.deprecationInfo()
if isinstance(info, dict) and "new_type" in info:
new_type = info["new_type"]
if hasattr(new_type, "description") and callable(
new_type.description
):
replacement_text = new_type.description() or ""
elif isinstance(new_type, str):
replacement_text = new_type
except Exception:
pass
result.append((name, desc or name, dep, icon_name, replacement_text))
result.sort(key=lambda x: (x[2], x[1].lower())) # 非推奨を後ろ、表示名でソート
return result
def _network_editor_preferred():
"""カーソル下がネットワークエディタならそれを返し、そうでなければ最初のネットワークエディタを返す。
ホットキー起動時はマウスを置いたエディタを優先するためにも使う。
"""
pane = hou.ui.paneTabUnderCursor()
if pane is not None and pane.type() == hou.paneTabType.NetworkEditor:
return pane
return hou.ui.paneTabOfType(hou.paneTabType.NetworkEditor)
def _run_add_node(node_type):
"""現在のネットワークエディタの pwd で opadd -e <node_type> を実行"""
editor = _network_editor_preferred()
if editor is None:
hou.ui.displayMessage("Please open a Network Editor.")
return
pwd = editor.pwd()
if pwd is None or pwd.childTypeCategory() is None:
hou.ui.displayMessage("Cannot add nodes in this network.")
return
hou.cd(pwd.path())
hou.hscript("opadd -e {}".format(node_type))
def show_dialog():
app = QtWidgets.QApplication.instance()
if app is None:
app = QtWidgets.QApplication([])
win = QtWidgets.QWidget()
win.setWindowTitle("Add Old Node")
layout = QtWidgets.QVBoxLayout(win)
win.setMinimumHeight(360)
initial_width = 320 # リストなし時。リストあり時は後で上書きする。
search_edit_ref = [None] # 表示後にフォーカスを当てるため参照を保持
# ネットワークエディタの現在のコンテキスト(pwd)でタブにないノードのみ取得(カーソル下を優先)
editor = _network_editor_preferred()
pwd = editor.pwd() if editor else None
current_category = pwd.childTypeCategory() if pwd else None
hidden_list = _get_hidden_node_list(current_category)
has_list = len(hidden_list) > 0
if has_list:
# 最長の表示テキストが収まる幅を算出(横スクロールを出さない)。最大幅でキャップする。
fm = QtGui.QFontMetrics(win.font())
max_text_w = max(
fm.horizontalAdvance(display_text or name)
for name, display_text, _, _, _ in hidden_list
)
# リストのアイテム余白・QListWidget padding・縦スクロールバー・レイアウトマージン
padding = 96
initial_width = min(max(max_text_w + padding, 320), 800)
win.setMinimumWidth(initial_width)
search_edit = QtWidgets.QLineEdit()
search_edit_ref[0] = search_edit
search_edit.setPlaceholderText("Type to search")
search_edit.setClearButtonEnabled(True)
layout.addWidget(search_edit)
list_widget = QtWidgets.QListWidget()
list_widget.setMinimumHeight(220)
layout.addWidget(list_widget)
# キーボード操作時も分かるよう、選択中アイテムの情報を下部ラベルに常時表示
info_label = QtWidgets.QLabel()
info_label.setWordWrap(True)
info_label.setMinimumHeight(36)
layout.addWidget(info_label)
# アイコンを icon_name でキャッシュし、検索のたびに再生成しない
_icon_cache = {}
_icon_size = 20
# アイコンがない項目用の透明プレースホルダ(テキスト開始位置を揃える)
_argb32 = (
getattr(QtGui.QImage.Format, "Format_ARGB32", None)
or getattr(QtGui.QImage.Format, "ARGB32", None)
or 4
) # 4 = ARGB32 in Qt5/Qt6
_transparent_image = QtGui.QImage(_icon_size, _icon_size, _argb32)
_transparent_image.fill(0)
_empty_icon = QtGui.QIcon(QtGui.QPixmap.fromImage(_transparent_image))
# deprecated なノードを視覚的に区別するための前景色(Disabled テキストカラーを流用)
_palette = list_widget.palette()
_deprecated_color = _palette.color(QtGui.QPalette.Disabled, QtGui.QPalette.Text)
_deprecated_brush = QtGui.QBrush(_deprecated_color)
def _icon_for(icon_name):
if not icon_name:
return None
if icon_name in _icon_cache:
return _icon_cache[icon_name]
if hasattr(hou.qt, "canCreateIcon") and not hou.qt.canCreateIcon(icon_name):
return None
try:
qicon = hou.qt.Icon(icon_name, _icon_size, _icon_size)
_icon_cache[icon_name] = qicon
return qicon
except Exception:
return None
def _info_text(name, replacement_text):
# Fix two-line layout to avoid label height jitter
line1 = "Internal Name: {}".format(name or "")
line2 = "Replacement: {}".format(replacement_text or "")
return "{}\n{}".format(line1, line2)
def update_active_info():
"""選択中アイテムの情報を下部ラベルに表示する(キーボード操作時用)"""
row = list_widget.currentRow()
if row < 0:
info_label.setText("")
return
item = list_widget.item(row)
if item is None:
info_label.setText("")
return
text = item.data(QtCore.Qt.ItemDataRole.UserRole + 1)
info_label.setText(text or "")
def apply_filter():
query = search_edit.text()
filtered = _filter_and_sort_hidden_list(query, hidden_list)
list_widget.clear()
for name, display_text, dep, icon_name, replacement_text in filtered:
item = QtWidgets.QListWidgetItem(display_text)
qicon = _icon_for(icon_name)
item.setIcon(qicon if qicon is not None else _empty_icon)
if dep:
item.setForeground(_deprecated_brush)
item.setData(QtCore.Qt.ItemDataRole.UserRole, name)
item.setData(
QtCore.Qt.ItemDataRole.UserRole + 1,
_info_text(name, replacement_text),
)
list_widget.addItem(item)
if filtered:
list_widget.setCurrentRow(0)
update_active_info()
list_widget.currentRowChanged.connect(lambda: update_active_info())
def run_selected():
row = list_widget.currentRow()
if row < 0:
return
item = list_widget.item(row)
if item is None:
return
node_type = item.data(QtCore.Qt.ItemDataRole.UserRole)
if not node_type:
return
_run_add_node(node_type)
win.close()
search_edit.textChanged.connect(apply_filter)
list_widget.itemDoubleClicked.connect(lambda: run_selected())
search_edit.returnPressed.connect(run_selected)
def search_edit_key_filter(obj, event):
"""検索窓にフォーカスがあるときも上下キーでリストの選択を移動する"""
if event.type() != event.Type.KeyPress:
return False
count = list_widget.count()
if count == 0:
return False
row = list_widget.currentRow()
if event.key() == QtCore.Qt.Key.Key_Down and row < count - 1:
list_widget.setCurrentRow(row + 1)
return True
if event.key() == QtCore.Qt.Key.Key_Up and row > 0:
list_widget.setCurrentRow(row - 1)
return True
return False
class SearchArrowFilter(QtCore.QObject):
def eventFilter(self, obj, event):
if search_edit_key_filter(obj, event):
return True
return super().eventFilter(obj, event)
search_edit.installEventFilter(SearchArrowFilter(search_edit))
class EnterKeyFilter(QtCore.QObject):
def eventFilter(self, obj, event):
if event.type() == event.Type.KeyPress and event.key() in (
QtCore.Qt.Key.Key_Return,
QtCore.Qt.Key.Key_Enter,
):
run_selected()
return True
return super().eventFilter(obj, event)
list_widget.installEventFilter(EnterKeyFilter(list_widget))
apply_filter()
else:
win.setMinimumWidth(320)
status_label = QtWidgets.QLabel()
if editor is None:
status_label.setText("Please open a Network Editor.")
else:
status_label.setText("No hidden node types in this network.")
layout.addWidget(status_label)
# 控えめな QSS(ダークテーマに合わせる)
win.setStyleSheet(
"""
QLineEdit {
padding: 6px 8px;
min-height: 1.2em;
}
QListWidget {
padding: 4px;
}
QListWidget::item {
padding: 4px 6px;
}
QListWidget::item:selected {
background: palette(highlight);
color: palette(highlighted-text);
}
"""
)
esc_shortcut = QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key.Key_Escape), win)
esc_shortcut.activated.connect(win.close)
# ウィンドウが非アクティブになったら閉じる
event_type_enum = getattr(QtCore.QEvent, "Type", None)
window_deactivate = (
getattr(event_type_enum, "WindowDeactivate", None) if event_type_enum else None
) or getattr(QtCore.QEvent, "WindowDeactivate", None)
class DeactivateCloseFilter(QtCore.QObject):
def __init__(self, window):
super().__init__(window)
self._window = window
def eventFilter(self, obj, event):
if window_deactivate is not None and event.type() == window_deactivate:
self._window.close()
return True
return super().eventFilter(obj, event)
win.installEventFilter(DeactivateCloseFilter(win))
win.setLayout(layout)
# 計算した幅で初期表示する(setMinimumWidth だけではレイアウトが狭く出ることがあるため)
win.resize(initial_width, 360)
# マウスカーソル位置をウィンドウの左上にする
win.move(QtGui.QCursor.pos())
win.show()
win.raise_()
win.activateWindow()
if search_edit_ref[0] is not None:
search_edit_ref[0].setFocus()
show_dialog()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment