diff --git a/freecad/InitGui.py b/freecad/InitGui.py index e410398..6687c52 100644 --- a/freecad/InitGui.py +++ b/freecad/InitGui.py @@ -35,9 +35,20 @@ class SiloWorkbench(FreeCADGui.Workbench): except Exception as e: FreeCAD.Console.PrintWarning(f"Could not register Silo origin: {e}\n") + # Silo origin toolbar — shown as an overlay on any context when the + # active document is Silo-tracked. Registered as Unavailable so + # EditingContextResolver controls visibility via the overlay system. + self.silo_toolbar_commands = [ + "Silo_Commit", + "Silo_Pull", + "Silo_Push", + "Separator", + "Silo_Info", + "Silo_BOM", + ] + self.appendToolbar("Silo Origin", self.silo_toolbar_commands, "Unavailable") + # Silo menu provides admin/management commands. - # File operations (New/Open/Save) are handled by the standard File - # toolbar via the origin system -- no separate Silo toolbar needed. self.menu_commands = [ "Silo_Info", "Silo_BOM", @@ -68,6 +79,44 @@ class SiloWorkbench(FreeCADGui.Workbench): FreeCADGui.addWorkbench(SiloWorkbench()) FreeCAD.Console.PrintMessage("Silo workbench registered\n") + +# --------------------------------------------------------------------------- +# Silo overlay context — adds "Silo Origin" toolbar to any active context +# when the current document is Silo-tracked. +# --------------------------------------------------------------------------- + + +def _register_silo_overlay(): + """Register the Silo overlay after the Silo workbench has initialised.""" + + def _silo_overlay_match(): + """Return True if the active document is Silo-tracked.""" + try: + doc = FreeCAD.ActiveDocument + if not doc: + return False + from silo_origin import get_silo_origin + + origin = get_silo_origin() + return origin.ownsDocument(doc) + except Exception: + return False + + try: + FreeCADGui.registerEditingOverlay( + "silo", # overlay id + ["Silo Origin"], # toolbar names to append + _silo_overlay_match, # match function + ) + except Exception as e: + FreeCAD.Console.PrintWarning(f"Silo overlay registration failed: {e}\n") + + +from PySide import QtCore as _QtCore + +_QtCore.QTimer.singleShot(2500, _register_silo_overlay) + + # Override the Start page with Silo-aware version (must happen before # the C++ StartLauncher fires at ~100ms after GUI init) try: @@ -92,6 +141,4 @@ def _handle_startup_urls(): handle_kindred_url(arg) -from PySide import QtCore - -QtCore.QTimer.singleShot(500, _handle_startup_urls) +_QtCore.QTimer.singleShot(500, _handle_startup_urls) diff --git a/freecad/open_search.py b/freecad/open_search.py new file mode 100644 index 0000000..4107113 --- /dev/null +++ b/freecad/open_search.py @@ -0,0 +1,191 @@ +"""Search-and-open widget for Kindred Create. + +Provides :class:`OpenItemWidget`, a plain ``QWidget`` that can be +embedded in an MDI sub-window. Searches both the Silo database and +local CAD files, presenting results in a unified table. Emits +``item_selected`` when the user picks an item and ``cancelled`` when +the user clicks Cancel. +""" + +import FreeCAD +from PySide import QtCore, QtWidgets + + +class OpenItemWidget(QtWidgets.QWidget): + """Search-and-open widget for embedding in an MDI subwindow. + + Parameters + ---------- + client : SiloClient + Authenticated Silo API client instance. + search_local_fn : callable + Function that accepts a search term string and returns an + iterable of dicts with keys ``part_number``, ``description``, + ``path``, ``modified``. + parent : QWidget, optional + Parent widget. + + Signals + ------- + item_selected(dict) + Emitted when the user selects an item. The dict contains + keys: *part_number*, *description*, *item_type*, *source* + (``"database"``, ``"local"``, or ``"both"``), *modified*, + and *path* (str or ``None``). + cancelled() + Emitted when the user clicks Cancel. + """ + + item_selected = QtCore.Signal(dict) + cancelled = QtCore.Signal() + + def __init__(self, client, search_local_fn, parent=None): + super().__init__(parent) + self._client = client + self._search_local = search_local_fn + self._results_data = [] + + self.setMinimumWidth(700) + self.setMinimumHeight(500) + + self._build_ui() + + # Debounced search timer (500 ms) + self._search_timer = QtCore.QTimer(self) + self._search_timer.setSingleShot(True) + self._search_timer.setInterval(500) + self._search_timer.timeout.connect(self._do_search) + + # Populate on first display + QtCore.QTimer.singleShot(0, self._do_search) + + # ---- UI construction --------------------------------------------------- + + def _build_ui(self): + layout = QtWidgets.QVBoxLayout(self) + layout.setSpacing(8) + + # Search row + self._search_input = QtWidgets.QLineEdit() + self._search_input.setPlaceholderText("Search by part number or description...") + self._search_input.textChanged.connect(self._on_search_changed) + layout.addWidget(self._search_input) + + # Filter checkboxes + filter_layout = QtWidgets.QHBoxLayout() + self._db_checkbox = QtWidgets.QCheckBox("Database") + self._db_checkbox.setChecked(True) + self._local_checkbox = QtWidgets.QCheckBox("Local Files") + self._local_checkbox.setChecked(True) + self._db_checkbox.toggled.connect(self._on_filter_changed) + self._local_checkbox.toggled.connect(self._on_filter_changed) + filter_layout.addWidget(self._db_checkbox) + filter_layout.addWidget(self._local_checkbox) + filter_layout.addStretch() + layout.addLayout(filter_layout) + + # Results table + self._results_table = QtWidgets.QTableWidget() + self._results_table.setColumnCount(5) + self._results_table.setHorizontalHeaderLabels( + ["Part Number", "Description", "Type", "Source", "Modified"] + ) + self._results_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self._results_table.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + self._results_table.horizontalHeader().setStretchLastSection(True) + self._results_table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self._results_table.doubleClicked.connect(self._open_selected) + layout.addWidget(self._results_table, 1) + + # Buttons + btn_layout = QtWidgets.QHBoxLayout() + btn_layout.addStretch() + open_btn = QtWidgets.QPushButton("Open") + open_btn.clicked.connect(self._open_selected) + cancel_btn = QtWidgets.QPushButton("Cancel") + cancel_btn.clicked.connect(self.cancelled.emit) + btn_layout.addWidget(open_btn) + btn_layout.addWidget(cancel_btn) + layout.addLayout(btn_layout) + + # ---- Search logic ------------------------------------------------------ + + def _on_search_changed(self, _text): + """Restart debounce timer on each keystroke.""" + self._search_timer.start() + + def _on_filter_changed(self, _checked): + """Re-run search immediately when filter checkboxes change.""" + self._do_search() + + def _do_search(self): + """Execute search against database and/or local files.""" + search_term = self._search_input.text().strip() + self._results_data = [] + + if self._db_checkbox.isChecked(): + try: + for item in self._client.list_items(search=search_term): + self._results_data.append( + { + "part_number": item.get("part_number", ""), + "description": item.get("description", ""), + "item_type": item.get("item_type", ""), + "source": "database", + "modified": item.get("updated_at", "")[:10] + if item.get("updated_at") + else "", + "path": None, + } + ) + except Exception as e: + FreeCAD.Console.PrintWarning(f"DB search failed: {e}\n") + + if self._local_checkbox.isChecked(): + try: + for item in self._search_local(search_term): + existing = next( + (r for r in self._results_data if r["part_number"] == item["part_number"]), + None, + ) + if existing: + existing["source"] = "both" + existing["path"] = item.get("path") + else: + self._results_data.append( + { + "part_number": item.get("part_number", ""), + "description": item.get("description", ""), + "item_type": "", + "source": "local", + "modified": item.get("modified", "")[:10] + if item.get("modified") + else "", + "path": item.get("path"), + } + ) + except Exception as e: + FreeCAD.Console.PrintWarning(f"Local search failed: {e}\n") + + self._populate_table() + + def _populate_table(self): + """Refresh the results table from ``_results_data``.""" + self._results_table.setRowCount(len(self._results_data)) + for row, data in enumerate(self._results_data): + self._results_table.setItem(row, 0, QtWidgets.QTableWidgetItem(data["part_number"])) + self._results_table.setItem(row, 1, QtWidgets.QTableWidgetItem(data["description"])) + self._results_table.setItem(row, 2, QtWidgets.QTableWidgetItem(data["item_type"])) + self._results_table.setItem(row, 3, QtWidgets.QTableWidgetItem(data["source"])) + self._results_table.setItem(row, 4, QtWidgets.QTableWidgetItem(data["modified"])) + self._results_table.resizeColumnsToContents() + + # ---- Selection --------------------------------------------------------- + + def _open_selected(self): + """Emit ``item_selected`` with the data from the selected row.""" + selected = self._results_table.selectedItems() + if not selected: + return + row = selected[0].row() + self.item_selected.emit(dict(self._results_data[row])) diff --git a/freecad/schema_form.py b/freecad/schema_form.py index 4f30f84..a6cba20 100644 --- a/freecad/schema_form.py +++ b/freecad/schema_form.py @@ -1,8 +1,12 @@ -"""Schema-driven new-item dialog for Kindred Create. +"""Schema-driven new-item form for Kindred Create. Fetches schema data from the Silo REST API and builds a dynamic Qt form that mirrors the React ``CreateItemPane`` — category picker, property fields grouped by domain, live part number preview, and project tagging. + +The primary widget is :class:`SchemaFormWidget` (a plain ``QWidget``) +which can be embedded in an MDI tab, dock panel, or wrapped in the +backward-compatible :class:`SchemaFormDialog` modal. """ import json @@ -211,30 +215,29 @@ def _read_field(widget: QtWidgets.QWidget, prop_def: dict): # --------------------------------------------------------------------------- -# Main form dialog +# Embeddable form widget # --------------------------------------------------------------------------- -class SchemaFormDialog(QtWidgets.QDialog): - """Schema-driven new-item dialog. +class SchemaFormWidget(QtWidgets.QWidget): + """Schema-driven new-item form widget. - Fetches schema and property data from the Silo API, builds the form - dynamically, and returns the creation result on accept. + A plain ``QWidget`` that can be embedded in an MDI subwindow, dock + panel, or dialog. Emits :pyqt:`item_created` on successful creation + and :pyqt:`cancelled` when the user clicks Cancel. """ + item_created = QtCore.Signal(dict) + cancelled = QtCore.Signal() + def __init__(self, client, parent=None): super().__init__(parent) self._client = client - self._result = None self._prop_widgets = {} # {key: (widget, prop_def)} self._prop_groups = [] # list of _CollapsibleGroup to clear on category change self._categories = {} self._projects = [] - self.setWindowTitle("New Item") - self.setMinimumSize(600, 500) - self.resize(680, 700) - self._load_schema_data() self._build_ui() @@ -307,8 +310,18 @@ class SchemaFormDialog(QtWidgets.QDialog): root = QtWidgets.QVBoxLayout(self) root.setSpacing(8) + # Inline error label (hidden by default) + self._error_label = QtWidgets.QLabel() + self._error_label.setStyleSheet( + "background-color: #f38ba8; color: #1e1e2e; " + "padding: 8px; border-radius: 4px; font-weight: bold;" + ) + self._error_label.setAlignment(QtCore.Qt.AlignCenter) + self._error_label.hide() + root.addWidget(self._error_label) + # Part number preview banner - self._pn_label = QtWidgets.QLabel("Part Number: —") + self._pn_label = QtWidgets.QLabel("Part Number: \u2014") self._pn_label.setStyleSheet("font-size: 16px; font-weight: bold; padding: 8px;") self._pn_label.setAlignment(QtCore.Qt.AlignCenter) root.addWidget(self._pn_label) @@ -401,7 +414,7 @@ class SchemaFormDialog(QtWidgets.QDialog): btn_layout.addStretch() self._cancel_btn = QtWidgets.QPushButton("Cancel") - self._cancel_btn.clicked.connect(self.reject) + self._cancel_btn.clicked.connect(self.cancelled.emit) btn_layout.addWidget(self._cancel_btn) self._create_btn = QtWidgets.QPushButton("Create") @@ -506,8 +519,10 @@ class SchemaFormDialog(QtWidgets.QDialog): pn = self._generate_pn_preview(category) if pn: self._pn_label.setText(f"Part Number: {pn}") + self.setWindowTitle(f"New: {pn}") else: self._pn_label.setText(f"Part Number: {category}-????") + self.setWindowTitle(f"New: {category}-????") # -- submission --------------------------------------------------------- @@ -550,10 +565,12 @@ class SchemaFormDialog(QtWidgets.QDialog): def _on_create(self): """Validate and submit the form.""" + self._error_label.hide() data = self._collect_form_data() if not data["category"]: - QtWidgets.QMessageBox.warning(self, "Validation", "Category is required.") + self._error_label.setText("Category is required.") + self._error_label.show() return try: @@ -563,11 +580,47 @@ class SchemaFormDialog(QtWidgets.QDialog): data["description"], projects=data["projects"], ) - self._result = result - self._result["_form_data"] = data - self.accept() + result["_form_data"] = data + self.item_created.emit(result) except Exception as e: - QtWidgets.QMessageBox.critical(self, "Error", f"Failed to create item:\n{e}") + self._error_label.setText(f"Failed to create item: {e}") + self._error_label.show() + + +# --------------------------------------------------------------------------- +# Modal dialog wrapper (backward compatibility) +# --------------------------------------------------------------------------- + + +class SchemaFormDialog(QtWidgets.QDialog): + """Modal dialog wrapper around :class:`SchemaFormWidget`. + + Provides the same ``exec_and_create()`` API as the original + implementation for callers that still need blocking modal behavior. + """ + + def __init__(self, client, parent=None): + super().__init__(parent) + self.setWindowTitle("New Item") + self.setMinimumSize(600, 500) + self.resize(680, 700) + self._result = None + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self._form = SchemaFormWidget(client, parent=self) + self._form.item_created.connect(self._on_created) + self._form.cancelled.connect(self.reject) + layout.addWidget(self._form) + + @property + def _desc_edit(self): + """Expose description field for pre-fill by callers.""" + return self._form._desc_edit + + def _on_created(self, result): + self._result = result + self.accept() def exec_and_create(self): """Show dialog and return the creation result, or None if cancelled.""" diff --git a/freecad/silo_commands.py b/freecad/silo_commands.py index 4294e27..6013cb5 100644 --- a/freecad/silo_commands.py +++ b/freecad/silo_commands.py @@ -594,149 +594,45 @@ class Silo_Open: } def Activated(self): - from PySide import QtCore, QtGui + from PySide import QtGui, QtWidgets - dialog = QtGui.QDialog() - dialog.setWindowTitle("Silo - Open Item") - dialog.setMinimumWidth(700) - dialog.setMinimumHeight(500) + from open_search import OpenItemWidget - layout = QtGui.QVBoxLayout(dialog) + mw = FreeCADGui.getMainWindow() + mdi = mw.findChild(QtWidgets.QMdiArea) + if not mdi: + return - # Search row - search_layout = QtGui.QHBoxLayout() - search_input = QtGui.QLineEdit() - search_input.setPlaceholderText("Search by part number or description...") - search_layout.addWidget(search_input) - layout.addLayout(search_layout) + widget = OpenItemWidget(_client, search_local_files) - # Filters - filter_layout = QtGui.QHBoxLayout() - db_checkbox = QtGui.QCheckBox("Database") - db_checkbox.setChecked(True) - local_checkbox = QtGui.QCheckBox("Local Files") - local_checkbox.setChecked(True) - filter_layout.addWidget(db_checkbox) - filter_layout.addWidget(local_checkbox) - filter_layout.addStretch() - layout.addLayout(filter_layout) + sw = mdi.addSubWindow(widget) + sw.setWindowTitle("Open Item") + sw.setWindowIcon(QtGui.QIcon(_icon("open"))) + sw.show() + mdi.setActiveSubWindow(sw) - # Results table - results_table = QtGui.QTableWidget() - results_table.setColumnCount(5) - results_table.setHorizontalHeaderLabels( - ["Part Number", "Description", "Type", "Source", "Modified"] - ) - results_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) - results_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) - results_table.horizontalHeader().setStretchLastSection(True) - results_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) - layout.addWidget(results_table) - - results_data = [] - - def do_search(): - nonlocal results_data - search_term = search_input.text().strip() - results_data = [] - results_table.setRowCount(0) - - if db_checkbox.isChecked(): - try: - for item in _client.list_items(search=search_term): - results_data.append( - { - "part_number": item.get("part_number", ""), - "description": item.get("description", ""), - "item_type": item.get("item_type", ""), - "source": "database", - "modified": item.get("updated_at", "")[:10] - if item.get("updated_at") - else "", - "path": None, - } - ) - except Exception as e: - FreeCAD.Console.PrintWarning(f"DB search failed: {e}\n") - - if local_checkbox.isChecked(): - try: - for item in search_local_files(search_term): - existing = next( - (r for r in results_data if r["part_number"] == item["part_number"]), - None, - ) - if existing: - existing["source"] = "both" - existing["path"] = item.get("path") - else: - results_data.append( - { - "part_number": item.get("part_number", ""), - "description": item.get("description", ""), - "item_type": "", - "source": "local", - "modified": item.get("modified", "")[:10] - if item.get("modified") - else "", - "path": item.get("path"), - } - ) - except Exception as e: - FreeCAD.Console.PrintWarning(f"Local search failed: {e}\n") - - results_table.setRowCount(len(results_data)) - for row, data in enumerate(results_data): - results_table.setItem(row, 0, QtGui.QTableWidgetItem(data["part_number"])) - results_table.setItem(row, 1, QtGui.QTableWidgetItem(data["description"])) - results_table.setItem(row, 2, QtGui.QTableWidgetItem(data["item_type"])) - results_table.setItem(row, 3, QtGui.QTableWidgetItem(data["source"])) - results_table.setItem(row, 4, QtGui.QTableWidgetItem(data["modified"])) - results_table.resizeColumnsToContents() - - _open_after_close = [None] - - def open_selected(): - selected = results_table.selectedItems() - if not selected: - return - row = selected[0].row() - _open_after_close[0] = dict(results_data[row]) - dialog.accept() - - search_input.textChanged.connect(lambda: do_search()) - results_table.doubleClicked.connect(open_selected) - - # Buttons - btn_layout = QtGui.QHBoxLayout() - open_btn = QtGui.QPushButton("Open") - open_btn.clicked.connect(open_selected) - cancel_btn = QtGui.QPushButton("Cancel") - cancel_btn.clicked.connect(dialog.reject) - btn_layout.addStretch() - btn_layout.addWidget(open_btn) - btn_layout.addWidget(cancel_btn) - layout.addLayout(btn_layout) - - do_search() - dialog.exec_() - - # Open the document AFTER the dialog has fully closed so that - # heavy document loads (especially Assembly files) don't run - # inside the dialog's nested event loop, which can cause crashes. - data = _open_after_close[0] - if data is not None: + def _on_selected(data): + sw.close() if data.get("path"): FreeCAD.openDocument(data["path"]) else: _sync.open_item(data["part_number"]) + widget.item_selected.connect(_on_selected) + widget.cancelled.connect(sw.close) + def IsActive(self): return True class Silo_New: - """Create new item with part number.""" + """Create new item with part number. + + Opens a pre-document MDI tab containing the schema-driven creation + form. Each invocation opens a new tab so multiple items can be + prepared in parallel. On successful creation the tab closes and + the real document opens in its place. + """ def GetResources(self): return { @@ -746,53 +642,60 @@ class Silo_New: } def Activated(self): - from PySide import QtGui + from PySide import QtGui, QtWidgets - from schema_form import SchemaFormDialog + from schema_form import SchemaFormWidget - sel = FreeCADGui.Selection.getSelection() - - dlg = SchemaFormDialog(_client, parent=FreeCADGui.getMainWindow()) - - # Pre-fill description from selected object - if sel: - dlg._desc_edit.setText(sel[0].Label) - - result = dlg.exec_and_create() - if result is None: + mw = FreeCADGui.getMainWindow() + mdi = mw.findChild(QtWidgets.QMdiArea) + if not mdi: return - part_number = result["part_number"] - form_data = result.get("_form_data", {}) - selected_projects = form_data.get("projects") or [] + # Each invocation creates a new pre-document tab + form = SchemaFormWidget(_client) - try: - if sel: - # Tag selected object - obj = sel[0] - set_silo_properties( - obj, - { - "SiloItemId": result.get("id", ""), - "SiloPartNumber": part_number, - "SiloRevision": 1, - }, - ) - obj.Label = part_number - _sync.save_to_canonical_path(FreeCAD.ActiveDocument, force_rename=True) - else: - # Create new document - _sync.create_document_for_item(result, save=True) + # Pre-fill description from current selection + sel = FreeCADGui.Selection.getSelection() + if sel: + form._desc_edit.setText(sel[0].Label) - msg = f"Part number: {part_number}" - if selected_projects: - msg += f"\nTagged with projects: {', '.join(selected_projects)}" + # Add as MDI subwindow (appears as a tab alongside documents) + sw = mdi.addSubWindow(form) + sw.setWindowTitle("New Item") + sw.setWindowIcon(QtGui.QIcon(_icon("new"))) + sw.show() + mdi.setActiveSubWindow(sw) - FreeCAD.Console.PrintMessage(f"Created: {part_number}\n") - QtGui.QMessageBox.information(None, "Item Created", msg) + # On creation: process result, close tab, open real document + def _on_created(result): + part_number = result["part_number"] - except Exception as e: - QtGui.QMessageBox.critical(None, "Error", str(e)) + try: + sel_now = FreeCADGui.Selection.getSelection() + if sel_now: + obj = sel_now[0] + set_silo_properties( + obj, + { + "SiloItemId": result.get("id", ""), + "SiloPartNumber": part_number, + "SiloRevision": 1, + }, + ) + obj.Label = part_number + _sync.save_to_canonical_path(FreeCAD.ActiveDocument, force_rename=True) + else: + _sync.create_document_for_item(result, save=True) + + FreeCAD.Console.PrintMessage(f"Created: {part_number}\n") + except Exception as e: + FreeCAD.Console.PrintError(f"Failed to process created item: {e}\n") + + # Close the pre-document tab + sw.close() + + form.item_created.connect(_on_created) + form.cancelled.connect(sw.close) def IsActive(self): return _server_mode == "normal"