| 1 |
""" |
| 2 |
SaveUtils.py - core logic for the SaveUtils FreeCAD addon. |
| 3 |
""" |
| 4 |
|
| 5 |
import re |
| 6 |
import os |
| 7 |
import datetime |
| 8 |
import FreeCAD |
| 9 |
import FreeCADGui |
| 10 |
from PySide import QtCore, QtGui |
| 11 |
|
| 12 |
# Imported only from InitGui.py, i.e. always in a GUI session — see the note |
| 13 |
# there about why FreeCAD.GuiUp must not gate these imports. |
| 14 |
|
| 15 |
QT_TRANSLATE_NOOP = FreeCAD.Qt.QT_TRANSLATE_NOOP |
| 16 |
|
| 17 |
FreeCAD.Console.PrintLog("=== SaveUtils: SaveUtils.py loading ===\n") |
| 18 |
|
| 19 |
# --------------------------------------------------------------------------- |
| 20 |
# Regex patterns |
| 21 |
# --------------------------------------------------------------------------- |
| 22 |
_TS_PATTERN = re.compile(r"-\d{8}-\d{6}\.FCStd$", re.IGNORECASE) |
| 23 |
# Unbounded digit count, so counters that grow past -99 keep incrementing |
| 24 |
# instead of colliding on the same name |
| 25 |
_INC_SCAN_PATTERN = re.compile(r"-(\d+)\.FCStd$", re.IGNORECASE) |
| 26 |
_EXT_PATTERN = re.compile(r"\.FCStd$", re.IGNORECASE) |
| 27 |
|
| 28 |
|
| 29 |
def _strip_fcstd(path): |
| 30 |
return _EXT_PATTERN.sub("", path) |
| 31 |
|
| 32 |
|
| 33 |
def _active_document(): |
| 34 |
doc = FreeCAD.ActiveDocument |
| 35 |
if doc is None: |
| 36 |
QtGui.QMessageBox.warning( |
| 37 |
FreeCADGui.getMainWindow(), "SaveUtils", "No active document." |
| 38 |
) |
| 39 |
return doc |
| 40 |
|
| 41 |
|
| 42 |
def _ask_filename(title, current=""): |
| 43 |
"""Ask for a base filename. Returns the raw path, or None if cancelled.""" |
| 44 |
start = current or os.path.expanduser("~/untitled.FCStd") |
| 45 |
parent = FreeCADGui.getMainWindow() |
| 46 |
flt = "FreeCAD files (*.FCStd)" |
| 47 |
# We derive a different name from whatever is picked, so Qt's overwrite |
| 48 |
# confirmation would be warning about a file we are never going to write. |
| 49 |
try: |
| 50 |
dont_confirm = QtGui.QFileDialog.Option.DontConfirmOverwrite |
| 51 |
path, _ = QtGui.QFileDialog.getSaveFileName( |
| 52 |
parent, title, start, flt, "", dont_confirm |
| 53 |
) |
| 54 |
except Exception: |
| 55 |
path, _ = QtGui.QFileDialog.getSaveFileName(parent, title, start, flt) |
| 56 |
return path or None |
| 57 |
|
| 58 |
|
| 59 |
def _error(exc): |
| 60 |
QtGui.QMessageBox.critical( |
| 61 |
FreeCADGui.getMainWindow(), |
| 62 |
"SaveUtils error", |
| 63 |
f"Could not save file:\n{exc}", |
| 64 |
) |
| 65 |
return False |
| 66 |
|
| 67 |
|
| 68 |
def _clear_modified(doc): |
| 69 |
"""Clear the GUI's unsaved-changes marker — the * in the title bar. |
| 70 |
|
| 71 |
The asterisk is driven by the Gui document's own modified flag, which does |
| 72 |
not reliably follow an App-level saveAs(). Only ever called straight after a |
| 73 |
successful save, when there genuinely are no unsaved changes left. |
| 74 |
""" |
| 75 |
try: |
| 76 |
gui_doc = FreeCADGui.getDocument(doc.Name) |
| 77 |
except Exception: |
| 78 |
return |
| 79 |
try: |
| 80 |
if getattr(gui_doc, "Modified", False): |
| 81 |
gui_doc.Modified = False |
| 82 |
except Exception: |
| 83 |
FreeCAD.Console.PrintLog( |
| 84 |
"=== SaveUtils: could not clear the modified flag ===\n" |
| 85 |
) |
| 86 |
|
| 87 |
|
| 88 |
def _save_as(new_path): |
| 89 |
"""Save the active document as `new_path` and keep working in it.""" |
| 90 |
doc = FreeCAD.ActiveDocument |
| 91 |
try: |
| 92 |
doc.saveAs(new_path) |
| 93 |
except Exception as exc: |
| 94 |
return _error(exc) |
| 95 |
_clear_modified(doc) |
| 96 |
FreeCAD.Console.PrintMessage(f"SaveUtils: saved as '{new_path}'\n") |
| 97 |
return True |
| 98 |
|
| 99 |
|
| 100 |
def _timestamp_base(path): |
| 101 |
"""Strip a trailing -YYYYMMDD-HHMMSS and the .FCStd extension.""" |
| 102 |
if _TS_PATTERN.search(path): |
| 103 |
return _TS_PATTERN.sub("", path) |
| 104 |
return _strip_fcstd(path) |
| 105 |
|
| 106 |
|
| 107 |
def _increment_base(path): |
| 108 |
"""Strip a trailing -NN and the .FCStd extension.""" |
| 109 |
# A -YYYYMMDD-HHMMSS tail also ends in digits; keep it whole rather than |
| 110 |
# incrementing the time portion into a bogus timestamp |
| 111 |
if not _TS_PATTERN.search(path): |
| 112 |
m = _INC_SCAN_PATTERN.search(path) |
| 113 |
if m: |
| 114 |
return path[: m.start()] |
| 115 |
return _strip_fcstd(path) |
| 116 |
|
| 117 |
|
| 118 |
def _next_increment(base): |
| 119 |
"""Return the lowest unused -NN counter for `base` (path without .FCStd).""" |
| 120 |
directory = os.path.dirname(base) or "." |
| 121 |
stem = os.path.basename(base) |
| 122 |
highest = 0 |
| 123 |
try: |
| 124 |
entries = os.listdir(directory) |
| 125 |
except OSError: |
| 126 |
entries = [] |
| 127 |
prefix = f"{stem.lower()}-" |
| 128 |
for name in entries: |
| 129 |
if not name.lower().startswith(prefix): |
| 130 |
continue |
| 131 |
m = _INC_SCAN_PATTERN.search(name) |
| 132 |
# Only count siblings whose counter directly follows the stem, so |
| 133 |
# "design-detail-01.FCStd" is not mistaken for a "design" increment. |
| 134 |
if m and m.start() == len(stem): |
| 135 |
highest = max(highest, int(m.group(1))) |
| 136 |
return highest + 1 |
| 137 |
|
| 138 |
|
| 139 |
def _timestamp_name(base): |
| 140 |
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") |
| 141 |
return f"{base}-{stamp}.FCStd" |
| 142 |
|
| 143 |
|
| 144 |
def _increment_name(base): |
| 145 |
return f"{base}-{_next_increment(base):02d}.FCStd" |
| 146 |
|
| 147 |
|
| 148 |
# --------------------------------------------------------------------------- |
| 149 |
# Commands |
| 150 |
# --------------------------------------------------------------------------- |
| 151 |
|
| 152 |
|
| 153 |
def _save_derived(prompt, title, strip_tail, make_name): |
| 154 |
"""Save the document under a name derived from a base, and switch to it. |
| 155 |
|
| 156 |
`strip_tail` removes any existing suffix so repeated use does not stack them |
| 157 |
up; `make_name` builds the new filename. The base comes from the open |
| 158 |
document, or from a file dialog when `prompt` is set — or when the document |
| 159 |
has never been saved and so has no name to derive from. |
| 160 |
""" |
| 161 |
doc = _active_document() |
| 162 |
if doc is None: |
| 163 |
return |
| 164 |
|
| 165 |
path = doc.FileName |
| 166 |
if prompt or not path: |
| 167 |
path = _ask_filename(title, path) |
| 168 |
if path is None: |
| 169 |
return |
| 170 |
|
| 171 |
_save_as(make_name(strip_tail(path))) |
| 172 |
|
| 173 |
|
| 174 |
class CmdSaveTimestamp: |
| 175 |
"""File > Save with Timestamp — auto-named from the open document.""" |
| 176 |
|
| 177 |
def GetResources(self): |
| 178 |
return { |
| 179 |
"MenuText": "Save with Timestamp", |
| 180 |
"ToolTip": "Save as <name>-YYYYMMDD-HHMMSS.FCStd and keep working in it", |
| 181 |
"Pixmap": "", |
| 182 |
} |
| 183 |
|
| 184 |
def IsActive(self): |
| 185 |
return FreeCAD.ActiveDocument is not None |
| 186 |
|
| 187 |
def Activated(self): |
| 188 |
_save_derived( |
| 189 |
False, "Save with Timestamp", _timestamp_base, _timestamp_name |
| 190 |
) |
| 191 |
|
| 192 |
|
| 193 |
class CmdSaveIncrement: |
| 194 |
"""File > Save Increment — auto-named from the open document.""" |
| 195 |
|
| 196 |
def GetResources(self): |
| 197 |
return { |
| 198 |
"MenuText": "Save Increment", |
| 199 |
"ToolTip": "Save as the next <name>-NN.FCStd and keep working in it", |
| 200 |
"Pixmap": "", |
| 201 |
} |
| 202 |
|
| 203 |
def IsActive(self): |
| 204 |
return FreeCAD.ActiveDocument is not None |
| 205 |
|
| 206 |
def Activated(self): |
| 207 |
_save_derived(False, "Save Increment", _increment_base, _increment_name) |
| 208 |
|
| 209 |
|
| 210 |
class CmdSaveAsTimestamp: |
| 211 |
"""File > Save As with Timestamp — base filename chosen in a dialog.""" |
| 212 |
|
| 213 |
def GetResources(self): |
| 214 |
return { |
| 215 |
"MenuText": "Save As with Timestamp", |
| 216 |
"ToolTip": "Choose a base filename, then save as <base>-YYYYMMDD-HHMMSS.FCStd", |
| 217 |
"Pixmap": "", |
| 218 |
} |
| 219 |
|
| 220 |
def IsActive(self): |
| 221 |
return FreeCAD.ActiveDocument is not None |
| 222 |
|
| 223 |
def Activated(self): |
| 224 |
_save_derived( |
| 225 |
True, |
| 226 |
"Save As with Timestamp – choose base filename", |
| 227 |
_timestamp_base, |
| 228 |
_timestamp_name, |
| 229 |
) |
| 230 |
|
| 231 |
|
| 232 |
class CmdSaveAsIncrement: |
| 233 |
"""File > Save As Increment — base filename chosen in a dialog.""" |
| 234 |
|
| 235 |
def GetResources(self): |
| 236 |
return { |
| 237 |
"MenuText": "Save As Increment", |
| 238 |
"ToolTip": "Choose a base filename, then save as the next <base>-NN.FCStd", |
| 239 |
"Pixmap": "", |
| 240 |
} |
| 241 |
|
| 242 |
def IsActive(self): |
| 243 |
return FreeCAD.ActiveDocument is not None |
| 244 |
|
| 245 |
def Activated(self): |
| 246 |
_save_derived( |
| 247 |
True, |
| 248 |
"Save As Increment – choose base filename", |
| 249 |
_increment_base, |
| 250 |
_increment_name, |
| 251 |
) |
| 252 |
|
| 253 |
|
| 254 |
# --------------------------------------------------------------------------- |
| 255 |
# Menu injection |
| 256 |
# --------------------------------------------------------------------------- |
| 257 |
|
| 258 |
|
| 259 |
_commands_registered = False |
| 260 |
|
| 261 |
|
| 262 |
def install_safely(): |
| 263 |
"""Entry point for InitGui.py. |
| 264 |
|
| 265 |
Called from a QTimer slot, so an escaping exception would propagate into |
| 266 |
Qt's event loop. Report it and carry on instead. |
| 267 |
""" |
| 268 |
try: |
| 269 |
install() |
| 270 |
except Exception as exc: |
| 271 |
FreeCAD.Console.PrintError(f"=== SaveUtils install error: {exc} ===\n") |
| 272 |
|
| 273 |
|
| 274 |
def install(): |
| 275 |
"""Register the commands and put them in the File menu. Safe to call again.""" |
| 276 |
global _commands_registered |
| 277 |
|
| 278 |
FreeCAD.Console.PrintLog("=== SaveUtils: install() called ===\n") |
| 279 |
|
| 280 |
if not _commands_registered: |
| 281 |
# Command names are kept as they were so existing keyboard shortcuts |
| 282 |
# still resolve, even though the classes behind them were renamed. |
| 283 |
FreeCADGui.addCommand("SaveUtils_SaveTimestamp", CmdSaveTimestamp()) |
| 284 |
FreeCADGui.addCommand("SaveUtils_SaveIncrement", CmdSaveIncrement()) |
| 285 |
FreeCADGui.addCommand("SaveUtils_Timestamp", CmdSaveAsTimestamp()) |
| 286 |
FreeCADGui.addCommand("SaveUtils_Increment", CmdSaveAsIncrement()) |
| 287 |
_commands_registered = True |
| 288 |
|
| 289 |
_inject_menu() |
| 290 |
|
| 291 |
|
| 292 |
# Menu text, tooltip and command name for each item, in display order |
| 293 |
_ITEMS = [ |
| 294 |
( |
| 295 |
"Save with Timestamp", |
| 296 |
"Save as <name>-YYYYMMDD-HHMMSS.FCStd and keep working in it", |
| 297 |
"SaveUtils_SaveTimestamp", |
| 298 |
), |
| 299 |
( |
| 300 |
"Save Increment", |
| 301 |
"Save as the next <name>-NN.FCStd and keep working in it", |
| 302 |
"SaveUtils_SaveIncrement", |
| 303 |
), |
| 304 |
( |
| 305 |
"Save As with Timestamp", |
| 306 |
"Choose a base filename, then save as <base>-YYYYMMDD-HHMMSS.FCStd", |
| 307 |
"SaveUtils_Timestamp", |
| 308 |
), |
| 309 |
( |
| 310 |
"Save As Increment", |
| 311 |
"Choose a base filename, then save as the next <base>-NN.FCStd", |
| 312 |
"SaveUtils_Increment", |
| 313 |
), |
| 314 |
] |
| 315 |
|
| 316 |
_ITEM_TEXTS = {text for text, _, _ in _ITEMS} |
| 317 |
|
| 318 |
|
| 319 |
def _alive(obj): |
| 320 |
"""True if the C++ half of a PySide wrapper still exists. |
| 321 |
|
| 322 |
Deliberately catches everything: this is only ever used to decide whether |
| 323 |
touching `obj` is safe, so it must not be able to raise on its own. |
| 324 |
""" |
| 325 |
if obj is None: |
| 326 |
return False |
| 327 |
try: |
| 328 |
obj.objectName() |
| 329 |
return True |
| 330 |
except Exception: |
| 331 |
return False |
| 332 |
|
| 333 |
|
| 334 |
def _addr(obj): |
| 335 |
"""Identity of the underlying C++ object, so recreations are visible.""" |
| 336 |
if obj is None: |
| 337 |
return "None" |
| 338 |
try: |
| 339 |
import shiboken6 |
| 340 |
|
| 341 |
return hex(shiboken6.getCppPointer(obj)[0]) |
| 342 |
except Exception: |
| 343 |
try: |
| 344 |
return f"py:{id(obj):x}" |
| 345 |
except Exception: |
| 346 |
return "?" |
| 347 |
|
| 348 |
|
| 349 |
def report(): |
| 350 |
"""Diagnostics. Run from FreeCAD's Python console: |
| 351 |
|
| 352 |
import SaveUtils; SaveUtils.report() |
| 353 |
|
| 354 |
Everything is collected before anything is printed: writing to the Report |
| 355 |
view can pump the event loop, and FreeCAD may destroy menus while it does, |
| 356 |
which would invalidate the wrappers mid-report. |
| 357 |
""" |
| 358 |
lines = [] |
| 359 |
try: |
| 360 |
mw = FreeCADGui.getMainWindow() |
| 361 |
lines.append(f"commands registered : {_commands_registered}") |
| 362 |
lines.append(f"main window : {_addr(mw)} alive={_alive(mw)}") |
| 363 |
if not _alive(mw): |
| 364 |
return _emit(lines) |
| 365 |
|
| 366 |
menubar = mw.menuBar() |
| 367 |
lines.append( |
| 368 |
f"menu bar : {_addr(menubar)} alive={_alive(menubar)} " |
| 369 |
f"visible={menubar.isVisible()} native={menubar.isNativeMenuBar()}" |
| 370 |
) |
| 371 |
|
| 372 |
lines.append("menu bar entries : text | objectName | visible | addr") |
| 373 |
for action in menubar.actions(): |
| 374 |
try: |
| 375 |
menu = action.menu() |
| 376 |
if menu is None: |
| 377 |
lines.append(f" (no submenu) {action.text()!r}") |
| 378 |
continue |
| 379 |
lines.append( |
| 380 |
f" {action.text()!r} | {menu.objectName()!r} | " |
| 381 |
f"{action.isVisible()} | {_addr(menu)}" |
| 382 |
) |
| 383 |
except RuntimeError: |
| 384 |
lines.append(" <dead QMenu wrapper>") |
| 385 |
|
| 386 |
file_menu = _find_file_menu(menubar) |
| 387 |
lines.append( |
| 388 |
f"resolved File menu : {_addr(file_menu)} alive={_alive(file_menu)}" |
| 389 |
) |
| 390 |
lines.append( |
| 391 |
f"hooked menu : {_addr(_hooked_menu)} " |
| 392 |
f"alive={_alive(_hooked_menu)} same={_hooked_menu is file_menu}" |
| 393 |
) |
| 394 |
lines.append( |
| 395 |
f"our actions : {len(_our_actions)} tracked, " |
| 396 |
f"{sum(1 for a in _our_actions if _alive(a))} alive" |
| 397 |
) |
| 398 |
lines.append(f"Show-event watcher : {'installed' if _watcher else 'NOT installed'}") |
| 399 |
|
| 400 |
if _alive(file_menu): |
| 401 |
lines.append("File menu contents :") |
| 402 |
for act in file_menu.actions(): |
| 403 |
try: |
| 404 |
mark = " <-- ours" if act.text() in _ITEM_TEXTS else "" |
| 405 |
label = "--separator--" if act.isSeparator() else act.text() |
| 406 |
lines.append(f" {label!r} visible={act.isVisible()}{mark}") |
| 407 |
except RuntimeError: |
| 408 |
lines.append(" <dead QAction wrapper>") |
| 409 |
|
| 410 |
# Every QMenu anywhere under the main window that looks like a File menu. |
| 411 |
# If the one shown on screen is not the one we resolved, it shows up here. |
| 412 |
try: |
| 413 |
menu_cls = None |
| 414 |
for action in menubar.actions(): |
| 415 |
try: |
| 416 |
if action.menu() is not None: |
| 417 |
menu_cls = type(action.menu()) |
| 418 |
break |
| 419 |
except RuntimeError: |
| 420 |
continue |
| 421 |
candidates = mw.findChildren(menu_cls) if menu_cls else [] |
| 422 |
lines.append(f"all QMenus under mw : {len(candidates)}") |
| 423 |
for m in candidates: |
| 424 |
try: |
| 425 |
if "file" in m.objectName().replace("&", "").strip().lower(): |
| 426 |
parent = m.parent() |
| 427 |
lines.append( |
| 428 |
f" {m.objectName()!r} addr={_addr(m)} " |
| 429 |
f"actions={len(m.actions())} " |
| 430 |
f"parent={type(parent).__name__ if parent else None}" |
| 431 |
) |
| 432 |
except RuntimeError: |
| 433 |
lines.append(" <dead QMenu wrapper>") |
| 434 |
except Exception as exc: |
| 435 |
lines.append(f"child scan failed : {exc!r}") |
| 436 |
except Exception as exc: |
| 437 |
lines.append(f"report failed: {exc!r}") |
| 438 |
|
| 439 |
return _emit(lines) |
| 440 |
|
| 441 |
|
| 442 |
def _emit(lines): |
| 443 |
text = "\n".join(["=== SaveUtils report ==="] + lines) |
| 444 |
# print() lands in the Python console, where whoever ran report() is looking |
| 445 |
print(text) |
| 446 |
return None |
| 447 |
|
| 448 |
|
| 449 |
def force(): |
| 450 |
"""Re-run installation right now, then report. For use from the console.""" |
| 451 |
install_safely() |
| 452 |
return report() |
| 453 |
|
| 454 |
|
| 455 |
def _menu_entries(menubar): |
| 456 |
"""[(action, menu, objectName, text)] for every live menu in the menu bar. |
| 457 |
|
| 458 |
FreeCAD tears menus down and rebuilds them on workbench switches, so the menu |
| 459 |
bar can hand back wrappers whose underlying C++ QMenu is already gone. |
| 460 |
Touching one of those raises RuntimeError rather than returning None. |
| 461 |
""" |
| 462 |
entries = [] |
| 463 |
for action in menubar.actions(): |
| 464 |
try: |
| 465 |
menu = action.menu() |
| 466 |
if menu is None: |
| 467 |
continue |
| 468 |
entries.append( |
| 469 |
( |
| 470 |
action, |
| 471 |
menu, |
| 472 |
menu.objectName().replace("&", "").strip().lower(), |
| 473 |
action.text().replace("&", "").strip().lower(), |
| 474 |
) |
| 475 |
) |
| 476 |
except RuntimeError: |
| 477 |
continue |
| 478 |
return entries |
| 479 |
|
| 480 |
|
| 481 |
def _find_file_menu(menubar): |
| 482 |
# Match on objectName first (most reliable), then on display text |
| 483 |
matches = [e for e in _menu_entries(menubar) if e[2] == "file" or e[3] == "file"] |
| 484 |
if not matches: |
| 485 |
return None |
| 486 |
# A rebuild can leave a detached File menu behind. Injecting into that one |
| 487 |
# succeeds silently and shows nothing, so prefer a menu the user can open. |
| 488 |
for action, menu, _, _ in matches: |
| 489 |
if action.isVisible() and _alive(menu): |
| 490 |
return menu |
| 491 |
menu = matches[0][1] |
| 492 |
return menu if _alive(menu) else None |
| 493 |
|
| 494 |
|
| 495 |
_our_actions = [] |
| 496 |
|
| 497 |
|
| 498 |
def _add_items(file_menu): |
| 499 |
"""Put the SaveUtils entries into `file_menu`. No-op if they are there.""" |
| 500 |
global _our_actions |
| 501 |
|
| 502 |
existing = {act.text() for act in file_menu.actions()} |
| 503 |
if existing & _ITEM_TEXTS: |
| 504 |
return False |
| 505 |
|
| 506 |
# A rebuild can strip our items while leaving the separator we added, so |
| 507 |
# clear out whatever is left of the last injection before adding a new one. |
| 508 |
for act in _our_actions: |
| 509 |
try: |
| 510 |
file_menu.removeAction(act) |
| 511 |
except RuntimeError: |
| 512 |
pass |
| 513 |
_our_actions = [] |
| 514 |
|
| 515 |
new_actions = [] |
| 516 |
for text, tip, command in _ITEMS: |
| 517 |
action = QtGui.QAction(text, file_menu) |
| 518 |
action.setToolTip(tip) |
| 519 |
# Bind the command name per-iteration so every lambda keeps its own |
| 520 |
action.triggered.connect( |
| 521 |
lambda checked=False, cmd=command: FreeCADGui.runCommand(cmd) |
| 522 |
) |
| 523 |
new_actions.append(action) |
| 524 |
|
| 525 |
# Insert before the first separator |
| 526 |
insert_before = next((a for a in file_menu.actions() if a.isSeparator()), None) |
| 527 |
|
| 528 |
if insert_before: |
| 529 |
for action in new_actions: |
| 530 |
file_menu.insertAction(insert_before, action) |
| 531 |
new_actions.append(file_menu.insertSeparator(insert_before)) |
| 532 |
else: |
| 533 |
new_actions.append(file_menu.addSeparator()) |
| 534 |
for action in new_actions[:-1]: |
| 535 |
file_menu.addAction(action) |
| 536 |
|
| 537 |
_our_actions = [a for a in new_actions if a is not None] |
| 538 |
return True |
| 539 |
|
| 540 |
|
| 541 |
def _is_file_menu(obj): |
| 542 |
"""True if `obj` is a QMenu that FreeCAD built as the File menu.""" |
| 543 |
try: |
| 544 |
# QMenu, not merely any QObject that happens to be named "file" |
| 545 |
if not hasattr(obj, "insertSeparator") or not hasattr(obj, "actions"): |
| 546 |
return False |
| 547 |
return obj.objectName().replace("&", "").strip().lower() == "file" |
| 548 |
except Exception: |
| 549 |
return False |
| 550 |
|
| 551 |
|
| 552 |
class _MenuWatcher(QtCore.QObject): |
| 553 |
"""Injects the entries whenever a File menu is about to be shown. |
| 554 |
|
| 555 |
FreeCAD destroys and recreates the File QMenu, which takes any aboutToShow |
| 556 |
connection — and every action we previously inserted — with it. Filtering |
| 557 |
Show events at application level catches whichever menu object exists at |
| 558 |
the moment it is displayed, so recreation stops mattering. |
| 559 |
""" |
| 560 |
|
| 561 |
def eventFilter(self, obj, event): |
| 562 |
try: |
| 563 |
if event.type() == _SHOW_EVENT and _is_file_menu(obj): |
| 564 |
_add_items(obj) |
| 565 |
except Exception: |
| 566 |
pass |
| 567 |
# Never consume the event |
| 568 |
return False |
| 569 |
|
| 570 |
|
| 571 |
try: |
| 572 |
_SHOW_EVENT = QtCore.QEvent.Type.Show |
| 573 |
except AttributeError: # older bindings expose the enum unscoped |
| 574 |
_SHOW_EVENT = QtCore.QEvent.Show |
| 575 |
|
| 576 |
_watcher = None |
| 577 |
|
| 578 |
|
| 579 |
def _install_watcher(): |
| 580 |
"""Install the app-wide Show filter once.""" |
| 581 |
global _watcher |
| 582 |
if _watcher is not None: |
| 583 |
return |
| 584 |
# QCoreApplication lives in QtCore, so this works regardless of how the |
| 585 |
# PySide shim splits QtGui/QtWidgets across Qt versions. |
| 586 |
app = QtCore.QCoreApplication.instance() |
| 587 |
if app is None: |
| 588 |
return |
| 589 |
_watcher = _MenuWatcher() |
| 590 |
app.installEventFilter(_watcher) |
| 591 |
FreeCAD.Console.PrintLog("=== SaveUtils: menu watcher installed ===\n") |
| 592 |
|
| 593 |
|
| 594 |
_hooked_menu = None |
| 595 |
|
| 596 |
|
| 597 |
def _on_file_menu_about_to_show(): |
| 598 |
"""Re-add the entries just before the menu is displayed. |
| 599 |
|
| 600 |
FreeCAD's MenuManager rebuilds the File menu on workbench switches and drops |
| 601 |
any action it does not own, so a one-shot injection does not survive. Doing |
| 602 |
it here means the items are present whenever the menu is actually opened. |
| 603 |
""" |
| 604 |
global _hooked_menu |
| 605 |
try: |
| 606 |
if _hooked_menu is not None: |
| 607 |
_add_items(_hooked_menu) |
| 608 |
except RuntimeError: |
| 609 |
_hooked_menu = None |
| 610 |
|
| 611 |
|
| 612 |
def _inject_menu(): |
| 613 |
global _hooked_menu |
| 614 |
|
| 615 |
# Works even if the File menu below is never found or is later replaced |
| 616 |
_install_watcher() |
| 617 |
|
| 618 |
mw = FreeCADGui.getMainWindow() |
| 619 |
if mw is None: |
| 620 |
FreeCAD.Console.PrintError("=== SaveUtils: main window not found ===\n") |
| 621 |
return |
| 622 |
|
| 623 |
menubar = mw.menuBar() |
| 624 |
file_menu = _find_file_menu(menubar) |
| 625 |
|
| 626 |
if file_menu is None: |
| 627 |
FreeCAD.Console.PrintError("=== SaveUtils: File menu not found ===\n") |
| 628 |
found = [(e[3], e[2]) for e in _menu_entries(menubar)] |
| 629 |
FreeCAD.Console.PrintMessage(f"=== SaveUtils: menus found: {found} ===\n") |
| 630 |
return |
| 631 |
|
| 632 |
try: |
| 633 |
if _hooked_menu is not file_menu: |
| 634 |
file_menu.aboutToShow.connect(_on_file_menu_about_to_show) |
| 635 |
_hooked_menu = file_menu |
| 636 |
|
| 637 |
added = _add_items(file_menu) |
| 638 |
except RuntimeError as exc: |
| 639 |
# File menu was destroyed underneath us mid-rebuild; the next workbench |
| 640 |
# activation will inject into the replacement. |
| 641 |
FreeCAD.Console.PrintLog(f"=== SaveUtils: menu went away ({exc}) ===\n") |
| 642 |
_hooked_menu = None |
| 643 |
return |
| 644 |
|
| 645 |
FreeCAD.Console.PrintLog( |
| 646 |
"=== SaveUtils: menu items injected ===\n" |
| 647 |
if added |
| 648 |
else "=== SaveUtils: already installed, skipping ===\n" |
| 649 |
) |