From e947822c7a6dbe0df98b3ddebc6f9631d316f41f Mon Sep 17 00:00:00 2001 From: forbes Date: Wed, 18 Feb 2026 17:11:05 -0600 Subject: [PATCH] =?UTF-8?q?feat(create):=20metadata=20editor=20=E2=80=94?= =?UTF-8?q?=20editable=20form=20with=20dirty=20tracking=20and=20save-back?= =?UTF-8?q?=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SiloMetadataEditor widget that opens in an MDI subwindow when the user double-clicks the Metadata node in the Silo tree. Supports editing lifecycle state, tags (add/remove chips), and schema-defined fields with type-inferred widgets (QCheckBox, QDoubleSpinBox, QLineEdit). Changes: - silo_viewers.py: SiloMetadataEditor with dirty tracking, Save/Reset buttons, unsaved-changes close guard, and tag chip management - silo_objects.py: mark_dirty()/is_dirty()/clear_dirty() on proxy - kc_format.py: fix entries=None before hooks; _metadata_save_hook writes dirty RawContent back to silo/ cache on document save Closes #39 --- src/Mod/Create/kc_format.py | 16 ++ src/Mod/Create/silo_objects.py | 14 +- src/Mod/Create/silo_viewers.py | 302 ++++++++++++++++++++++++++++++++- 3 files changed, 330 insertions(+), 2 deletions(-) diff --git a/src/Mod/Create/kc_format.py b/src/Mod/Create/kc_format.py index 17953f2dbd..8a86efb826 100644 --- a/src/Mod/Create/kc_format.py +++ b/src/Mod/Create/kc_format.py @@ -31,6 +31,20 @@ def register_pre_reinject(callback): _pre_reinject_hooks.append(callback) +def _metadata_save_hook(doc, filename, entries): + """Write dirty metadata back to the silo/ cache before ZIP write.""" + obj = doc.getObject("SiloMetadata") + if obj is None or not hasattr(obj, "Proxy"): + return + proxy = obj.Proxy + if proxy is None or not proxy.is_dirty(): + return + entries["silo/metadata.json"] = obj.RawContent.encode("utf-8") + + +register_pre_reinject(_metadata_save_hook) + + KC_VERSION = "1.0" @@ -75,6 +89,8 @@ class _KcFormatObserver: _silo_cache.pop(filename, None) return entries = _silo_cache.pop(filename, None) + if entries is None: + entries = {} for _hook in _pre_reinject_hooks: try: _hook(doc, filename, entries) diff --git a/src/Mod/Create/silo_objects.py b/src/Mod/Create/silo_objects.py index 10693958fc..e6428e15fb 100644 --- a/src/Mod/Create/silo_objects.py +++ b/src/Mod/Create/silo_objects.py @@ -1,5 +1,5 @@ """ -silo_objects.py - FreeCAD FeaturePython proxy for Silo tree leaf nodes. +silo_objects.py - Create FeaturePython proxy for Silo tree leaf nodes. Each silo/ ZIP entry in a .kc file gets one SiloViewerObject in the FreeCAD document tree. All properties are Transient so they are never @@ -51,6 +51,18 @@ class SiloViewerObject: def execute(self, obj): pass + def mark_dirty(self): + """Flag this object's content as modified by a viewer widget.""" + self._dirty = True + + def is_dirty(self): + """Return True if content has been modified since last save.""" + return getattr(self, "_dirty", False) + + def clear_dirty(self): + """Clear the dirty flag after saving.""" + self._dirty = False + def __getstate__(self): return None diff --git a/src/Mod/Create/silo_viewers.py b/src/Mod/Create/silo_viewers.py index a5cd4a5fd4..2f4f00da64 100644 --- a/src/Mod/Create/silo_viewers.py +++ b/src/Mod/Create/silo_viewers.py @@ -1,11 +1,12 @@ """ -silo_viewers.py — Read-only MDI viewer widgets for Silo tree leaf nodes. +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 @@ -124,12 +125,311 @@ def _add_row(form, label_text, display_val, raw_val, copyable): 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() + + # --------------------------------------------------------------------------- # Viewer factory # --------------------------------------------------------------------------- _VIEWER_REGISTRY = { "silo/manifest.json": SiloManifestViewer, + "silo/metadata.json": SiloMetadataEditor, }