SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
session: Open Recent command in the HC Panel
A fuzzy picker over Houdini's recent hips, newest first, existing files
only, the current one left out; picking one loads it with the usual
save prompt. HOM has no recent-file accessor, so the list is parsed
from the HIP block of $HOUDINI_USER_PREF_DIR/file.history, the file
Houdini's own Open Recent strip is built from.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
python3.13libs/hc/hcsession.py | 82 ++++++++++++++++++++++++++++++++++++++++++
tools/check.py | 22 ++++++++++++
2 files changed, 104 insertions(+)
diff --git a/python3.13libs/hc/hcsession.py b/python3.13libs/hc/hcsession.py
index 01327f2..5d74a8f 100644
--- a/python3.13libs/hc/hcsession.py
+++ b/python3.13libs/hc/hcsession.py
@@ -54,6 +54,36 @@ def floatWindow(window, size=None):
window.resize(*size)
+def parseFileHistory(text):
+ """Paths of the HIP block of a file.history, most recent first.
+
+ Houdini writes blocks as ``HIP\n{\n<path>\n...\n}`` and appends the
+ newest path at the end; a path opened again is listed once.
+ """
+ paths = []
+ block = None
+ for line in text.splitlines():
+ line = line.strip()
+ if block is None:
+ if line and line not in ("{", "}"):
+ block = line # a block name: HIP, OTL, ...
+ continue
+ if line == "{":
+ continue
+ if line == "}":
+ block = None
+ continue
+ if block == "HIP" and line:
+ paths.append(line)
+ seen = set()
+ ordered = []
+ for path in reversed(paths):
+ if path not in seen:
+ seen.add(path)
+ ordered.append(path)
+ return ordered
+
+
class HCSession:
def __init__(self):
return
@@ -724,6 +754,58 @@ class HCSession:
for callback in callbacks:
hou.ui.removeEventLoopCallback(callback)
+ def recentHipFiles(self):
+ """Houdini's recent hips, most recent first, existing files only.
+
+ HOM exposes no recent-file list (nothing on hou.hipFile or hou.ui),
+ so this reads the HIP block of $HOUDINI_USER_PREF_DIR/file.history,
+ the file Houdini's own File > Open Recent Files strip is built from.
+ """
+ import os
+ base = hou.getenv("HOUDINI_USER_PREF_DIR") or ""
+ try:
+ text = open(os.path.join(base, "file.history")).read()
+ except OSError:
+ return []
+ return [f for f in parseFileHistory(text) if os.path.isfile(f)]
+
+ @command("Open Recent")
+ def openRecent(self):
+ """A fuzzy picker over the recent hips; picking one loads it."""
+ current = hou.hipFile.path()
+ files = [f for f in self.recentHipFiles() if f != current]
+ if not files:
+ hou.ui.setStatusMessage("No other recent files", hou.severityType.Warning)
+ return
+
+ def load(path):
+ try:
+ # Prompts to save unsaved changes first, like File > Open.
+ hou.hipFile.load(path)
+ except hou.OperationInterrupted:
+ return # cancelled at the save prompt
+ except hou.LoadWarning as e:
+ hou.ui.setStatusMessage(str(e), hou.severityType.Warning)
+ except hou.Error as e:
+ hou.ui.setStatusMessage(f"Cannot open {path}: {e}",
+ hou.severityType.Error)
+
+ import os
+ names = [os.path.basename(f) for f in files]
+ list_dict = {}
+ for path, name in zip(files, names):
+ # The directory only when the name alone would be ambiguous.
+ label = path if names.count(name) > 1 else \
+ f"{name} {os.path.basename(os.path.dirname(path))}"
+ list_dict[label] = (lambda p=path: load(p))
+
+ from .hcwidgets import HCWidgets
+ dialog = HCWidgets.SelectionDialog(
+ "Open Recent", list_dict, anchor_geometry=self.mainWindowGeometry())
+ dialog.show()
+ dialog.raise_()
+ dialog.activateWindow()
+
@command("Restart Houdini")
def restartHoudini(self):
import os
diff --git a/tools/check.py b/tools/check.py
index 8430840..618ec9e 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -600,6 +600,27 @@ def check_nodegraph_hooks():
check("one mouseup completer", one_mouseup_completer)
+def check_open_recent():
+ print("open recent")
+ from hc import hcsession
+
+ def history_parses_newest_first():
+ text = "HIP\n{\n/a/one.hip\n/b/two.hip\n/a/one.hip\n/c/three.hip\n}\nOTL\n{\n/x/lib.hda\n}\n"
+ got = hcsession.parseFileHistory(text)
+ assert got == ["/c/three.hip", "/a/one.hip", "/b/two.hip"], got
+ assert hcsession.parseFileHistory("") == []
+ return "newest first, deduplicated, OTL block ignored"
+
+ check("file.history parser", history_parses_newest_first)
+
+ def recent_files_exist():
+ files = HCSession().recentHipFiles()
+ assert all(Path(f).is_file() for f in files), files
+ return f"{len(files)} recent hips on disk"
+
+ check("recentHipFiles() only lists existing files", recent_files_exist)
+
+
def check_status_circle():
"""The status circle's menu is built from the main-menu XML files with
everything Python cannot run filtered out."""
@@ -1753,6 +1774,7 @@ def main():
check_chrome()
check_split_handles()
check_nodegraph_hooks()
+ check_open_recent()
check_status_circle()
check_geometry()
check_node_ops()