Files
create/src/Mod/Create/silo_viewers.py
forbes bb14d7b0ef
All checks were successful
Build and Test / build (pull_request) Successful in 31m3s
feat(create): remaining viewers — dependencies, jobs, macros, approvals (#41)
Add four viewer widgets for the remaining Silo tree node types:

- SiloApprovalsViewer: ECO approval status cards with colored status
  icons, state badge, and Open in Silo Web UI button
- SiloDependencyTable: QTableView with resolution status (checks open
  documents for matching part_uuid)
- SiloJobViewer: YAML source editor with Edit/Lock toggle, monospace
  font, dirty tracking, and unsaved-changes guard
- SiloMacroEditor: Python source editor with Run Now (exec in FreeCAD
  context), Save button, and dirty tracking

Also extends the viewer factory with prefix-based routing for
silo/jobs/*.yaml and silo/macros/*.py entries.

Closes #41
2026-02-18 18:46:06 -06:00

1019 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
silo_viewers.py — MDI viewer widgets for Silo tree leaf nodes.
Each viewer is a plain QWidget suitable for embedding in an MDI subwindow.
The ``create_viewer_widget`` factory routes a SiloViewerObject to the
appropriate viewer class based on its SiloPath property.
"""
import copy
import json
import FreeCAD
from PySide import QtCore, QtWidgets
# ---------------------------------------------------------------------------
# Manifest Viewer
# ---------------------------------------------------------------------------
_MANIFEST_FIELDS = [
("Part UUID", "part_uuid", True),
("Silo Instance", "silo_instance", True),
("Revision Hash", "revision_hash", False),
("KC Version", "kc_version", False),
("Created", "created_at", False),
("Modified", "modified_at", False),
("Created By", "created_by", False),
]
class SiloManifestViewer(QtWidgets.QWidget):
"""Read-only form displaying ``silo/manifest.json`` fields."""
WINDOW_TITLE = "Silo \u2014 Manifest"
def __init__(self, obj, parent=None):
super().__init__(parent)
self.setObjectName(f"SiloViewer_{obj.Name}")
self._build_ui(obj.RawContent)
# -- layout --------------------------------------------------------------
def _build_ui(self, raw_content):
try:
data = json.loads(raw_content)
except Exception:
data = {}
outer = QtWidgets.QVBoxLayout(self)
outer.setContentsMargins(16, 16, 16, 16)
outer.setSpacing(12)
title = QtWidgets.QLabel("Silo Manifest")
font = title.font()
font.setPointSize(font.pointSize() + 2)
font.setBold(True)
title.setFont(font)
outer.addWidget(title)
line = QtWidgets.QFrame()
line.setFrameShape(QtWidgets.QFrame.HLine)
line.setFrameShadow(QtWidgets.QFrame.Sunken)
outer.addWidget(line)
form = QtWidgets.QFormLayout()
form.setLabelAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
form.setHorizontalSpacing(16)
form.setVerticalSpacing(8)
outer.addLayout(form)
for label_text, key, copyable in _MANIFEST_FIELDS:
raw_val = str(data.get(key, "") or "")
display_val = _format_value(key, raw_val)
_add_row(form, label_text, display_val, raw_val, copyable)
outer.addStretch()
# -- no state to serialize -----------------------------------------------
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _format_value(key, value):
"""Format a manifest value for display."""
if not value:
return "\u2014" # em-dash
if key in ("created_at", "modified_at"):
try:
s = str(value).replace("Z", "").replace("T", " ")
if len(s) >= 16:
s = s[:16]
return s + " UTC"
except Exception:
pass
return str(value)
def _add_row(form, label_text, display_val, raw_val, copyable):
"""Add a single row to the form layout."""
value_label = QtWidgets.QLabel(display_val)
value_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
if copyable and raw_val:
row = QtWidgets.QWidget()
row_layout = QtWidgets.QHBoxLayout(row)
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(4)
row_layout.addWidget(value_label)
btn = QtWidgets.QToolButton()
btn.setText("\u29c9") # ⧉
btn.setFixedWidth(24)
btn.setToolTip(f"Copy {label_text}")
btn.clicked.connect(
lambda checked=False, v=raw_val: QtWidgets.QApplication.clipboard().setText(
v
)
)
row_layout.addWidget(btn)
row_layout.addStretch()
form.addRow(label_text + ":", row)
else:
form.addRow(label_text + ":", value_label)
# ---------------------------------------------------------------------------
# Metadata Editor
# ---------------------------------------------------------------------------
_LIFECYCLE_OPTIONS = ["draft", "review", "released", "obsolete"]
class SiloMetadataEditor(QtWidgets.QWidget):
"""Editable form for ``silo/metadata.json`` fields."""
WINDOW_TITLE = "Silo \u2014 Metadata"
def __init__(self, obj, parent=None):
super().__init__(parent)
self.setObjectName(f"SiloViewer_{obj.Name}")
self._obj = obj
self._original_data = {}
self._field_widgets = {} # key -> QWidget
self._tags_layout = None
self._lifecycle_combo = None
self._save_btn = None
self._reset_btn = None
self._schema_name = ""
self._build_ui(obj.RawContent)
# -- layout --------------------------------------------------------------
def _build_ui(self, raw_content):
try:
data = json.loads(raw_content)
except Exception:
data = {}
self._original_data = copy.deepcopy(data)
self._schema_name = data.get("schema", "")
outer = QtWidgets.QVBoxLayout(self)
outer.setContentsMargins(16, 16, 16, 16)
outer.setSpacing(12)
# Header: title + lifecycle combo
header = QtWidgets.QHBoxLayout()
title = QtWidgets.QLabel("Part Metadata")
font = title.font()
font.setPointSize(font.pointSize() + 2)
font.setBold(True)
title.setFont(font)
header.addWidget(title)
header.addStretch()
self._lifecycle_combo = QtWidgets.QComboBox()
self._lifecycle_combo.addItems(_LIFECYCLE_OPTIONS)
current_lc = data.get("lifecycle", "draft")
idx = self._lifecycle_combo.findText(current_lc)
if idx >= 0:
self._lifecycle_combo.setCurrentIndex(idx)
self._lifecycle_combo.currentTextChanged.connect(self._on_edited)
header.addWidget(QtWidgets.QLabel("Lifecycle:"))
header.addWidget(self._lifecycle_combo)
outer.addLayout(header)
# Separator
line = QtWidgets.QFrame()
line.setFrameShape(QtWidgets.QFrame.HLine)
line.setFrameShadow(QtWidgets.QFrame.Sunken)
outer.addWidget(line)
# Scroll area for form content
scroll = QtWidgets.QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QtWidgets.QFrame.NoFrame)
content = QtWidgets.QWidget()
content_layout = QtWidgets.QVBoxLayout(content)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.setSpacing(12)
# Schema label
schema_text = self._schema_name or "\u2014"
content_layout.addWidget(QtWidgets.QLabel(f"Schema: {schema_text}"))
# Tags row
tags_container = QtWidgets.QWidget()
tags_outer = QtWidgets.QHBoxLayout(tags_container)
tags_outer.setContentsMargins(0, 0, 0, 0)
tags_outer.setSpacing(4)
tags_outer.addWidget(QtWidgets.QLabel("Tags:"))
self._tags_layout = QtWidgets.QHBoxLayout()
self._tags_layout.setContentsMargins(0, 0, 0, 0)
self._tags_layout.setSpacing(4)
tags_outer.addLayout(self._tags_layout)
for tag in data.get("tags", []):
self._add_tag_chip(tag)
add_btn = QtWidgets.QToolButton()
add_btn.setText("+")
add_btn.setFixedWidth(24)
add_btn.setToolTip("Add tag")
add_btn.clicked.connect(self._on_add_tag)
tags_outer.addWidget(add_btn)
tags_outer.addStretch()
content_layout.addWidget(tags_container)
# Fields section
fields = data.get("fields", {})
if fields:
group = QtWidgets.QGroupBox("Fields")
form = QtWidgets.QFormLayout(group)
form.setLabelAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
form.setHorizontalSpacing(16)
form.setVerticalSpacing(8)
for key, value in fields.items():
widget = self._make_field_widget(value)
self._field_widgets[key] = widget
form.addRow(key + ":", widget)
content_layout.addWidget(group)
content_layout.addStretch()
scroll.setWidget(content)
outer.addWidget(scroll, 1)
# Button bar
btn_bar = QtWidgets.QHBoxLayout()
btn_bar.addStretch()
self._save_btn = QtWidgets.QPushButton("Save")
self._save_btn.setEnabled(False)
self._save_btn.clicked.connect(self._on_save)
btn_bar.addWidget(self._save_btn)
self._reset_btn = QtWidgets.QPushButton("Reset")
self._reset_btn.setEnabled(False)
self._reset_btn.clicked.connect(self._on_reset)
btn_bar.addWidget(self._reset_btn)
outer.addLayout(btn_bar)
# -- field widgets -------------------------------------------------------
def _make_field_widget(self, value):
"""Create an appropriate edit widget based on the value type."""
if isinstance(value, bool):
cb = QtWidgets.QCheckBox()
cb.setChecked(value)
cb.stateChanged.connect(self._on_edited)
return cb
if isinstance(value, (int, float)):
spin = QtWidgets.QDoubleSpinBox()
spin.setDecimals(4)
spin.setRange(-1e9, 1e9)
spin.setValue(float(value))
spin.valueChanged.connect(self._on_edited)
return spin
le = QtWidgets.QLineEdit()
le.setText(str(value) if value is not None else "")
le.textChanged.connect(self._on_edited)
return le
def _read_field_widget(self, widget):
"""Read a value back from a field widget."""
if isinstance(widget, QtWidgets.QCheckBox):
return widget.isChecked()
if isinstance(widget, QtWidgets.QDoubleSpinBox):
return widget.value()
return widget.text()
# -- tag management ------------------------------------------------------
def _add_tag_chip(self, tag_text):
"""Add a removable tag chip to the tags row."""
chip = QtWidgets.QFrame()
chip.setStyleSheet(
"QFrame { border: 1px solid palette(mid); "
"border-radius: 8px; padding: 2px 4px; }"
)
chip_layout = QtWidgets.QHBoxLayout(chip)
chip_layout.setContentsMargins(4, 0, 0, 0)
chip_layout.setSpacing(2)
chip_layout.addWidget(QtWidgets.QLabel(tag_text))
remove_btn = QtWidgets.QToolButton()
remove_btn.setText("\u00d7") # ×
remove_btn.setFixedSize(16, 16)
remove_btn.setStyleSheet("QToolButton { border: none; }")
remove_btn.clicked.connect(
lambda checked=False, c=chip: self._remove_tag_chip(c)
)
chip_layout.addWidget(remove_btn)
self._tags_layout.addWidget(chip)
def _remove_tag_chip(self, chip):
"""Remove a tag chip and mark dirty."""
self._tags_layout.removeWidget(chip)
chip.deleteLater()
self._on_edited()
def _on_add_tag(self):
"""Prompt for a new tag and add it."""
text, ok = QtWidgets.QInputDialog.getText(self, "Add Tag", "Tag name:")
if ok and text.strip():
self._add_tag_chip(text.strip())
self._on_edited()
def _get_tags(self):
"""Collect current tag strings from the chip widgets."""
tags = []
for i in range(self._tags_layout.count()):
chip = self._tags_layout.itemAt(i).widget()
if chip is None:
continue
label = chip.findChild(QtWidgets.QLabel)
if label:
tags.append(label.text())
return tags
# -- dirty tracking / save / reset ---------------------------------------
def _on_edited(self, *args):
"""Mark the object dirty and enable Save/Reset."""
self._obj.Proxy.mark_dirty()
self._save_btn.setEnabled(True)
self._reset_btn.setEnabled(True)
def _collect_data(self):
"""Build a metadata dict from current widget state."""
return {
"schema": self._schema_name,
"lifecycle": self._lifecycle_combo.currentText(),
"tags": self._get_tags(),
"fields": {
key: self._read_field_widget(w)
for key, w in self._field_widgets.items()
},
}
def _on_save(self):
"""Write current form state to obj.RawContent."""
new_data = self._collect_data()
self._obj.RawContent = json.dumps(new_data, indent=2)
self._obj.Proxy.clear_dirty()
self._original_data = copy.deepcopy(new_data)
self._save_btn.setEnabled(False)
self._reset_btn.setEnabled(False)
def _on_reset(self):
"""Revert all fields to last-saved state."""
# Clear existing field widgets and tags
self._field_widgets.clear()
for i in reversed(range(self._tags_layout.count())):
w = self._tags_layout.itemAt(i).widget()
if w:
w.deleteLater()
# Repopulate from original data
data = self._original_data
# Lifecycle
idx = self._lifecycle_combo.findText(data.get("lifecycle", "draft"))
if idx >= 0:
self._lifecycle_combo.blockSignals(True)
self._lifecycle_combo.setCurrentIndex(idx)
self._lifecycle_combo.blockSignals(False)
# Tags
for tag in data.get("tags", []):
self._add_tag_chip(tag)
# Fields — find the QGroupBox and rebuild its form
for child in self.findChildren(QtWidgets.QGroupBox):
if child.title() == "Fields":
form = child.layout()
# Clear existing rows
while form.count():
item = form.takeAt(0)
if item.widget():
item.widget().deleteLater()
# Rebuild
for key, value in data.get("fields", {}).items():
widget = self._make_field_widget(value)
self._field_widgets[key] = widget
form.addRow(key + ":", widget)
break
self._obj.Proxy.clear_dirty()
self._save_btn.setEnabled(False)
self._reset_btn.setEnabled(False)
# -- close guard ---------------------------------------------------------
def closeEvent(self, event):
if self._obj.Proxy.is_dirty():
reply = QtWidgets.QMessageBox.question(
self,
"Unsaved Changes",
"Metadata has unsaved changes. Discard?",
QtWidgets.QMessageBox.Discard | QtWidgets.QMessageBox.Cancel,
QtWidgets.QMessageBox.Cancel,
)
if reply == QtWidgets.QMessageBox.Cancel:
event.ignore()
return
self._obj.Proxy.clear_dirty()
event.accept()
# ---------------------------------------------------------------------------
# History Viewer
# ---------------------------------------------------------------------------
_BADGE_STYLES = {
"draft": "background: #45475a; color: #cdd6f4;",
"review": "background: #f9e2af; color: #11111b;",
"released": "background: #a6e3a1; color: #11111b;",
"obsolete": "background: #f38ba8; color: #11111b;",
}
class SiloHistoryViewer(QtWidgets.QWidget):
"""Read-only revision timeline from ``silo/history.json``."""
WINDOW_TITLE = "Silo \u2014 History"
def __init__(self, obj, parent=None):
super().__init__(parent)
self.setObjectName(f"SiloViewer_{obj.Name}")
self._build_ui(obj.RawContent)
def _build_ui(self, raw_content):
try:
data = json.loads(raw_content)
except Exception:
data = {}
revisions = data.get("revisions", [])
outer = QtWidgets.QVBoxLayout(self)
outer.setContentsMargins(16, 16, 16, 16)
outer.setSpacing(12)
# Header
header = QtWidgets.QHBoxLayout()
title = QtWidgets.QLabel("Revision History")
font = title.font()
font.setPointSize(font.pointSize() + 2)
font.setBold(True)
title.setFont(font)
header.addWidget(title)
header.addStretch()
outer.addLayout(header)
# Separator
line = QtWidgets.QFrame()
line.setFrameShape(QtWidgets.QFrame.HLine)
line.setFrameShadow(QtWidgets.QFrame.Sunken)
outer.addWidget(line)
if not revisions:
placeholder = QtWidgets.QLabel("No revision history available.")
placeholder.setAlignment(QtCore.Qt.AlignCenter)
outer.addWidget(placeholder)
outer.addStretch()
return
# Scroll area with revision cards
scroll = QtWidgets.QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QtWidgets.QFrame.NoFrame)
content = QtWidgets.QWidget()
cards_layout = QtWidgets.QVBoxLayout(content)
cards_layout.setContentsMargins(0, 0, 0, 0)
cards_layout.setSpacing(0)
for i, rev in enumerate(revisions):
cards_layout.addWidget(self._make_revision_card(rev))
if i < len(revisions) - 1:
sep = QtWidgets.QFrame()
sep.setFrameShape(QtWidgets.QFrame.HLine)
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
cards_layout.addWidget(sep)
cards_layout.addStretch()
scroll.setWidget(content)
outer.addWidget(scroll, 1)
def _make_revision_card(self, rev):
"""Build a widget for a single revision entry."""
card = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(card)
layout.setContentsMargins(4, 8, 4, 8)
layout.setSpacing(4)
# Top line: Rev N · status badge · author · timestamp
top = QtWidgets.QHBoxLayout()
top.setSpacing(8)
rev_num = rev.get("revision", "?")
rev_label = QtWidgets.QLabel(f"<b>Rev {rev_num}</b>")
top.addWidget(rev_label)
status = rev.get("status", "draft")
badge = QtWidgets.QLabel(status)
style = _BADGE_STYLES.get(status, _BADGE_STYLES["draft"])
badge.setStyleSheet(
f"QLabel {{ {style} border-radius: 4px; "
f"padding: 1px 6px; font-size: 11px; }}"
)
top.addWidget(badge)
author = rev.get("author", "")
if author:
top.addWidget(QtWidgets.QLabel(f"\u00b7 {author}"))
timestamp = rev.get("timestamp", "")
if timestamp:
display_ts = _format_value("created_at", timestamp)
top.addWidget(QtWidgets.QLabel(f"\u00b7 {display_ts}"))
top.addStretch()
layout.addLayout(top)
# Comment line
comment = rev.get("comment", "")
if comment:
comment_label = QtWidgets.QLabel(comment)
comment_label.setWordWrap(True)
comment_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
layout.addWidget(comment_label)
return card
# ---------------------------------------------------------------------------
# Approvals Viewer
# ---------------------------------------------------------------------------
_APPROVAL_STATUS_ICONS = {
"approved": ("\u2713", "#a6e3a1"), # ✓ green
"pending": ("\u25cb", "#cdd6f4"), # ○ text
"rejected": ("\u2717", "#f38ba8"), # ✗ red
}
class SiloApprovalsViewer(QtWidgets.QWidget):
"""Read-only ECO approval status from ``silo/approvals.json``."""
WINDOW_TITLE = "Silo \u2014 Approvals"
def __init__(self, obj, parent=None):
super().__init__(parent)
self.setObjectName(f"SiloViewer_{obj.Name}")
self._build_ui(obj.RawContent)
def _build_ui(self, raw_content):
try:
data = json.loads(raw_content)
except Exception:
data = {}
outer = QtWidgets.QVBoxLayout(self)
outer.setContentsMargins(16, 16, 16, 16)
outer.setSpacing(12)
# Header: ECO ID + state badge
header = QtWidgets.QHBoxLayout()
eco_id = data.get("eco", "\u2014")
title = QtWidgets.QLabel(eco_id)
font = title.font()
font.setPointSize(font.pointSize() + 2)
font.setBold(True)
title.setFont(font)
header.addWidget(title)
state = data.get("state", "")
if state:
badge = QtWidgets.QLabel(state)
style = _BADGE_STYLES.get(
state.replace("pending_", ""),
"background: #45475a; color: #cdd6f4;",
)
badge.setStyleSheet(
f"QLabel {{ {style} border-radius: 4px; "
f"padding: 1px 6px; font-size: 11px; }}"
)
header.addWidget(badge)
header.addStretch()
outer.addLayout(header)
# Separator
sep = QtWidgets.QFrame()
sep.setFrameShape(QtWidgets.QFrame.HLine)
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
outer.addWidget(sep)
# Approver cards
approvers = data.get("approvers", [])
if not approvers:
outer.addWidget(QtWidgets.QLabel("No approval data available."))
else:
for approver in approvers:
outer.addWidget(self._make_approver_card(approver))
# Open in web UI button
silo_instance = ""
try:
import FreeCAD as _fc
doc = _fc.ActiveDocument
if doc:
manifest_obj = doc.getObject("SiloManifest")
if manifest_obj and manifest_obj.RawContent:
m = json.loads(manifest_obj.RawContent)
silo_instance = m.get("silo_instance", "") or ""
except Exception:
pass
if silo_instance and eco_id != "\u2014":
link_btn = QtWidgets.QPushButton("Open in Silo Web UI \u2192")
link_btn.clicked.connect(
lambda checked=False, url=f"{silo_instance}/eco/{eco_id}": _open_url(
url
)
)
outer.addWidget(link_btn)
outer.addStretch()
def _make_approver_card(self, approver):
card = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(card)
layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(2)
top = QtWidgets.QHBoxLayout()
top.setSpacing(8)
status = approver.get("status", "pending")
icon_char, icon_color = _APPROVAL_STATUS_ICONS.get(
status, _APPROVAL_STATUS_ICONS["pending"]
)
icon_label = QtWidgets.QLabel(icon_char)
icon_label.setStyleSheet(f"color: {icon_color}; font-weight: bold;")
top.addWidget(icon_label)
user = approver.get("user", "")
top.addWidget(QtWidgets.QLabel(f"<b>{user}</b>"))
role = approver.get("role", "")
if role:
role_label = QtWidgets.QLabel(role)
role_label.setStyleSheet("color: #a6adc8;")
top.addWidget(role_label)
top.addWidget(QtWidgets.QLabel(status))
top.addStretch()
layout.addLayout(top)
ts = approver.get("timestamp")
if ts:
ts_label = QtWidgets.QLabel(_format_value("created_at", ts))
ts_label.setStyleSheet("color: #a6adc8; margin-left: 24px;")
layout.addWidget(ts_label)
return card
def _open_url(url):
"""Open a URL in the system browser."""
from PySide.QtCore import QUrl
from PySide.QtGui import QDesktopServices
QDesktopServices.openUrl(QUrl(url))
# ---------------------------------------------------------------------------
# Dependency Table
# ---------------------------------------------------------------------------
class SiloDependencyTable(QtWidgets.QWidget):
"""Table view of assembly dependencies from ``silo/dependencies.json``."""
WINDOW_TITLE = "Silo \u2014 Dependencies"
def __init__(self, obj, parent=None):
super().__init__(parent)
self.setObjectName(f"SiloViewer_{obj.Name}")
self._build_ui(obj.RawContent)
def _build_ui(self, raw_content):
try:
data = json.loads(raw_content)
except Exception:
data = {}
links = data.get("links", [])
outer = QtWidgets.QVBoxLayout(self)
outer.setContentsMargins(16, 16, 16, 16)
outer.setSpacing(12)
# Header
title = QtWidgets.QLabel("Assembly Dependencies")
font = title.font()
font.setPointSize(font.pointSize() + 2)
font.setBold(True)
title.setFont(font)
outer.addWidget(title)
sep = QtWidgets.QFrame()
sep.setFrameShape(QtWidgets.QFrame.HLine)
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
outer.addWidget(sep)
if not links:
outer.addWidget(QtWidgets.QLabel("No dependencies defined."))
outer.addStretch()
return
# Table
from PySide.QtGui import QStandardItemModel, QStandardItem
table = QtWidgets.QTableView()
model = QStandardItemModel(len(links), 5)
model.setHorizontalHeaderLabels(["Label", "Part Number", "Rev", "Qty", "Local"])
for row, link in enumerate(links):
label_item = QStandardItem(link.get("label", ""))
label_item.setEditable(False)
model.setItem(row, 0, label_item)
pn_item = QStandardItem(link.get("part_number", ""))
pn_item.setEditable(False)
model.setItem(row, 1, pn_item)
rev_item = QStandardItem(str(link.get("revision", "")))
rev_item.setEditable(False)
model.setItem(row, 2, rev_item)
qty_item = QStandardItem(str(link.get("quantity", "")))
qty_item.setEditable(False)
model.setItem(row, 3, qty_item)
# Resolution: check if UUID is in any open document
resolved = _is_dependency_resolved(link.get("uuid", ""))
status_item = QStandardItem("\u2713" if resolved else "\u2717")
status_item.setEditable(False)
model.setItem(row, 4, status_item)
table.setModel(model)
table.horizontalHeader().setStretchLastSection(True)
table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
outer.addWidget(table, 1)
def _is_dependency_resolved(uuid_str):
"""Check if a dependency UUID corresponds to a locally open document."""
if not uuid_str:
return False
try:
for doc in FreeCAD.listDocuments().values():
manifest_obj = doc.getObject("SiloManifest")
if manifest_obj and manifest_obj.RawContent:
m = json.loads(manifest_obj.RawContent)
if m.get("part_uuid") == uuid_str:
return True
except Exception:
pass
return False
# ---------------------------------------------------------------------------
# Job Viewer
# ---------------------------------------------------------------------------
class SiloJobViewer(QtWidgets.QWidget):
"""YAML source viewer for ``silo/jobs/*.yaml`` files."""
WINDOW_TITLE = "Silo \u2014 Job"
def __init__(self, obj, parent=None):
super().__init__(parent)
self.setObjectName(f"SiloViewer_{obj.Name}")
self._obj = obj
self._build_ui(obj.RawContent, obj.Label)
def _build_ui(self, raw_content, label):
outer = QtWidgets.QVBoxLayout(self)
outer.setContentsMargins(16, 16, 16, 16)
outer.setSpacing(12)
# Header
header = QtWidgets.QHBoxLayout()
title = QtWidgets.QLabel(label)
font = title.font()
font.setPointSize(font.pointSize() + 2)
font.setBold(True)
title.setFont(font)
header.addWidget(title)
header.addStretch()
self._edit_btn = QtWidgets.QPushButton("Edit")
self._edit_btn.setCheckable(True)
self._edit_btn.toggled.connect(self._on_edit_toggled)
header.addWidget(self._edit_btn)
outer.addLayout(header)
sep = QtWidgets.QFrame()
sep.setFrameShape(QtWidgets.QFrame.HLine)
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
outer.addWidget(sep)
# Source editor
self._editor = QtWidgets.QPlainTextEdit()
self._editor.setPlainText(raw_content)
self._editor.setReadOnly(True)
self._editor.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
mono = self._editor.font()
mono.setFamily("monospace")
self._editor.setFont(mono)
outer.addWidget(self._editor, 1)
# Button bar
btn_bar = QtWidgets.QHBoxLayout()
btn_bar.addStretch()
self._save_btn = QtWidgets.QPushButton("Save")
self._save_btn.setEnabled(False)
self._save_btn.clicked.connect(self._on_save)
btn_bar.addWidget(self._save_btn)
outer.addLayout(btn_bar)
self._editor.textChanged.connect(self._on_text_changed)
def _on_edit_toggled(self, checked):
self._editor.setReadOnly(not checked)
self._edit_btn.setText("Lock" if checked else "Edit")
def _on_text_changed(self):
if not self._editor.isReadOnly():
self._obj.Proxy.mark_dirty()
self._save_btn.setEnabled(True)
def _on_save(self):
self._obj.RawContent = self._editor.toPlainText()
self._obj.Proxy.clear_dirty()
self._save_btn.setEnabled(False)
def closeEvent(self, event):
if self._obj.Proxy.is_dirty():
reply = QtWidgets.QMessageBox.question(
self,
"Unsaved Changes",
"Job definition has unsaved changes. Discard?",
QtWidgets.QMessageBox.Discard | QtWidgets.QMessageBox.Cancel,
QtWidgets.QMessageBox.Cancel,
)
if reply == QtWidgets.QMessageBox.Cancel:
event.ignore()
return
self._obj.Proxy.clear_dirty()
event.accept()
# ---------------------------------------------------------------------------
# Macro Editor
# ---------------------------------------------------------------------------
class SiloMacroEditor(QtWidgets.QWidget):
"""Python source editor for ``silo/macros/*.py`` files."""
WINDOW_TITLE = "Silo \u2014 Macro"
def __init__(self, obj, parent=None):
super().__init__(parent)
self.setObjectName(f"SiloViewer_{obj.Name}")
self._obj = obj
self._build_ui(obj.RawContent, obj.Label)
def _build_ui(self, raw_content, label):
outer = QtWidgets.QVBoxLayout(self)
outer.setContentsMargins(16, 16, 16, 16)
outer.setSpacing(12)
# Header
header = QtWidgets.QHBoxLayout()
title = QtWidgets.QLabel(label)
font = title.font()
font.setPointSize(font.pointSize() + 2)
font.setBold(True)
title.setFont(font)
header.addWidget(title)
header.addStretch()
outer.addLayout(header)
sep = QtWidgets.QFrame()
sep.setFrameShape(QtWidgets.QFrame.HLine)
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
outer.addWidget(sep)
# Source editor
self._editor = QtWidgets.QPlainTextEdit()
self._editor.setPlainText(raw_content)
self._editor.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
mono = self._editor.font()
mono.setFamily("monospace")
self._editor.setFont(mono)
outer.addWidget(self._editor, 1)
self._editor.textChanged.connect(self._on_text_changed)
# Button bar
btn_bar = QtWidgets.QHBoxLayout()
btn_bar.addStretch()
run_btn = QtWidgets.QPushButton("Run Now")
run_btn.clicked.connect(self._on_run)
btn_bar.addWidget(run_btn)
self._save_btn = QtWidgets.QPushButton("Save")
self._save_btn.setEnabled(False)
self._save_btn.clicked.connect(self._on_save)
btn_bar.addWidget(self._save_btn)
outer.addLayout(btn_bar)
def _on_text_changed(self):
self._obj.Proxy.mark_dirty()
self._save_btn.setEnabled(True)
def _on_save(self):
self._obj.RawContent = self._editor.toPlainText()
self._obj.Proxy.clear_dirty()
self._save_btn.setEnabled(False)
def _on_run(self):
"""Execute the macro in FreeCAD's Python context."""
code = self._editor.toPlainText()
try:
FreeCAD.Console.PrintMessage(f"--- Running macro: {self._obj.Label} ---\n")
exec(code, {"__builtins__": __builtins__, "FreeCAD": FreeCAD})
except Exception as exc:
FreeCAD.Console.PrintError(f"Macro error: {exc}\n")
def closeEvent(self, event):
if self._obj.Proxy.is_dirty():
reply = QtWidgets.QMessageBox.question(
self,
"Unsaved Changes",
"Macro has unsaved changes. Discard?",
QtWidgets.QMessageBox.Discard | QtWidgets.QMessageBox.Cancel,
QtWidgets.QMessageBox.Cancel,
)
if reply == QtWidgets.QMessageBox.Cancel:
event.ignore()
return
self._obj.Proxy.clear_dirty()
event.accept()
# ---------------------------------------------------------------------------
# Viewer factory
# ---------------------------------------------------------------------------
# Exact path → viewer class
_VIEWER_REGISTRY = {
"silo/manifest.json": SiloManifestViewer,
"silo/metadata.json": SiloMetadataEditor,
"silo/history.json": SiloHistoryViewer,
"silo/approvals.json": SiloApprovalsViewer,
"silo/dependencies.json": SiloDependencyTable,
}
# Prefix → viewer class (for subdirectory entries)
_VIEWER_PREFIX_REGISTRY = {
"silo/jobs/": SiloJobViewer,
"silo/macros/": SiloMacroEditor,
}
def create_viewer_widget(obj):
"""Route a Silo tree node to the appropriate viewer widget.
Returns a QWidget ready for MDI embedding, or None if no viewer
is registered for this node's SiloPath.
"""
cls = _VIEWER_REGISTRY.get(obj.SiloPath)
if cls is None:
for prefix, prefix_cls in _VIEWER_PREFIX_REGISTRY.items():
if obj.SiloPath.startswith(prefix):
cls = prefix_cls
break
if cls is None:
return None
try:
return cls(obj)
except Exception as exc:
FreeCAD.Console.PrintWarning(
f"silo_viewers: failed to create viewer for {obj.SiloPath!r}: {exc}\n"
)
return None