Created
March 14, 2026 06:22
-
-
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
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
| 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