From bb14d7b0eff39290528bba16944b44b7be8048b5 Mon Sep 17 00:00:00 2001 From: forbes Date: Wed, 18 Feb 2026 18:46:06 -0600 Subject: [PATCH] =?UTF-8?q?feat(create):=20remaining=20viewers=20=E2=80=94?= =?UTF-8?q?=20dependencies,=20jobs,=20macros,=20approvals=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/Mod/Create/silo_viewers.py | 441 +++++++++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) diff --git a/src/Mod/Create/silo_viewers.py b/src/Mod/Create/silo_viewers.py index c95b8a00c5..4f40f3b9b5 100644 --- a/src/Mod/Create/silo_viewers.py +++ b/src/Mod/Create/silo_viewers.py @@ -548,14 +548,450 @@ class SiloHistoryViewer(QtWidgets.QWidget): 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"{user}")) + + 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, } @@ -566,6 +1002,11 @@ def create_viewer_widget(obj): 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: -- 2.49.1