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

python3.13libs/hc/hcstatusbar.py (13.6K)

  1 """The status circle pinned over Houdini's status bar.
  2 
  3 One small circle, bottom right of the main window:
  4 
  5 - light grey: the hip has no unsaved changes
  6 - red: unsaved changes
  7 - blue: autosave is on, with the autosave interval in minutes inside
  8 
  9 Clicking it pops up a menu built from the main menu bar's XML definition.
 10 Houdini's main menu is not a Qt widget and HOM has no call to fire a menu
 11 action by its hotkey symbol, so the popup can only carry what Python can run:
 12 every scriptItem (all of hc's own menu, and Houdini's script-backed entries
 13 such as File > Import) plus the core actions listed in ACTION_SYMBOLS, which
 14 are mapped to their HOM equivalents. Everything else is left out rather than
 15 shown greyed. The first entry toggles Houdini's real menu bar for the rest.
 16 """
 17 
 18 import os
 19 import tempfile
 20 
 21 import hou
 22 import lxml.etree as ET
 23 from PySide6.QtCore import QEvent, QObject, QPoint, Qt, QTimer
 24 from PySide6.QtGui import QColor, QFont, QPainter, QRegion
 25 from PySide6.QtWidgets import QFrame, QHBoxLayout, QWidget
 26 
 27 
 28 HCSTATUSBAR_SIZE = 34
 29 # Houdini's status bar keeps its right-hand controls right-aligned, so the
 30 # circle is placed from the right edge: its right side this far in, which is
 31 # a small gap left of the live-cooking toggle, and its centre this far up.
 32 HCSTATUSBAR_RIGHT_OFFSET = 666
 33 HCSTATUSBAR_CENTER_FROM_BOTTOM = 24
 34 
 35 COLOR_SAVED = QColor("#a0a0a0")
 36 COLOR_UNSAVED = QColor("#d9534f")
 37 COLOR_AUTOSAVE = QColor("#4a90d9")
 38 COLOR_TEXT = QColor("#ffffff")
 39 
 40 # Main-menu actionItem symbols the popup can run, and how. Houdini's own
 41 # handlers for these are C++ and unreachable from HOM; these are the HOM
 42 # equivalents, prompting where Houdini would.
 43 ACTION_SYMBOLS = {
 44     "h.new": lambda: hou.hipFile.clear(),
 45     "h.open": lambda: _openHip(),
 46     "h.merge": lambda: _mergeHip(),
 47     "h.save": lambda: hou.hipFile.save(),
 48     "h.save_as": lambda: _saveHipAs(),
 49     "h.quit": lambda: hou.exit(),
 50 }
 51 
 52 
 53 # Element kinds hou.qt.XMLMenuParser can build, and the ones it cannot.
 54 MENU_ITEMS = ("actionItem", "scriptItem", "scriptToggleItem", "separatorItem",
 55               "titleItem", "subMenu", "addScriptItem", "scriptMenuStripDynamic")
 56 RUNNABLE_ITEMS = ("actionItem", "scriptItem", "scriptToggleItem",
 57                   "scriptMenuStripDynamic", "addScriptItem")
 58 MENU_MODIFIERS = ("removeItem", "addScriptItem", "modifyItem")
 59 UNSUPPORTED_ITEMS = ("toggleItem", "menuStripDynamic", "menuStripDynamicRadio",
 60                      "menuStripRadio", "scriptMenuStripRadio",
 61                      "scriptMenuStripDynamicRadio")
 62 
 63 
 64 def _openHip():
 65     path = hou.ui.selectFile(title="Open", file_type=hou.fileType.Hip,
 66                              chooser_mode=hou.fileChooserMode.Read)
 67     if path:
 68         hou.hipFile.load(hou.text.expandString(path))
 69 
 70 
 71 def _mergeHip():
 72     path = hou.ui.selectFile(title="Merge", file_type=hou.fileType.Hip,
 73                              chooser_mode=hou.fileChooserMode.Read)
 74     if path:
 75         hou.hipFile.merge(hou.text.expandString(path))
 76 
 77 
 78 def _saveHipAs():
 79     path = hou.ui.selectFile(title="Save As", file_type=hou.fileType.Hip,
 80                              chooser_mode=hou.fileChooserMode.Write)
 81     if path:
 82         hou.hipFile.save(hou.text.expandString(path))
 83 
 84 
 85 class _ResizeFilter(QObject):
 86     def __init__(self, manager):
 87         super().__init__(manager.overlay)
 88         self.manager = manager
 89 
 90     def eventFilter(self, obj, event):
 91         if event.type() == QEvent.Resize:
 92             self.manager._pin()
 93         return False
 94 
 95 
 96 def _pref(name, default=""):
 97     # hou.getPreference raises NotAvailable without a UI (hython, check.py).
 98     try:
 99         return hou.getPreference(name) or default
100     except hou.NotAvailable:
101         return default
102 
103 
104 def hipState():
105     """('saved' | 'unsaved' | 'autosave', autosave interval in minutes)."""
106     try:
107         minutes = int(float(_pref("autosaveinterval.val", "0")))
108     except ValueError:
109         minutes = 0
110     if _pref("autoSave", "0") == "1":
111         return "autosave", minutes
112     if hou.hipFile.hasUnsavedChanges():
113         return "unsaved", minutes
114     return "saved", minutes
115 
116 
117 class StatusCircle(QWidget):
118     OBJECT_NAME = "hc_status_circle"
119     POLL_MS = 500
120 
121     def __init__(self, parent=None):
122         super().__init__(parent)
123         self.setObjectName(self.OBJECT_NAME)
124         self.setFixedSize(HCSTATUSBAR_SIZE, HCSTATUSBAR_SIZE)
125         self.setCursor(Qt.PointingHandCursor)
126         # Its own X window, or it can be seen but never clicked: Houdini
127         # draws its UI into one native GL window that owns mouse input across
128         # its area, and a plain child widget stacked above only paints there.
129         # Same fix as hcsplithandles._SplitHandle, measured the same way --
130         # without it the circle received no mouse or hover event at all.
131         self.setAttribute(Qt.WA_NativeWindow, True)
132         self.winId()
133         # A native child has its own surface, so the corners outside the
134         # circle would be its uninitialised black; mask them away.
135         self.setMask(QRegion(self.rect(), QRegion.Ellipse))
136         self._state = None
137         self._minutes = 0
138         self._timer = QTimer(self)
139         self._timer.timeout.connect(self.refresh)
140         self._timer.start(self.POLL_MS)
141         self.refresh()
142 
143     def state(self):
144         return self._state
145 
146     def refresh(self):
147         state, minutes = hipState()
148         if (state, minutes) == (self._state, self._minutes):
149             return
150         self._state, self._minutes = state, minutes
151         self.setToolTip({
152             "saved": "No unsaved changes",
153             "unsaved": "Unsaved changes",
154             "autosave": f"Autosave every {minutes} min",
155         }[state])
156         self.update()
157 
158     def color(self):
159         return {
160             "saved": COLOR_SAVED,
161             "unsaved": COLOR_UNSAVED,
162             "autosave": COLOR_AUTOSAVE,
163         }[self._state or "saved"]
164 
165     def paintEvent(self, event):
166         painter = QPainter(self)
167         painter.setRenderHint(QPainter.Antialiasing)
168         # Fill to the edge: the window mask is the circle's outline, and any
169         # pixel inside it left unpainted shows the native surface's black.
170         rect = self.rect()
171         painter.setPen(Qt.NoPen)
172         painter.setBrush(self.color())
173         painter.drawEllipse(rect)
174         if self._state == "autosave":
175             font = QFont(painter.font())
176             font.setBold(True)
177             font.setPixelSize(max(8, HCSTATUSBAR_SIZE // 2))
178             painter.setFont(font)
179             painter.setPen(COLOR_TEXT)
180             painter.drawText(rect, Qt.AlignCenter, str(self._minutes))
181         painter.end()
182 
183     def mousePressEvent(self, event):
184         if event.button() == Qt.LeftButton:
185             # Held on the widget so it outlives this handler; not parented,
186             # since setParent would drop the popup window flags.
187             self._menu = buildMainMenu()
188             # Open upward: bottom-left of the menu on the circle's top-left.
189             # popup() still keeps it on screen if that would not fit.
190             top_left = self.mapToGlobal(QPoint(0, 0))
191             top_left.setY(top_left.y() - self._menu.sizeHint().height())
192             self._menu.popup(top_left)
193             event.accept()
194             return
195         super().mousePressEvent(event)
196 
197 
198 def _menuDocument(path):
199     """A main-menu XML file rewritten into the menuDocument form
200     hou.qt.XMLMenuParser accepts, keeping only entries the popup can run."""
201     root = ET.parse(path).getroot()
202     # Package files put addScriptItem entries straight under <mainMenu>,
203     # without a <menuBar>.
204     bar = root.find("menuBar")
205     if bar is None:
206         bar = root
207     for comment in list(bar.iter(ET.Comment)):
208         comment.getparent().remove(comment)
209     for item in list(bar.iter("actionItem")):
210         if item.get("id") not in ACTION_SYMBOLS:
211             item.getparent().remove(item)
212     # Houdini-internal item kinds the Qt parser has no element handler for
213     # (recent-file strips, save-as-type radios, preference toggles).
214     for tag in UNSUPPORTED_ITEMS:
215         for item in list(bar.iter(tag)):
216             item.getparent().remove(item)
217     # A subMenu's own <context><expression> decides its visibility (Houdini
218     # uses it to pick the old or new Preferences menu). The parser reads it
219     # but has no element handler for it and warns on every click, so settle
220     # it here and drop the element.
221     for sub in list(bar.iter("subMenu")):
222         ctx = sub.find("context")
223         if ctx is None:
224             continue
225         sub.remove(ctx)
226         expr = ctx.findtext("expression")
227         if expr and not _contextAllows(expr):
228             sub.getparent().remove(sub)
229     # A submenu with no runnable entry left anywhere beneath it is noise.
230     for sub in reversed(list(bar.iter("subMenu"))):
231         if next(sub.iter(*RUNNABLE_ITEMS), None) is None:
232             sub.getparent().remove(sub)
233     # The parser compiles a one-line scriptCode as `return <line>`, which is
234     # a syntax error for the `import x; x.y()` one-liners hc's menu uses;
235     # a second line makes it compile the code as a statement body.
236     for code in bar.iter("scriptCode"):
237         text = (code.text or "").strip()
238         if text and len(text.splitlines()) == 1 and not text.startswith("return"):
239             code.text = "pass\n" + text
240     doc = ET.Element("menuDocument")
241     menu = ET.SubElement(doc, "menu")
242     for child in list(bar):
243         # Modifiers (hc's removeItem, kinefx's addScriptItem) sit beside the
244         # menu in a menuDocument, not inside it.
245         (doc if child.tag in MENU_MODIFIERS else menu).append(child)
246     return ET.tostring(doc)
247 
248 
249 def _contextAllows(expression):
250     """Evaluate a menu <context><expression> body the way the parser does:
251     a Python function body returning a bool. Unparseable means visible."""
252     body = "\n".join("    " + line for line in expression.strip().splitlines())
253     namespace = {}
254     try:
255         exec(f"def _ctx(kwargs):\n{body}\n", namespace)
256         return bool(namespace["_ctx"]({}))
257     except Exception:
258         return True
259 
260 
261 def mainMenuXMLFiles():
262     return list(reversed(hou.findFiles("MainMenuCommon.xml")))
263 
264 
265 def _runAction(kwargs):
266     fn = ACTION_SYMBOLS.get(kwargs.get("id"))
267     if fn is not None:
268         fn()
269 
270 
271 def _toggleMainMenuBar():
272     # A module function, not HCSession().toggleMainMenu: PySide keeps only a
273     # weak reference to a bound method's instance, so a slot on a throwaway
274     # HCSession() was garbage-collected with it and the action did nothing.
275     from .hcsession import HCSession
276     HCSession().toggleMainMenu()
277 
278 
279 def buildMainMenu():
280     """The popup: a toggle for Houdini's own menu bar, then every runnable
281     entry of the main menu definition, hc's menu included."""
282     menu = hou.qt.Menu()
283     shown = _pref("showmenu.val", "1") == "1"
284     menu.addAction("Hide Main Menu Bar" if shown else "Show Main Menu Bar",
285                    _toggleMainMenuBar)
286     menu.addSeparator()
287     parser = hou.qt.XMLMenuParser()
288     # parseString() ends in a NameError in Houdini's own code after a
289     # successful parse, so the rewritten documents go through parseFile().
290     for path in mainMenuXMLFiles():
291         try:
292             xml = _menuDocument(path)
293         except ET.XMLSyntaxError as e:
294             print(f"[HCStatusBar] skipping {path}: {e}")
295             continue
296         with tempfile.NamedTemporaryFile("wb", suffix=".xml", delete=False) as tmp:
297             tmp.write(xml)
298         try:
299             parser.parseFile(tmp.name)
300         except ET.XMLSyntaxError as e:
301             print(f"[HCStatusBar] {path} rejected by menupy.xsd: {e}")
302         finally:
303             os.unlink(tmp.name)
304     parser.generateMenu({}, menu, _runAction)
305     return menu
306 
307 
308 class HCStatusBar:
309     OVERLAY_NAME = "hc_status_overlay"
310 
311     def __init__(self):
312         self.main = hou.qt.mainWindow()
313         self.overlay = self._findOverlay()
314 
315     def _findOverlay(self):
316         return self.main.findChild(QFrame, self.OVERLAY_NAME)
317 
318     def _ensureOverlay(self):
319         if self.overlay is not None and self.overlay.parent() is self.main:
320             return self.overlay
321         frame = QFrame(self.main)
322         frame.setObjectName(self.OVERLAY_NAME)
323         frame.setStyleSheet(f"QFrame#{self.OVERLAY_NAME} {{ background: transparent; border: none; }}")
324         layout = QHBoxLayout(frame)
325         layout.setContentsMargins(0, 0, 0, 0)
326         layout.setSpacing(0)
327         layout.addWidget(StatusCircle(frame))
328         filt = _ResizeFilter(self)
329         self.main.installEventFilter(filt)
330         frame._hc_filter = filt  # keep reference alive
331         self.overlay = frame
332         self._pin()
333         frame.show()
334         frame.raise_()
335         return frame
336 
337     def circle(self):
338         if self.overlay is None:
339             return None
340         return self.overlay.findChild(StatusCircle, StatusCircle.OBJECT_NAME)
341 
342     def updateAutosave(self):
343         """Repaint now rather than on the next poll; toggleAutoSave calls it."""
344         circle = self.circle()
345         if circle is not None:
346             circle.refresh()
347 
348     def _pin(self):
349         if self.overlay is None:
350             return
351         x = self.main.width() - HCSTATUSBAR_RIGHT_OFFSET - HCSTATUSBAR_SIZE
352         y = self.main.height() - HCSTATUSBAR_CENTER_FROM_BOTTOM - HCSTATUSBAR_SIZE // 2
353         self.overlay.setGeometry(x, y, HCSTATUSBAR_SIZE, HCSTATUSBAR_SIZE)
354         self.overlay.raise_()
355 
356 
357     """ Show / Hide """
358 
359 
360     def show(self):
361         self._ensureOverlay()
362 
363     def hide(self):
364         if self.overlay is not None:
365             self.overlay.hide()
366 
367     def isVisible(self):
368         return self.overlay is not None and self.overlay.isVisible()
369 
370     def clear(self):
371         if self.overlay is None:
372             return
373         self.overlay.setParent(None)
374         self.overlay.deleteLater()
375         self.overlay = None
376 
377     def exists(self):
378         return self._findOverlay() is not None