From be8783bf0a98fca9bc89f6bb02bdafc2c3911e4e Mon Sep 17 00:00:00 2001 From: forbes-0023 Date: Thu, 12 Feb 2026 10:22:42 -0600 Subject: [PATCH] feat(open): replace modal open dialog with MDI tab Extract search-and-open UI into OpenItemWidget (open_search.py), a plain QWidget with item_selected/cancelled signals. Silo_Open now adds this widget as an MDI subwindow instead of running a blocking QDialog, matching the Silo_New tab pattern. Improvements over the old dialog: - Non-blocking: multiple search tabs can be open simultaneously - 500 ms debounce on search input reduces API load - Filter checkbox changes trigger immediate re-search --- freecad/open_search.py | 191 +++++++++++++++++++++++++++++++++++++++ freecad/silo_commands.py | 144 ++++------------------------- 2 files changed, 208 insertions(+), 127 deletions(-) create mode 100644 freecad/open_search.py 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/silo_commands.py b/freecad/silo_commands.py index 2df4899..661b800 100644 --- a/freecad/silo_commands.py +++ b/freecad/silo_commands.py @@ -594,143 +594,33 @@ 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