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