feat(create): manifest viewer — read-only MDI widget for silo/manifest.json (#38)
All checks were successful
Build and Test / build (pull_request) Successful in 29m13s

Add SiloManifestViewer widget that opens in an MDI subwindow when the
user double-clicks the Manifest node in the Silo tree. Displays all
manifest.json fields in a read-only QFormLayout with copy buttons for
Part UUID and Silo Instance.

New files:
- silo_viewers.py: SiloManifestViewer widget + create_viewer_widget()
  factory with _VIEWER_REGISTRY for future viewer classes

Modified files:
- silo_viewproviders.py: doubleClicked() wired to open MDI subwindow
  with deduplication via widget objectName()
- CMakeLists.txt: add silo_viewers.py to install list

Closes #38
This commit is contained in:
forbes
2026-02-18 16:48:34 -06:00
parent 65f24b23eb
commit 90728414a9
3 changed files with 190 additions and 2 deletions

View File

@@ -25,6 +25,7 @@ install(
silo_document.py
silo_objects.py
silo_tree.py
silo_viewers.py
silo_viewproviders.py
update_checker.py
${CMAKE_CURRENT_BINARY_DIR}/version.py

View File

@@ -0,0 +1,151 @@
"""
silo_viewers.py — Read-only 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 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)
# ---------------------------------------------------------------------------
# Viewer factory
# ---------------------------------------------------------------------------
_VIEWER_REGISTRY = {
"silo/manifest.json": SiloManifestViewer,
}
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:
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

View File

@@ -63,8 +63,44 @@ class SiloViewerViewProvider:
return ""
def doubleClicked(self, vobj):
"""Phase 1: no action on double-click."""
return False
"""Open a read-only MDI viewer for this silo node."""
try:
import FreeCADGui
from PySide import QtWidgets
from silo_viewers import create_viewer_widget
obj = vobj.Object
widget = create_viewer_widget(obj)
if widget is None:
return False
mw = FreeCADGui.getMainWindow()
mdi = mw.findChild(QtWidgets.QMdiArea)
if mdi is None:
return False
# Reuse existing subwindow if already open for this object
target_name = widget.objectName()
for sw in mdi.subWindowList():
if sw.widget() and sw.widget().objectName() == target_name:
widget.deleteLater()
mdi.setActiveSubWindow(sw)
sw.show()
return True
sw = mdi.addSubWindow(widget)
sw.setWindowTitle(getattr(widget, "WINDOW_TITLE", "Silo Viewer"))
sw.show()
mdi.setActiveSubWindow(sw)
return True
except Exception as exc:
import FreeCAD
FreeCAD.Console.PrintWarning(
f"silo_viewproviders: doubleClicked failed: {exc}\n"
)
return False
def setupContextMenu(self, vobj, menu):
"""Phase 1: no context menu items."""