git.lucas.co / hou-control
SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git

python3.13libs/hc/hcsession.py (41.5K)

   1 import hou
   2 from PySide6.QtWidgets import QApplication
   3 from PySide6.QtCore import QTimer
   4 from .hcbindings import HCBindings
   5 from .hcnetworkeditor import HCNetworkEditor
   6 from .hcsceneviewer import HCSceneViewer
   7 from .hcpane import HCPane
   8 from .hcpathtab import HCPathTab
   9 from .hctab import HCTab
  10 from .hcwidgets import HCWidgets
  11 from .hccommands import command
  12 
  13 
  14 SETTINGS_PANEL_SIZE = (720, 600)
  15 SPREADSHEET_PANEL_SIZE = (1400, 900)
  16 
  17 # (panel label, hou.updateMode attribute name) for the Update Mode dropdown.
  18 UPDATE_MODES = (
  19     ("Auto", "AutoUpdate"),
  20     ("Manual", "Manual"),
  21     ("On Mouse Up", "OnMouseUp"),
  22 )
  23 
  24 
  25 def floatWindow(window, size=None):
  26     """Make a Houdini floating panel's toplevel read as a dialog to the WM.
  27 
  28     Houdini creates every floating panel as a plain _NET_WM_WINDOW_TYPE_NORMAL
  29     toplevel with no transient parent, so a tiling compositor (wlroots/niri
  30     here) tiles it like any other window -- which is why HC Settings came up
  31     the same size as the main Houdini window no matter what `size` was passed
  32     to createFloatingPanel(). Qt.Dialog plus a transient parent makes the
  33     compositor float it instead and honour the requested size.
  34 
  35     Changing the flag re-creates the native window, so it is only done once;
  36     on an already-converted window this is a no-op and the panel does not
  37     flicker.
  38     """
  39     from PySide6 import QtCore
  40 
  41     # Qt.Dialog is Qt.Window|0x2, so a plain `flags & Qt.Dialog` test is true
  42     # for every toplevel. Mask off the hint bits and compare the type.
  43     if window.windowFlags() & QtCore.Qt.WindowType_Mask == QtCore.Qt.Dialog:
  44         return
  45 
  46     window.hide()
  47     window.setWindowFlag(QtCore.Qt.Dialog, True)
  48     handle = window.windowHandle()
  49     main = hou.qt.mainWindow()
  50     main_handle = main.windowHandle() if main is not None else None
  51     if handle is not None and main_handle is not None:
  52         handle.setTransientParent(main_handle)
  53     if size is not None:
  54         window.resize(*size)
  55 
  56 
  57 def parseFileHistory(text):
  58     """Paths of the HIP block of a file.history, most recent first.
  59 
  60     Houdini writes blocks as ``HIP\n{\n<path>\n...\n}`` and appends the
  61     newest path at the end; a path opened again is listed once.
  62     """
  63     paths = []
  64     block = None
  65     for line in text.splitlines():
  66         line = line.strip()
  67         if block is None:
  68             if line and line not in ("{", "}"):
  69                 block = line  # a block name: HIP, OTL, ...
  70             continue
  71         if line == "{":
  72             continue
  73         if line == "}":
  74             block = None
  75             continue
  76         if block == "HIP" and line:
  77             paths.append(line)
  78     seen = set()
  79     ordered = []
  80     for path in reversed(paths):
  81         if path not in seen:
  82             seen.add(path)
  83             ordered.append(path)
  84     return ordered
  85 
  86 
  87 class HCSession:
  88     def __init__(self):
  89         return
  90 
  91 
  92     """ Return HC Objects """
  93 
  94     def allNetworkEditors(self):
  95         network_editors = []
  96         for tab in self.allTabs():
  97             if tab.type() == 'HCNetworkEditor':
  98                 network_editors.append(tab)
  99         return network_editors
 100 
 101     def allPanes(self):
 102         panes = []
 103         seen = set()
 104         for pane in self.desktop().panes():
 105             if pane.id() not in seen:
 106                 panes.append(HCPane(pane))
 107                 seen.add(pane.id())
 108         for fp in hou.ui.floatingPanels():
 109             if fp.qtParentWindow() is None:
 110                 continue
 111             for pane in fp.panes():
 112                 if pane.id() not in seen:
 113                     panes.append(HCPane(pane))
 114                     seen.add(pane.id())
 115         return panes
 116 
 117     def allSceneViewers(self):
 118         scene_viewers = []
 119         for tab in self.allTabs():
 120             if tab.type() == 'HCSceneViewer':
 121                 scene_viewers.append(tab)
 122         return scene_viewers
 123 
 124     def allTabs(self):
 125         tabs = []
 126         for pane in self.allPanes():
 127             for hou_tab in pane.hou_pane.tabs():
 128                 tabs.append(pane.convertTab(hou_tab))
 129         return tabs
 130 
 131     def allViewports(self):
 132         viewports = []
 133         scene_viewers = self.allSceneViewers()
 134         for scene_viewer in scene_viewers:
 135             for viewport in scene_viewer.allViewports():
 136                 viewports.append(viewport)
 137         return viewports
 138 
 139     def currentPane(self):
 140         hou_pane = self._paneFromQtFocus()
 141         if hou_pane is None:
 142             hou_pane = hou.ui.paneUnderCursor()
 143         if hou_pane is None:
 144             hou_pane = self._floatingPaneUnderCursor()
 145         return HCPane(hou_pane) if hou_pane is not None else None
 146 
 147     def _paneFromQtFocus(self):
 148         """The pane whose area encloses the focused Qt widget, or None.
 149 
 150         Only a widget that sits entirely inside one pane counts -- a Python
 151         panel such as HC Settings, say. Houdini draws its native panes into a
 152         single RE_Window widget spanning the whole desktop, and that is what
 153         has focus while you work in a network editor or scene viewer. Testing
 154         that widget's centre used to hand back whichever pane happened to lie
 155         under the middle of the window, so the HC Panel listed the scene
 156         viewer's commands no matter which pane it was opened over. A widget
 157         that no single pane encloses says nothing about where the user is,
 158         and currentPane() falls through to the pane under the cursor.
 159         """
 160         app = QApplication.instance()
 161         if app is None:
 162             return None
 163 
 164         focus_widget = app.focusWidget()
 165         if focus_widget is None:
 166             return None
 167 
 168         top_left = focus_widget.mapToGlobal(focus_widget.rect().topLeft())
 169         bottom_right = focus_widget.mapToGlobal(focus_widget.rect().bottomRight())
 170 
 171         for pane in self.allPanes():
 172             try:
 173                 geometry = pane.qtScreenGeometry()
 174                 if geometry.contains(top_left) and geometry.contains(bottom_right):
 175                     return pane.hou_pane
 176             except Exception:
 177                 continue
 178         return None
 179 
 180     def _floatingPaneUnderCursor(self):
 181         from PySide6.QtGui import QCursor
 182         pos = QCursor.pos()
 183         for fp in hou.ui.floatingPanels():
 184             qw = fp.qtParentWindow()
 185             if qw is None or not qw.geometry().contains(pos):
 186                 continue
 187             panes = fp.panes()
 188             if panes:
 189                 return panes[0]
 190         return None
 191 
 192     def currentTab(self):
 193         pane = self.currentPane()
 194         return pane.currentTab() if pane is not None else None
 195 
 196     def mainWindowGeometry(self):
 197         """The main Houdini window's frame, in the same screen space as
 198         hou.Pane.qtScreenGeometry(), or None when there is no main window."""
 199         main = hou.qt.mainWindow()
 200         return main.frameGeometry() if main is not None else None
 201 
 202 
 203     """ Windows """
 204 
 205     @command("Color Editor")
 206     def colorEditor(self):
 207         hou.ui.selectColor()
 208 
 209     @command("Floating Parameter Editor")
 210     def floatingParameterEditor(self):
 211         tab = self.currentTab()
 212         if tab is None or tab.type() != 'HCNetworkEditor':
 213             hou.ui.setStatusMessage('Not a network editor', hou.severityType.Error)
 214             return
 215         node = tab.currentNode()
 216         if node is None:
 217             hou.ui.setStatusMessage('No current node', hou.severityType.Warning)
 218             return
 219         hou.ui.showFloatingParameterEditor(node)
 220 
 221     def hcPanel(self):
 222         from .hcmaps import HCMaps
 223 
 224         pane = self.currentPane()
 225         tab = self.currentTab()
 226         list_dict = HCMaps().commands(self, pane, tab)
 227 
 228         # Centred on the Houdini window, not the pane the panel was opened
 229         # over: the pane still decides which commands are listed, but a
 230         # 900x600 dialog centred on a narrow pane hung off the window edge.
 231         panel = HCWidgets.SelectionDialog('hcpanel', list_dict, anchor_geometry=self.mainWindowGeometry())
 232         panel.show()
 233         panel.raise_()
 234         panel.activateWindow()
 235 
 236     @command("New File")
 237     def newFile(self):
 238         file_path = hou.ui.selectFile(
 239             title="New File", 
 240             file_type=hou.fileType.Hip, 
 241             chooser_mode=hou.fileChooserMode.Write
 242         )
 243         if file_path:
 244             QTimer.singleShot(0, lambda: self._executeNewFile(file_path))
 245 
 246     def _executeNewFile(self, file_path):
 247         file_path = self._normalizedHipPath(file_path)
 248         
 249         try:
 250             hou.hipFile.clear()
 251             hou.hipFile.save(file_path)
 252             self._verifyHipFileSaved(file_path, "Created new file")
 253         except (hou.Error, OSError) as e:
 254             hou.ui.displayMessage(f"Error creating file: {e}", severity=hou.severityType.Error)
 255 
 256     @command("Open File")
 257     def openFile(self):
 258         file_path = hou.ui.selectFile(title="Open File", file_type=hou.fileType.Hip)
 259         if file_path:
 260             QTimer.singleShot(0, lambda: self._executeOpenFile(file_path))
 261 
 262     def _executeOpenFile(self, file_path):
 263         try:
 264             hou.hipFile.load(file_path)
 265             hou.ui.setStatusMessage(f"Opened file: {file_path}")
 266         except hou.Error as e:
 267             hou.ui.displayMessage(f"Error opening file: {e}", severity=hou.severityType.Error)
 268 
 269     @command("Open Preferences")
 270     def openPreferences(self):
 271         hou.ui.openPreferences('ui', '')
 272 
 273     @command("Open Hotkey Editor")
 274     def openHotkeyEditor(self):
 275         # Edit > Hotkeys... is a compiled-in actionItem (h.hotkey_mgr) with
 276         # no HOM call behind it; the window it opens is this Python module.
 277         from hotkeys import mainwidget
 278         mainwidget.showHotkeyManagerWindow()
 279 
 280     def statusDialog(self):
 281         """The HC Status dialog if one has been created, else None."""
 282         from .hcstatus import HCStatus
 283         return hou.qt.mainWindow().findChild(HCStatus, HCStatus.OBJECT_NAME)
 284 
 285     def isStatusVisible(self):
 286         dialog = self.statusDialog()
 287         return dialog is not None and dialog.isVisible()
 288 
 289     @command("HC Status", state="isStatusVisible")
 290     def toggleStatus(self):
 291         from .hcstatus import HCStatus
 292         existing = self.statusDialog()
 293         if existing is None:
 294             existing = HCStatus()
 295         if existing.isVisible():
 296             existing.hide()
 297         else:
 298             existing.show()
 299             existing.raise_()
 300 
 301     def toggleStatusBar(self):
 302         from .hcstatusbar import HCStatusBar
 303         bar = HCStatusBar()
 304         if bar.isVisible():
 305             bar.hide()
 306         else:
 307             bar._ensureOverlay()
 308             bar.overlay.show()
 309             bar.overlay.raise_()
 310 
 311     def settingsTab(self):
 312         """The open HC Settings pane tab, or None.
 313 
 314         A PythonPanel tab is identified by its active interface, not by its
 315         type -- every Python Panel is a hou.paneTabType.PythonPanel, so the
 316         interface name is the only thing that distinguishes ours from any
 317         other. allPanes() already spans the desktop and the floating panels,
 318         so a settings tab the user has docked somewhere is found too.
 319         """
 320         from .hcsettings import HCSettingsPanel
 321         for pane in self.allPanes():
 322             for hou_tab in pane.hou_pane.tabs():
 323                 if hou_tab.type() != hou.paneTabType.PythonPanel:
 324                     continue
 325                 interface = hou_tab.activeInterface()
 326                 if interface is not None and interface.name() == HCSettingsPanel.INTERFACE_NAME:
 327                     return hou_tab
 328         return None
 329 
 330     @command("Open HC Settings")
 331     def openSettings(self):
 332         """Show the HC Settings panel, creating a floating one if none is open.
 333 
 334         The panel is a registered Python Panel interface
 335         (python_panels/hc_settings.pypanel), so the user can also just add an
 336         "HC Settings" tab to any pane. This command keeps the old one-keypress
 337         behaviour: focus the existing tab, or float a new one.
 338         """
 339         from .hcsettings import HCSettingsPanel
 340 
 341         created = False
 342         hou_tab = self.settingsTab()
 343         if hou_tab is None:
 344             if hou.pypanel.interfaceByName(HCSettingsPanel.INTERFACE_NAME) is None:
 345                 hou.ui.setStatusMessage(
 346                     "HC Settings panel is not registered -- is "
 347                     "python_panels/hc_settings.pypanel on HOUDINI_PATH?",
 348                     hou.severityType.Error)
 349                 return None
 350             # Passing the interface name (rather than setting it afterwards)
 351             # also suppresses the Python Panel interface-picker toolbar.
 352             panel = self.desktop().createFloatingPanel(
 353                 hou.paneTabType.PythonPanel,
 354                 size=SETTINGS_PANEL_SIZE,
 355                 python_panel_interface=HCSettingsPanel.INTERFACE_NAME,
 356                 immediate=True)
 357             hou_tab = panel.paneTabs()[0]
 358             created = True
 359 
 360         hou_tab.setIsCurrentTab()
 361         window = hou_tab.qtParentWindow()
 362         if window is not None:
 363             # Only size it on the way up: re-invoking the command on an open
 364             # panel should focus it, not undo a resize the user has made.
 365             floatWindow(window, size=SETTINGS_PANEL_SIZE if created else None)
 366             window.show()
 367             window.raise_()
 368             window.activateWindow()
 369         return hou_tab
 370 
 371     def splitHandles(self):
 372         from .hcsplithandles import HCSplitHandles
 373         if not hasattr(hou.session, "_hc_split_handles"):
 374             hou.session._hc_split_handles = HCSplitHandles()
 375         return hou.session._hc_split_handles
 376 
 377     def isSplitHandlesShowing(self):
 378         # Read without creating: the panel asks on every open, and a state
 379         # query should not be what first builds the handle manager.
 380         handles = getattr(hou.session, "_hc_split_handles", None)
 381         return handles is not None and handles.isShowing()
 382 
 383     @command("Split Handles", state="isSplitHandlesShowing")
 384     def toggleSplitHandles(self):
 385         self.splitHandles().toggle()
 386 
 387     def spreadsheetPanel(self):
 388         """The floating panel holding a DetailsView tab, or None."""
 389         for fp in hou.ui.floatingPanels():
 390             for pane in fp.panes():
 391                 for tab in pane.tabs():
 392                     if tab.type() == hou.paneTabType.DetailsView:
 393                         return fp
 394         return None
 395 
 396     def isSpreadsheetOpen(self):
 397         return self.spreadsheetPanel() is not None
 398 
 399     @command("Spreadsheet", state="isSpreadsheetOpen")
 400     def toggleSpreadsheet(self):
 401         panel = self.spreadsheetPanel()
 402         if panel is not None:
 403             panel.close()
 404             hou.ui.setStatusMessage("Closed Spreadsheet")
 405             return
 406 
 407         # If not found, open a new floating one with DetailsView. It asks to
 408         # be floated: like HC Settings, a tiling WM would otherwise give the
 409         # spreadsheet the same tile -- and so the same size -- as the main
 410         # Houdini window.
 411         self.newFloatingPane(hou.paneTabType.DetailsView,
 412                              size=SPREADSHEET_PANEL_SIZE)
 413         hou.ui.setStatusMessage("Opened Spreadsheet")
 414 
 415     @command("Refresh Split Handles")
 416     def refreshSplitHandles(self):
 417         self.splitHandles().refresh()
 418 
 419     def rotateSplit(self):
 420         pane = hou.ui.paneUnderCursor()
 421         if pane is None:
 422             return
 423         parent = pane.getSplitParent()
 424         if parent is not None:
 425             parent.splitRotate()
 426 
 427     def windowWatcher(self):
 428         from .hcwindowwatcher import HCWindowWatcher
 429         if not hasattr(hou.session, "_hc_window_watcher"):
 430             hou.session._hc_window_watcher = HCWindowWatcher()
 431         return hou.session._hc_window_watcher
 432 
 433     def isWindowWatcherRunning(self):
 434         watcher = getattr(hou.session, "_hc_window_watcher", None)
 435         return watcher is not None and watcher.isRunning()
 436 
 437     @command("Window Watcher", state="isWindowWatcherRunning")
 438     def toggleWindowWatcher(self):
 439         self.windowWatcher().toggle()
 440 
 441 
 442     """ Settings """
 443 
 444     def isAutoSave(self):
 445         return hou.getPreference('autoSave')
 446 
 447     @command("Reload Colors")
 448     def reloadColorSchemes(self):
 449         """Reload the UI and viewport color schemes from config/.
 450 
 451         This used to call updateNodeColors() as well, so rereading a .hcs file
 452         also rewrote node colors throughout the open scene. They share a word
 453         and nothing else: one reloads config off disk, the other edits the hip.
 454         Update Node Colors is its own command and its own menu item.
 455         """
 456         hou.ui.reloadColorScheme()
 457         hou.ui.reloadViewportColorSchemes()
 458 
 459     @command("HC Info")
 460     def hcInfo(self):
 461         from . import __version__
 462         from PySide6.QtCore import Qt
 463         from PySide6.QtWidgets import (
 464             QDialog, QVBoxLayout, QLabel, QTextBrowser, QPushButton,
 465         )
 466 
 467         win = QDialog(hou.qt.mainWindow())
 468         win.setObjectName("hc_info_window")
 469         win.setWindowTitle("HC Info")
 470         win.setWindowFlags(Qt.Tool)
 471         win.resize(960, 740)
 472 
 473         layout = QVBoxLayout(win)
 474 
 475         header = QLabel(f"<b>hou-control</b> v{__version__}")
 476         header.setStyleSheet("font-size: 16px; padding-bottom: 6px;")
 477         layout.addWidget(header)
 478 
 479         info = QTextBrowser()
 480         info.setReadOnly(True)
 481         info.setOpenExternalLinks(True)
 482 
 483         hc_path = hou.getenv("HC_PATH") or "(unknown)"
 484         parts = [
 485             f"<b>HC_PATH:</b> {hc_path}",
 486             f"<b>Houdini:</b> {hou.applicationVersionString()}",
 487             "",
 488             "<b>Known limitations</b>",
 489             "",
 490             "<i>Color scheme switching</i>",
 491             "Houdini 21 has no runtime API to switch UI color schemes. "
 492             "hou.ui.reloadColorScheme() only re-reads the current .hcs "
 493             "from disk (used for live-editing). The 'colors.scheme' "
 494             "preference controls the scheme loaded at startup. Use "
 495             "Edit > Preferences > UI to change it.",
 496             "",
 497             "<i>Gestures (removed)</i>",
 498             "Multi-touch pinch-to-zoom was attempted via a global Qt "
 499             "event filter (HCGestureFilter) but never worked reliably. "
 500             "The gesture code caused launch segfaults and was removed.",
 501         ]
 502         info.setHtml("<br>".join(parts))
 503 
 504         layout.addWidget(info)
 505 
 506         close_btn = QPushButton("Close")
 507         close_btn.clicked.connect(win.close)
 508         layout.addWidget(close_btn)
 509 
 510         win.show()
 511         win.raise_()
 512         win.activateWindow()
 513 
 514     @command("Reload HC")
 515     def reloadHC(self):
 516         import sys
 517         from PySide6.QtWidgets import QWidget
 518         # Close any HC-owned Qt widgets that would dangle with stale class refs
 519         main = hou.qt.mainWindow()
 520         for name in ("hc_status_dialog", "hc_status_overlay"):
 521             for w in main.findChildren(QWidget, name):
 522                 w.hide()
 523                 w.setParent(None)
 524                 w.deleteLater()
 525         # The view-regions poll is a callback from the module about to be
 526         # dropped; take it out first and start a fresh one below.
 527         from .hcviewregions import stop as _stop_view_regions
 528         _stop_view_regions()
 529         # Drop every hc.* module so the next import reloads from disk
 530         removed = [m for m in list(sys.modules) if m == "hc" or m.startswith("hc.")]
 531         for m in removed:
 532             del sys.modules[m]
 533         import hc
 534         # nodegraphhooks is Houdini's module, not hc's, but it holds
 535         # `from hc import HCNetworkEditor` -- left alone it keeps dispatching
 536         # every network editor event into the class object just dropped.
 537         hooks = sys.modules.get("nodegraphhooks")
 538         if hooks is not None:
 539             import importlib
 540             importlib.reload(hooks)
 541         hou.ui.setStatusMessage(f"Reloaded hc ({len(removed)} modules)")
 542         # Restore the status bar overlay (destroyed above)
 543         from .hcstatusbar import HCStatusBar
 544         HCStatusBar().show()
 545         from .hcviewregions import HCViewRegions
 546         HCViewRegions().start()
 547         # An open HC Settings tab holds a widget built from the class object of
 548         # the module we just dropped, so it would keep running pre-reload code.
 549         # reloadActiveInterface() re-runs the pypanel's onCreateInterface()
 550         # against the freshly imported module. Unsaved edits in it are lost,
 551         # which is the same trade the status bar teardown above makes.
 552         hou_tab = self.settingsTab()
 553         if hou_tab is not None:
 554             hou_tab.reloadActiveInterface()
 555 
 556     @command("Reload Hotkeys")
 557     def reloadHotkeys(self):
 558         HCBindings().load()
 559 
 560     def initializeNetworkEditors(self):
 561         initialized = 0
 562         for editor in self.allNetworkEditors():
 563             try:
 564                 editor.initialize()
 565                 initialized += 1
 566             except Exception as e:
 567                 print(f"[HCSession] Failed to initialize network editor: {e!r}")
 568         return initialized
 569 
 570     def _deferToEventLoop(self, callback_name, fn):
 571         """Run fn once on the next event-loop turn.
 572 
 573         The pending callback is published on hou.session under callback_name
 574         so a repeat call replaces it rather than stacking a second run.
 575         Headless (hython running 456.py for a batch load) there is no event
 576         loop and no pane to act on, so this is a no-op there.
 577         """
 578         if not hou.isUIAvailable():
 579             return
 580         existing = getattr(hou.session, callback_name, None)
 581         if existing is not None:
 582             try:
 583                 hou.ui.removeEventLoopCallback(existing)
 584             except Exception:
 585                 pass
 586 
 587         def _callback():
 588             try:
 589                 fn()
 590             finally:
 591                 try:
 592                     hou.ui.removeEventLoopCallback(_callback)
 593                 except Exception:
 594                     pass
 595                 if getattr(hou.session, callback_name, None) is _callback:
 596                     delattr(hou.session, callback_name)
 597 
 598         setattr(hou.session, callback_name, _callback)
 599         hou.ui.addEventLoopCallback(_callback)
 600 
 601     def initializeNetworkEditorsDeferred(self):
 602         self._deferToEventLoop("_hc_init_network_editors_callback",
 603                                self.initializeNetworkEditors)
 604 
 605     def resetHcnetcursors(self):
 606         reset = 0
 607         for editor in self.allNetworkEditors():
 608             try:
 609                 editor.resetHcnetcursor()
 610                 reset += 1
 611             except Exception as e:
 612                 print(f"[HCSession] Failed to reset hcnetcursor: {e!r}")
 613         return reset
 614 
 615     def resetHcnetcursorsDeferred(self):
 616         """456.py runs while the load is still settling; the editors show the
 617         loaded file's networks by the next event-loop turn."""
 618         self._deferToEventLoop("_hc_reset_hcnetcursors_callback",
 619                                self.resetHcnetcursors)
 620 
 621     def updateNodeColors(self):
 622         """Recolor HC-managed nodes to the configured default.
 623 
 624         A node counts as managed while its `hc_custom_color` userdata still
 625         records the color HC last wrote to it. Color it any other way -- Set
 626         Node Colors, Houdini's own color palette, a paste from another scene --
 627         and the record stops matching, so the node is left alone from then on.
 628 
 629         The tag used to be a bare "1" with no record of the color, and this
 630         method recolored every tagged node unconditionally. Since 456.py calls
 631         it on every hip load, a hand-picked color lasted exactly until the next
 632         time the file was opened.
 633         """
 634         from .hcsettings import HCSettings, colorsMatch, parseHex
 635         settings = HCSettings()
 636         if not settings.nodeColoringEnabled():
 637             # Nodes already carrying a tag keep it, so turning coloring back on
 638             # resumes maintaining exactly the nodes it was maintaining before.
 639             return 0
 640         new_color = settings.nodeColor()
 641         new_hex = settings.nodeColorHex()
 642 
 643         # Decide first, write second. Writing nothing when nothing changed is
 644         # the point -- every setColor marks the hip modified, so an
 645         # unconditional pass left a scene you had only opened asking to be
 646         # saved -- and collecting the work first means the undo group below is
 647         # never opened on a scene that needs none.
 648         pending = []
 649         # Nodes inside a locked HDA cannot be colored anyway. Skipping them at
 650         # the traversal rather than per node means the walk never expands an
 651         # asset's contents -- this runs on every hip load.
 652         for node in hou.node("/").allSubChildren(recurse_in_locked_nodes=False):
 653             tag = node.userData("hc_custom_color")
 654             if tag is None:
 655                 continue
 656             try:
 657                 current = node.color()
 658             except hou.Error:
 659                 continue
 660             recorded = parseHex(tag)
 661             if recorded is None:
 662                 # Legacy "1" tags predate recording the color. Adopt them once
 663                 # -- this load still overwrites whatever they carry -- and they
 664                 # follow the rule above from then on.
 665                 if tag != "1":
 666                     continue
 667             elif not colorsMatch(current, hou.Color(recorded)):
 668                 continue  # colored by hand since HC last wrote it
 669 
 670             recolor = not colorsMatch(current, new_color)
 671             retag = tag != new_hex
 672             if recolor or retag:
 673                 pending.append((node, recolor, retag))
 674 
 675         count = 0
 676         if pending:
 677             # One undo entry for the whole scene, not one per node.
 678             with hou.undos.group("Update Node Colors"):
 679                 for node, recolor, retag in pending:
 680                     try:
 681                         if recolor:
 682                             node.setColor(new_color)
 683                             count += 1
 684                         if retag:
 685                             node.setUserData("hc_custom_color", new_hex)
 686                     except hou.Error:
 687                         continue
 688 
 689         # Deliberately silent. This runs on every hip load and at startup, so
 690         # anything it says is said when you opened a file rather than when you
 691         # asked for it -- and now that an unchanged scene writes nothing, what
 692         # it had to say was almost always "0 nodes". The count is returned for
 693         # callers that do want it.
 694         return count
 695 
 696     def keycam(self):
 697         tab = self.currentTab()
 698         if tab is not None and tab.type() == 'HCSceneViewer':
 699             tab.keycam()
 700             return
 701         scene_viewers = self.allSceneViewers()
 702         if scene_viewers:
 703             scene_viewers[0].keycam()
 704             return
 705         hou.ui.setStatusMessage('No scene viewer available', hou.severityType.Error)
 706 
 707     @command("Reload Keycam")
 708     def reloadKeycam(self):
 709         hou.ui.reloadViewerState('keycam')
 710 
 711     def updateMode(self):
 712         """The current update mode's name: 'AutoUpdate', 'Manual', 'OnMouseUp'."""
 713         return hou.updateModeSetting().name()
 714 
 715     @command("Update Mode", state="updateMode", choices=UPDATE_MODES)
 716     def setUpdateMode(self, mode):
 717         """Set the update mode, by hou.updateMode value or by its name."""
 718         if isinstance(mode, str):
 719             mode = getattr(hou.updateMode, mode)
 720         hou.setUpdateMode(mode)
 721         hou.ui.setStatusMessage(f"Update mode: {mode.name()}")
 722 
 723     # The three below used to be separate panel entries. They are one
 724     # dropdown now; the methods stay for hotkeys and menus.
 725 
 726     def setUpdateModeAuto(self):
 727         self.setUpdateMode(hou.updateMode.AutoUpdate)
 728 
 729     def setUpdateModeManual(self):
 730         self.setUpdateMode(hou.updateMode.Manual)
 731 
 732     def toggleUpdateMode(self):
 733         # hou.updateModeSetting is a function; the old code stringified the
 734         # function object and then called the lookup dict, so this never ran.
 735         if hou.updateModeSetting() == hou.updateMode.Manual:
 736             self.setUpdateMode(hou.updateMode.AutoUpdate)
 737         else:
 738             self.setUpdateMode(hou.updateMode.Manual)
 739 
 740     def triggerUpdate(self):
 741         hou.ui.triggerUpdate()
 742 
 743     @command("Autosave", state="isAutoSave")
 744     def toggleAutoSave(self):
 745         map = {'0': '1', '1': '0'}
 746         hou.setPreference('autoSave', map[hou.getPreference('autoSave')])
 747         from .hcstatusbar import HCStatusBar
 748         HCStatusBar().updateAutosave()
 749 
 750     def updateMainMenuBar(self):
 751         hou.ui.updateMainMenuBar()
 752 
 753 
 754     """ Utils """
 755 
 756     def projectPath(self):
 757         return hou.hipFile.path()
 758 
 759     def removeEventLoopCallbacks(self):
 760         callbacks = hou.ui.eventLoopCallbacks()
 761         for callback in callbacks:
 762             hou.ui.removeEventLoopCallback(callback)
 763 
 764     def recentHipFiles(self):
 765         """Houdini's recent hips, most recent first, existing files only.
 766 
 767         HOM exposes no recent-file list (nothing on hou.hipFile or hou.ui),
 768         so this reads the HIP block of $HOUDINI_USER_PREF_DIR/file.history,
 769         the file Houdini's own File > Open Recent Files strip is built from.
 770         """
 771         import os
 772         base = hou.getenv("HOUDINI_USER_PREF_DIR") or ""
 773         try:
 774             text = open(os.path.join(base, "file.history")).read()
 775         except OSError:
 776             return []
 777         return [f for f in parseFileHistory(text) if os.path.isfile(f)]
 778 
 779     @command("Open Recent")
 780     def openRecent(self):
 781         """A fuzzy picker over the recent hips; picking one loads it."""
 782         current = hou.hipFile.path()
 783         files = [f for f in self.recentHipFiles() if f != current]
 784         if not files:
 785             hou.ui.setStatusMessage("No other recent files", hou.severityType.Warning)
 786             return
 787 
 788         def load(path):
 789             try:
 790                 # Prompts to save unsaved changes first, like File > Open.
 791                 hou.hipFile.load(path)
 792             except hou.OperationInterrupted:
 793                 return  # cancelled at the save prompt
 794             except hou.LoadWarning as e:
 795                 hou.ui.setStatusMessage(str(e), hou.severityType.Warning)
 796             except hou.Error as e:
 797                 hou.ui.setStatusMessage(f"Cannot open {path}: {e}",
 798                                         hou.severityType.Error)
 799 
 800         import os
 801         names = [os.path.basename(f) for f in files]
 802         list_dict = {}
 803         for path, name in zip(files, names):
 804             # The directory only when the name alone would be ambiguous.
 805             label = path if names.count(name) > 1 else \
 806                 f"{name}    {os.path.basename(os.path.dirname(path))}"
 807             list_dict[label] = (lambda p=path: load(p))
 808 
 809         from .hcwidgets import HCWidgets
 810         dialog = HCWidgets.SelectionDialog(
 811             "Open Recent", list_dict, anchor_geometry=self.mainWindowGeometry())
 812         dialog.show()
 813         dialog.raise_()
 814         dialog.activateWindow()
 815 
 816     @command("Restart Houdini")
 817     def restartHoudini(self):
 818         import os
 819         import subprocess
 820         import sys
 821         
 822         # Save current state
 823         current_hip = hou.hipFile.path()
 824         # Houdini usually names untitled files "untitled.hip" (or .hiplc / .hipnc)
 825         if "untitled.hip" not in current_hip.lower():
 826             try:
 827                 hou.hipFile.save()
 828             except hou.Error:
 829                 pass # Continue with restart even if save fails
 830         
 831         # Get the path to the houdini executable
 832         # On Linux, this is usually in $HFS/bin/houdini
 833         hfs = os.environ.get("HFS")
 834         if hfs:
 835             executable = os.path.join(hfs, "bin", "houdini")
 836         else:
 837             executable = "houdini" # Fallback to path
 838 
 839         # Launch new process
 840         # start_new_session=True detaches the new process from the current one
 841         args = [executable, current_hip]
 842         subprocess.Popen(args, close_fds=True, start_new_session=True)
 843         
 844         # Force an immediate low-level exit to bypass any hangs
 845         import os
 846         os._exit(0)
 847 
 848     def _verifyHipFileSaved(self, file_path, action_label):
 849         import os
 850 
 851         saved_path = self._normalizedHipPath(file_path)
 852         if not os.path.isfile(saved_path):
 853             raise hou.OperationFailed(f"{action_label} reported success, but file was not created: {saved_path}")
 854 
 855         size = os.path.getsize(saved_path)
 856         if size <= 0:
 857             raise hou.OperationFailed(f"{action_label} reported success, but file is empty: {saved_path}")
 858 
 859         hou.ui.setStatusMessage(
 860             f"{action_label}: {saved_path} ({size:,} bytes)",
 861             severity=hou.severityType.Message,
 862         )
 863         return saved_path
 864 
 865     @command("Save")
 866     def save(self):
 867         try:
 868             hou.hipFile.save()
 869             self._verifyHipFileSaved(hou.hipFile.path(), "Saved")
 870         except (hou.Error, OSError) as e:
 871             hou.ui.displayMessage(f"Error saving file: {e}", severity=hou.severityType.Error)
 872 
 873     @command("Save As")
 874     def saveAs(self):
 875         file_path = hou.ui.selectFile(
 876             title="Save As", 
 877             file_type=hou.fileType.Hip, 
 878             chooser_mode=hou.fileChooserMode.Write
 879         )
 880         if file_path:
 881             QTimer.singleShot(0, lambda: self._executeSaveAs(file_path))
 882 
 883     def _normalizedHipPath(self, file_path):
 884         import os
 885 
 886         # Houdini's file chooser can return paths containing variables like
 887         # $HOME/$HIP.  hou.hipFile.save() usually expands them, but not in every
 888         # chooser/QTimer path, and the error message can remain misleadingly
 889         # literal.  Normalize before extension checks and saving.
 890         file_path = hou.text.expandString(file_path)
 891         file_path = os.path.expandvars(os.path.expanduser(file_path))
 892 
 893         if not any(file_path.endswith(ext) for ext in ('.hip', '.hiplc', '.hipnc')):
 894             file_path += '.hip'
 895         return file_path
 896 
 897     def _executeSaveAs(self, file_path):
 898         file_path = self._normalizedHipPath(file_path)
 899         try:
 900             hou.hipFile.save(file_path)
 901             self._verifyHipFileSaved(file_path, "Saved as")
 902         except (hou.Error, OSError) as e:
 903             hou.ui.displayMessage(f"Error saving file: {e}", severity=hou.severityType.Error)
 904 
 905 
 906     """ Layout """
 907 
 908     # def clearLayout(self):
 909     #     tabs = self.allTabs()
 910     #     for tab in tabs:
 911     #         if tab != tabs[0]:
 912     #             tab.close()
 913 
 914     def desktop(self):
 915         return hou.ui.curDesktop()
 916 
 917     # def layoutRoot(self):
 918     #     root = hou.ui.panes()[0].getSplitParent().getSplitParent()
 919 
 920     # def printLayout(self):
 921     #     panes = hou.ui.panes()
 922     #     root = panes[0].getSplitParent()
 923     #     lefts = []
 924     #     tops = []
 925     #     for pane in self.panes():
 926     #         geo = pane.qtScreenGeometry()
 927     #         lefts.append(geo.left())
 928     #         tops.append(geo.top())
 929     #     print(lefts)
 930     #     print(tops)
 931 
 932     # def renameTabs(self):
 933     #     tabs = hou.ui.paneTabs()
 934     #     i = 0
 935     #     for tab in tabs:
 936     #         tab.setName('placeholder' + str(i))
 937     #         i += 1
 938     #     i = 0
 939     #     for tab in tabs:
 940     #         tab.setName('panetab' + str(i))
 941     #         i += 1
 942 
 943 
 944     """ Visibility """
 945 
 946     def isVisibleMainMenu(self):
 947         value = hou.getPreference('showmenu.val')
 948         return int(value)
 949 
 950     def isVisibleMenus(self):
 951         """Whether any chrome is showing: main menu, tab chrome, or pane tabs."""
 952         if self.isVisibleMainMenu():
 953             return 1
 954         if any(tab.isChromeVisible() for tab in self.allTabs()):
 955             return 1
 956         if any(pane.isShowingTabs() for pane in self.allPanes()):
 957             return 1
 958         return 0
 959 
 960     @command("Hide Shelf")
 961     def hideShelf(self):
 962         self.desktop().shelfDock().show(0)
 963 
 964     def showMainMenu(self, value):
 965         hou.setPreference('showmenu.val', str(value))
 966 
 967     @command("Show Shelf")
 968     def showShelf(self):
 969         self.desktop().shelfDock().show(1)
 970 
 971     @command("Main Menu", state="isVisibleMainMenu")
 972     def toggleMainMenu(self):
 973         value = (self.isVisibleMainMenu()+1) % 2
 974         self.showMainMenu(value)
 975         tab = self.currentTab()
 976         if tab is not None and tab.type() == 'HCNetworkEditor':
 977             tab.toggleMenu()
 978 
 979     @command("All Menus", state="isVisibleMenus")
 980     def toggleMenus(self):
 981         visible = self.isVisibleMenus()
 982         show = not visible
 983         self.showMainMenu(int(show))
 984         for tab in self.allTabs():
 985             tab.showChrome(show)
 986         for pane in self.allPanes():
 987             pane.showTabs(show)
 988         # Apply (needs to be called twice for some reason)
 989         # hou.ui.setHideAllMinimizedStowbars(visible)
 990         # hou.ui.setHideAllMinimizedStowbars(visible)
 991 
 992     def toggleAllNetworkControls(self):
 993         visible = 0
 994         tabs = self.allTabs()
 995         for tab in tabs:
 996             if tab.isShowingNetworkControls():
 997                 visible = 1
 998         for tab in tabs:
 999             tab.showNetworkControls(not visible)
1000 
1001     def isStowbarsVisible(self):
1002         return not hou.ui.hideAllMinimizedStowbars()
1003 
1004     @command("Stowbars", state="isStowbarsVisible")
1005     def toggleStowbars(self):
1006         if hou.ui.hideAllMinimizedStowbars():
1007             hou.ui.setHideAllMinimizedStowbars(False)
1008         else:
1009             hou.ui.setHideAllMinimizedStowbars(True)
1010             hou.ui.setHideAllMinimizedStowbars(True)
1011 
1012     def toggleCurrentPaneTabs(self):
1013         pane = self.currentPane()
1014         if pane is not None:
1015             pane.toggleTabs()
1016 
1017     def _paneTabTypeDialog(self, title, action, anchor_geometry=None):
1018         type_map = HCTab(None).paneTabTypes()
1019         list_dict = {name: (lambda t=tab_type: action(t)) for name, tab_type in type_map.items()}
1020         dialog = HCWidgets.SelectionDialog(title, list_dict, anchor_geometry=anchor_geometry)
1021         dialog.show()
1022         dialog.raise_()
1023         dialog.activateWindow()
1024         return dialog
1025 
1026     @command("New Tab")
1027     def newTabDialog(self):
1028         pane = self.currentPane()
1029         if pane is None:
1030             hou.ui.setStatusMessage("No pane under cursor", hou.severityType.Warning)
1031             return None
1032         anchor_geometry = pane.qtScreenGeometry()
1033         return self._paneTabTypeDialog("New Tab", pane.newTab, anchor_geometry=anchor_geometry)
1034 
1035     def newFloatingPane(self, tab_type, size=None):
1036         """Create a floating panel holding one tab of `tab_type`.
1037 
1038         Pass `size` for a panel that should be a free-floating utility window
1039         rather than another tile: it is handed to createFloatingPanel() and the
1040         toplevel is marked as a dialog (see floatWindow), without which a
1041         tiling WM resizes it to the main window's tile. Left None, the panel
1042         keeps Houdini's default behaviour -- which is what the New Floating
1043         Pane dialog wants, since those are full working panes.
1044         """
1045         kwargs = {"immediate": True}
1046         if size is not None:
1047             kwargs["size"] = size
1048         panel = self.desktop().createFloatingPanel(tab_type, **kwargs)
1049         panel.attachToDesktop(True)
1050         for pane in panel.panes():
1051             HCPane(pane).initializeObject(pane)
1052         if size is not None:
1053             tabs = panel.paneTabs()
1054             window = tabs[0].qtParentWindow() if tabs else None
1055             if window is not None:
1056                 floatWindow(window, size=size)
1057                 window.show()
1058                 window.raise_()
1059                 window.activateWindow()
1060         hou.ui.setStatusMessage('Created new floating pane', hou.severityType.Message)
1061         return panel
1062 
1063     @command("New Floating Pane")
1064     def newFloatingPaneDialog(self):
1065         pane = self.currentPane()
1066         anchor_geometry = pane.qtScreenGeometry() if pane is not None else None
1067         return self._paneTabTypeDialog("New Floating Pane", self.newFloatingPane, anchor_geometry=anchor_geometry)
1068 
1069     def isAnyPaneShowingTabs(self):
1070         return any(pane.isShowingTabs() for pane in self.allPanes())
1071 
1072     # This was listed as "Toggle All Paths", but it has only ever switched the
1073     # pane tab strips; the paths are toggleAllNetworkControls above.
1074     @command("All Pane Tabs", state="isAnyPaneShowingTabs")
1075     def toggleAllTabs(self):
1076         visible = self.isAnyPaneShowingTabs()
1077         for pane in self.allPanes():
1078             pane.showTabs(not visible)
1079 
1080     def cyclablePanes(self):
1081         """Every pane a warp could land in, in reading order.
1082 
1083         Houdini has no keyboard focus for panes: a pane-scoped hotkey goes
1084         to whichever pane is under the pointer, so "focusing" a pane means
1085         putting the pointer there. A stowed or collapsed pane reports a
1086         -1 by -1 rectangle and is left out; so is every other pane while
1087         one is maximized, since their rectangles are stale then.
1088         """
1089         panes = []
1090         for pane in self.allPanes():
1091             rect = pane.qtScreenGeometry()
1092             if rect.width() <= 0 or rect.height() <= 0:
1093                 continue
1094             panes.append((rect.top(), rect.left(), pane))
1095         maximized = [entry for entry in panes if entry[2].isMaximized()]
1096         if maximized:
1097             panes = maximized
1098         panes.sort(key=lambda entry: entry[:2])
1099         return [entry[2] for entry in panes]
1100 
1101     def _cyclePane(self, step):
1102         panes = self.cyclablePanes()
1103         if not panes:
1104             return None
1105         current = self.currentPane()
1106         ids = [pane.hou_pane.id() for pane in panes]
1107         if current is not None and current.hou_pane.id() in ids:
1108             index = (ids.index(current.hou_pane.id()) + step) % len(panes)
1109         else:
1110             index = 0 if step > 0 else len(panes) - 1
1111         target = panes[index]
1112         # Through the compositor: an X warp alone leaves the visible cursor
1113         # where it was (see hcviewregions.move_pointer).
1114         from .hcviewregions import move_pointer
1115         move_pointer(target.qtScreenGeometry().center())
1116         return target
1117 
1118     @command("Next Pane")
1119     def focusNextPane(self):
1120         """Warp the pointer to the centre of the next pane in reading order."""
1121         self._cyclePane(1)
1122 
1123     @command("Previous Pane")
1124     def focusPrevPane(self):
1125         """Warp the pointer to the centre of the previous pane in reading order."""
1126         self._cyclePane(-1)