All checks were successful
Build and Test / build (pull_request) Successful in 31m3s
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
1019 lines
34 KiB
Python
1019 lines
34 KiB
Python
"""
|
||
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
|