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

commitb257f89f2ce48d6a84e699b4c2b8907630b032cb
parent670eba567a
authorLucas Galante <[email protected]>
date2026-09-15 10:31
statusbar: a status circle with a main-menu popup

Replaces the label strip over Houdini's status bar with one circle:
grey with no unsaved changes, red with unsaved changes, blue with the
autosave interval in minutes inside when autosave is on. The old tab
type label is gone.

Clicking it pops up a menu built from every MainMenuCommon.xml on the
path through hou.qt.XMLMenuParser. Houdini's menu bar is not a Qt
widget and HOM cannot fire a menu action by symbol, so the popup holds
what Python can run: all scriptItems (hc's whole menu, Houdini's
script-backed entries) plus New/Open/Merge/Save/Save As/Quit mapped to
HOM. Other actionItems, toggles and dynamic strips are filtered out,
submenus left empty are dropped, and the first entry toggles Houdini's
real menu bar for the rest.

Working around the parser on the way: it wants a menuDocument root,
menu modifiers beside the menu, no <context> under a subMenu (the
expression is evaluated here instead), compiles a one-line scriptCode
as a return expression, and its parseString ends in a NameError, so the
rewritten documents go through parseFile.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 python3.13libs/hc/hcstatusbar.py | 406 +++++++++++++++++++++++++++------------
 tools/check.py                   |  37 ++++
 2 files changed, 316 insertions(+), 127 deletions(-)

diff --git a/python3.13libs/hc/hcstatusbar.py b/python3.13libs/hc/hcstatusbar.py
index 7824f99..27390e9 100644
--- a/python3.13libs/hc/hcstatusbar.py
+++ b/python3.13libs/hc/hcstatusbar.py
@@ -1,32 +1,84 @@
+"""The status circle pinned over Houdini's status bar.
+
+One small circle, bottom right of the main window:
+
+- light grey: the hip has no unsaved changes
+- red: unsaved changes
+- blue: autosave is on, with the autosave interval in minutes inside
+
+Clicking it pops up a menu built from the main menu bar's XML definition.
+Houdini's main menu is not a Qt widget and HOM has no call to fire a menu
+action by its hotkey symbol, so the popup can only carry what Python can run:
+every scriptItem (all of hc's own menu, and Houdini's script-backed entries
+such as File > Import) plus the core actions listed in ACTION_SYMBOLS, which
+are mapped to their HOM equivalents. Everything else is left out rather than
+shown greyed. The first entry toggles Houdini's real menu bar for the rest.
+"""
+
+import os
+import tempfile
+
 import hou
-from PySide6.QtCore import QEvent, QObject, Qt, QTimer
-from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QWidget
+import lxml.etree as ET
+from PySide6.QtCore import QEvent, QObject, QPoint, Qt, QTimer
+from PySide6.QtGui import QColor, QFont, QPainter, QPen
+from PySide6.QtWidgets import QFrame, QHBoxLayout, QWidget
 
 
-HCSTATUSBAR_HEIGHT = 48
-HCSTATUSBAR_WIDTH = 500
 HCSTATUSBAR_MARGIN = 4
 HCSTATUSBAR_RIGHT_MARGIN = 360
-HCSTATUSBAR_STYLE = """
-    QFrame#hc_status_overlay {
-        background: #2a2a2a;
-        border: 1px solid #444;
-    }
-    QFrame#hc_status_overlay QLabel {
-        color: #ccc;
-        background: transparent;
-        padding: 0 6px;
-    }
-    QFrame#hc_status_overlay QPushButton {
-        color: #eee;
-        background: #3a3a3a;
-        border: 1px solid #555;
-        padding: 0 10px;
-    }
-    QFrame#hc_status_overlay QPushButton:hover {
-        background: #4a4a4a;
-    }
-"""
+# The old 500px-wide label strip started here; the circle keeps its left edge.
+HCSTATUSBAR_LEFT_OFFSET = 500 + HCSTATUSBAR_RIGHT_MARGIN
+HCSTATUSBAR_SIZE = 26
+
+COLOR_SAVED = QColor("#a0a0a0")
+COLOR_UNSAVED = QColor("#d9534f")
+COLOR_AUTOSAVE = QColor("#4a90d9")
+COLOR_TEXT = QColor("#ffffff")
+
+# Main-menu actionItem symbols the popup can run, and how. Houdini's own
+# handlers for these are C++ and unreachable from HOM; these are the HOM
+# equivalents, prompting where Houdini would.
+ACTION_SYMBOLS = {
+    "h.new": lambda: hou.hipFile.clear(),
+    "h.open": lambda: _openHip(),
+    "h.merge": lambda: _mergeHip(),
+    "h.save": lambda: hou.hipFile.save(),
+    "h.save_as": lambda: _saveHipAs(),
+    "h.quit": lambda: hou.exit(),
+}
+
+
+# Element kinds hou.qt.XMLMenuParser can build, and the ones it cannot.
+MENU_ITEMS = ("actionItem", "scriptItem", "scriptToggleItem", "separatorItem",
+              "titleItem", "subMenu", "addScriptItem", "scriptMenuStripDynamic")
+RUNNABLE_ITEMS = ("actionItem", "scriptItem", "scriptToggleItem",
+                  "scriptMenuStripDynamic", "addScriptItem")
+MENU_MODIFIERS = ("removeItem", "addScriptItem", "modifyItem")
+UNSUPPORTED_ITEMS = ("toggleItem", "menuStripDynamic", "menuStripDynamicRadio",
+                     "menuStripRadio", "scriptMenuStripRadio",
+                     "scriptMenuStripDynamicRadio")
+
+
+def _openHip():
+    path = hou.ui.selectFile(title="Open", file_type=hou.fileType.Hip,
+                             chooser_mode=hou.fileChooserMode.Read)
+    if path:
+        hou.hipFile.load(hou.text.expandString(path))
+
+
+def _mergeHip():
+    path = hou.ui.selectFile(title="Merge", file_type=hou.fileType.Hip,
+                             chooser_mode=hou.fileChooserMode.Read)
+    if path:
+        hou.hipFile.merge(hou.text.expandString(path))
+
+
+def _saveHipAs():
+    path = hou.ui.selectFile(title="Save As", file_type=hou.fileType.Hip,
+                             chooser_mode=hou.fileChooserMode.Write)
+    if path:
+        hou.hipFile.save(hou.text.expandString(path))
 
 
 class _ResizeFilter(QObject):
@@ -40,10 +92,197 @@ class _ResizeFilter(QObject):
         return False
 
 
+def _pref(name, default=""):
+    # hou.getPreference raises NotAvailable without a UI (hython, check.py).
+    try:
+        return hou.getPreference(name) or default
+    except hou.NotAvailable:
+        return default
+
+
+def hipState():
+    """('saved' | 'unsaved' | 'autosave', autosave interval in minutes)."""
+    try:
+        minutes = int(float(_pref("autosaveinterval.val", "0")))
+    except ValueError:
+        minutes = 0
+    if _pref("autoSave", "0") == "1":
+        return "autosave", minutes
+    if hou.hipFile.hasUnsavedChanges():
+        return "unsaved", minutes
+    return "saved", minutes
+
+
+class StatusCircle(QWidget):
+    OBJECT_NAME = "hc_status_circle"
+    POLL_MS = 500
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.setObjectName(self.OBJECT_NAME)
+        self.setFixedSize(HCSTATUSBAR_SIZE, HCSTATUSBAR_SIZE)
+        self.setCursor(Qt.PointingHandCursor)
+        self._state = None
+        self._minutes = 0
+        self._timer = QTimer(self)
+        self._timer.timeout.connect(self.refresh)
+        self._timer.start(self.POLL_MS)
+        self.refresh()
+
+    def state(self):
+        return self._state
+
+    def refresh(self):
+        state, minutes = hipState()
+        if (state, minutes) == (self._state, self._minutes):
+            return
+        self._state, self._minutes = state, minutes
+        self.setToolTip({
+            "saved": "No unsaved changes",
+            "unsaved": "Unsaved changes",
+            "autosave": f"Autosave every {minutes} min",
+        }[state])
+        self.update()
+
+    def color(self):
+        return {
+            "saved": COLOR_SAVED,
+            "unsaved": COLOR_UNSAVED,
+            "autosave": COLOR_AUTOSAVE,
+        }[self._state or "saved"]
+
+    def paintEvent(self, event):
+        painter = QPainter(self)
+        painter.setRenderHint(QPainter.Antialiasing)
+        rect = self.rect().adjusted(2, 2, -2, -2)
+        painter.setPen(QPen(QColor(0, 0, 0, 90), 1))
+        painter.setBrush(self.color())
+        painter.drawEllipse(rect)
+        if self._state == "autosave":
+            font = QFont(painter.font())
+            font.setBold(True)
+            font.setPixelSize(max(8, HCSTATUSBAR_SIZE // 2))
+            painter.setFont(font)
+            painter.setPen(COLOR_TEXT)
+            painter.drawText(rect, Qt.AlignCenter, str(self._minutes))
+        painter.end()
+
+    def mousePressEvent(self, event):
+        if event.button() == Qt.LeftButton:
+            # Held on the widget so it outlives this handler; not parented,
+            # since setParent would drop the popup window flags.
+            self._menu = buildMainMenu()
+            self._menu.popup(self.mapToGlobal(QPoint(0, 0)))
+            event.accept()
+            return
+        super().mousePressEvent(event)
+
+
+def _menuDocument(path):
+    """A main-menu XML file rewritten into the menuDocument form
+    hou.qt.XMLMenuParser accepts, keeping only entries the popup can run."""
+    root = ET.parse(path).getroot()
+    # Package files put addScriptItem entries straight under <mainMenu>,
+    # without a <menuBar>.
+    bar = root.find("menuBar")
+    if bar is None:
+        bar = root
+    for comment in list(bar.iter(ET.Comment)):
+        comment.getparent().remove(comment)
+    for item in list(bar.iter("actionItem")):
+        if item.get("id") not in ACTION_SYMBOLS:
+            item.getparent().remove(item)
+    # Houdini-internal item kinds the Qt parser has no element handler for
+    # (recent-file strips, save-as-type radios, preference toggles).
+    for tag in UNSUPPORTED_ITEMS:
+        for item in list(bar.iter(tag)):
+            item.getparent().remove(item)
+    # A subMenu's own <context><expression> decides its visibility (Houdini
+    # uses it to pick the old or new Preferences menu). The parser reads it
+    # but has no element handler for it and warns on every click, so settle
+    # it here and drop the element.
+    for sub in list(bar.iter("subMenu")):
+        ctx = sub.find("context")
+        if ctx is None:
+            continue
+        sub.remove(ctx)
+        expr = ctx.findtext("expression")
+        if expr and not _contextAllows(expr):
+            sub.getparent().remove(sub)
+    # A submenu with no runnable entry left anywhere beneath it is noise.
+    for sub in reversed(list(bar.iter("subMenu"))):
+        if next(sub.iter(*RUNNABLE_ITEMS), None) is None:
+            sub.getparent().remove(sub)
+    # The parser compiles a one-line scriptCode as `return <line>`, which is
+    # a syntax error for the `import x; x.y()` one-liners hc's menu uses;
+    # a second line makes it compile the code as a statement body.
+    for code in bar.iter("scriptCode"):
+        text = (code.text or "").strip()
+        if text and len(text.splitlines()) == 1 and not text.startswith("return"):
+            code.text = "pass\n" + text
+    doc = ET.Element("menuDocument")
+    menu = ET.SubElement(doc, "menu")
+    for child in list(bar):
+        # Modifiers (hc's removeItem, kinefx's addScriptItem) sit beside the
+        # menu in a menuDocument, not inside it.
+        (doc if child.tag in MENU_MODIFIERS else menu).append(child)
+    return ET.tostring(doc)
+
+
+def _contextAllows(expression):
+    """Evaluate a menu <context><expression> body the way the parser does:
+    a Python function body returning a bool. Unparseable means visible."""
+    body = "\n".join("    " + line for line in expression.strip().splitlines())
+    namespace = {}
+    try:
+        exec(f"def _ctx(kwargs):\n{body}\n", namespace)
+        return bool(namespace["_ctx"]({}))
+    except Exception:
+        return True
+
+
+def mainMenuXMLFiles():
+    return list(reversed(hou.findFiles("MainMenuCommon.xml")))
+
+
+def _runAction(kwargs):
+    fn = ACTION_SYMBOLS.get(kwargs.get("id"))
+    if fn is not None:
+        fn()
+
+
+def buildMainMenu():
+    """The popup: a toggle for Houdini's own menu bar, then every runnable
+    entry of the main menu definition, hc's menu included."""
+    from .hcsession import HCSession
+    menu = hou.qt.Menu()
+    shown = _pref("showmenu.val", "1") == "1"
+    menu.addAction("Hide Main Menu Bar" if shown else "Show Main Menu Bar",
+                   HCSession().toggleMainMenu)
+    menu.addSeparator()
+    parser = hou.qt.XMLMenuParser()
+    # parseString() ends in a NameError in Houdini's own code after a
+    # successful parse, so the rewritten documents go through parseFile().
+    for path in mainMenuXMLFiles():
+        try:
+            xml = _menuDocument(path)
+        except ET.XMLSyntaxError as e:
+            print(f"[HCStatusBar] skipping {path}: {e}")
+            continue
+        with tempfile.NamedTemporaryFile("wb", suffix=".xml", delete=False) as tmp:
+            tmp.write(xml)
+        try:
+            parser.parseFile(tmp.name)
+        except ET.XMLSyntaxError as e:
+            print(f"[HCStatusBar] {path} rejected by menupy.xsd: {e}")
+        finally:
+            os.unlink(tmp.name)
+    parser.generateMenu({}, menu, _runAction)
+    return menu
+
+
 class HCStatusBar:
     OVERLAY_NAME = "hc_status_overlay"
-    TAB_TYPE_LABEL = "hc_status_tab_type"
-    AUTOSAVE_LABEL = "hc_status_autosave"
 
     def __init__(self):
         self.main = hou.qt.mainWindow()
@@ -57,12 +296,11 @@ class HCStatusBar:
             return self.overlay
         frame = QFrame(self.main)
         frame.setObjectName(self.OVERLAY_NAME)
-        frame.setStyleSheet(HCSTATUSBAR_STYLE)
+        frame.setStyleSheet(f"QFrame#{self.OVERLAY_NAME} {{ background: transparent; border: none; }}")
         layout = QHBoxLayout(frame)
-        layout.setContentsMargins(2, 0, 2, 0)
-        layout.setSpacing(4)
-        layout.addStretch()
-        frame.setLayout(layout)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(0)
+        layout.addWidget(StatusCircle(frame))
         filt = _ResizeFilter(self)
         self.main.installEventFilter(filt)
         frame._hc_filter = filt  # keep reference alive
@@ -70,57 +308,25 @@ class HCStatusBar:
         self._pin()
         frame.show()
         frame.raise_()
-        self._installLabels()
         return frame
 
-    def _installLabels(self):
-        frame = self.overlay
-        layout = frame.layout()
-
-        tab_lbl = QLabel("—", frame)
-        tab_lbl.setObjectName(self.TAB_TYPE_LABEL)
-        layout.insertWidget(0, tab_lbl)
-
-        autosave_lbl = QLabel("—", frame)
-        autosave_lbl.setObjectName(self.AUTOSAVE_LABEL)
-        layout.insertWidget(1, autosave_lbl)
-
-        timer = QTimer(frame)
-        timer.timeout.connect(self._refreshTabType)
-        timer.start(250)
-        frame._hc_tab_type_timer = timer
-
-        self._refreshTabType()
-        self.updateAutosave()
-
-    def _refreshTabType(self):
+    def circle(self):
         if self.overlay is None:
-            return
-        lbl = self.overlay.findChild(QLabel, self.TAB_TYPE_LABEL)
-        if lbl is None:
-            return
-        from .hcsession import HCSession
-        tab = HCSession().currentTab()
-        if tab is None:
-            lbl.setText("—")
-            return
-        lbl.setText(tab.hou_tab.type().name())
+            return None
+        return self.overlay.findChild(StatusCircle, StatusCircle.OBJECT_NAME)
 
     def updateAutosave(self):
-        if self.overlay is None:
-            return
-        lbl = self.overlay.findChild(QLabel, self.AUTOSAVE_LABEL)
-        if lbl is None:
-            return
-        autosave = hou.getPreference("autoSave")
-        lbl.setText("Autosave: On" if autosave == "1" else "Autosave: Off")
+        """Repaint now rather than on the next poll; toggleAutoSave calls it."""
+        circle = self.circle()
+        if circle is not None:
+            circle.refresh()
 
     def _pin(self):
         if self.overlay is None:
             return
-        x = self.main.width() - HCSTATUSBAR_WIDTH - HCSTATUSBAR_RIGHT_MARGIN
-        y = self.main.height() - HCSTATUSBAR_HEIGHT - HCSTATUSBAR_MARGIN
-        self.overlay.setGeometry(x, y, HCSTATUSBAR_WIDTH, HCSTATUSBAR_HEIGHT)
+        x = self.main.width() - HCSTATUSBAR_LEFT_OFFSET
+        y = self.main.height() - HCSTATUSBAR_SIZE - HCSTATUSBAR_MARGIN
+        self.overlay.setGeometry(x, y, HCSTATUSBAR_SIZE, HCSTATUSBAR_SIZE)
         self.overlay.raise_()
 
 
@@ -137,51 +343,6 @@ class HCStatusBar:
     def isVisible(self):
         return self.overlay is not None and self.overlay.isVisible()
 
-
-    """ Add """
-
-
-    def addButton(self, name, label, callback):
-        frame = self._ensureOverlay()
-        self.removeWidget(name)
-        btn = QPushButton(label, frame)
-        btn.setObjectName(name)
-        btn.clicked.connect(callback)
-        frame.layout().addWidget(btn)
-        frame.raise_()
-        return btn
-
-    def addLabel(self, name, text):
-        frame = self._ensureOverlay()
-        self.removeWidget(name)
-        lbl = QLabel(text, frame)
-        lbl.setObjectName(name)
-        frame.layout().addWidget(lbl)
-        frame.raise_()
-        return lbl
-
-    def addWidget(self, name, widget):
-        frame = self._ensureOverlay()
-        self.removeWidget(name)
-        widget.setObjectName(name)
-        widget.setParent(frame)
-        frame.layout().addWidget(widget)
-        frame.raise_()
-        return widget
-
-
-    """ Remove """
-
-
-    def removeWidget(self, name):
-        if self.overlay is None:
-            return
-        existing = self.overlay.findChild(QWidget, name)
-        if existing is not None:
-            self.overlay.layout().removeWidget(existing)
-            existing.setParent(None)
-            existing.deleteLater()
-
     def clear(self):
         if self.overlay is None:
             return
@@ -189,14 +350,5 @@ class HCStatusBar:
         self.overlay.deleteLater()
         self.overlay = None
 
-
-    """ Query """
-
-
     def exists(self):
         return self._findOverlay() is not None
-
-    def widget(self, name):
-        if self.overlay is None:
-            return None
-        return self.overlay.findChild(QWidget, name)
diff --git a/tools/check.py b/tools/check.py
index 83ac6e3..b118804 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -599,6 +599,42 @@ def check_nodegraph_hooks():
     check("one mouseup completer", one_mouseup_completer)
 
 
+def check_status_circle():
+    """The status circle's menu is built from the main-menu XML files with
+    everything Python cannot run filtered out."""
+    print("status circle")
+    from hc import hcstatusbar
+    import lxml.etree as ET
+
+    def state_is_wellformed():
+        state, minutes = hcstatusbar.hipState()
+        assert state in ("saved", "unsaved", "autosave"), state
+        assert isinstance(minutes, int)
+        return f"{state}, {minutes} min"
+
+    check("hipState()", state_is_wellformed)
+
+    def menu_documents_are_runnable():
+        files = hcstatusbar.mainMenuXMLFiles()
+        assert files, "no MainMenuCommon.xml on the path"
+        total = 0
+        for path in files:
+            xml = hcstatusbar._menuDocument(path)
+            root = ET.fromstring(xml)
+            assert root.tag == "menuDocument" and root[0].tag == "menu", path
+            ids = [a.get("id") for a in root.iter("actionItem")]
+            assert set(ids) <= set(hcstatusbar.ACTION_SYMBOLS), f"{path}: unmapped {ids}"
+            assert not list(root.iter("toggleItem")), f"{path}: toggleItem left in"
+            total += len(list(root.iter("scriptItem"))) + len(ids)
+        base = ET.parse([f for f in files if f.startswith(hou.getenv("HFS"))][0]).getroot()
+        declared = {a.get("id") for a in base.iter("actionItem")}
+        missing = set(hcstatusbar.ACTION_SYMBOLS) - declared
+        assert not missing, f"mapped symbols not in Houdini's menu: {sorted(missing)}"
+        return f"{total} runnable items across {len(files)} files"
+
+    check("menu XML filtered to runnable items", menu_documents_are_runnable)
+
+
 def check_geometry():
     """The visible-geometry walk, after HCNode was removed from under it."""
     print("geometry")
@@ -1701,6 +1737,7 @@ def main():
     check_chrome()
     check_split_handles()
     check_nodegraph_hooks()
+    check_status_circle()
     check_geometry()
     check_node_ops()
     check_node_colors()