| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| """Widget for browsing ToolBit assets with filtering and sorting.""" |
|
|
| import yaml |
| from typing import List, Optional, cast, Sequence |
| from PySide import QtGui, QtCore |
| from PySide.QtGui import QApplication, QMessageBox, QMenu, QAction, QKeySequence, QDialog |
| from PySide.QtCore import QMimeData |
| import FreeCAD |
| import Path |
| from ...assets import AssetManager, AssetUri |
| from ..models.base import ToolBit |
| from ..serializers.yaml import YamlToolBitSerializer |
| from .toollist import ToolBitListWidget, CompactToolBitListWidget, ToolBitUriRole |
| from .editor import ToolBitEditor |
| from .util import natural_sort_key |
|
|
|
|
| Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) |
| Path.Log.trackModule(Path.Log.thisModule()) |
|
|
|
|
| class ToolBitBrowserWidget(QtGui.QWidget): |
| """ |
| A widget to browse, filter, and select ToolBit assets from the |
| AssetManager, with sorting and batch insertion. |
| """ |
|
|
| |
| toolSelected = QtCore.Signal(str) |
| |
| itemDoubleClicked = QtCore.Signal(ToolBit) |
|
|
| |
| _search_timer_interval = 300 |
|
|
| def __init__( |
| self, |
| asset_manager: AssetManager, |
| store: str = "local", |
| parent=None, |
| tool_no_factory=None, |
| tool_fetcher=None, |
| compact=False, |
| ): |
| super().__init__(parent) |
| self._asset_manager = asset_manager |
| self._tool_no_factory = tool_no_factory |
| self._compact_mode = compact |
|
|
| self._is_fetching = False |
| self._store_name = store |
| self._all_assets: Sequence[ToolBit] = [] |
| self._current_search = "" |
| self._sort_key = "tool_no" if tool_no_factory else "label" |
| self._selected_uris: List[str] = [] |
|
|
| |
| self._search_edit = QtGui.QLineEdit() |
| self._search_edit.setPlaceholderText("Search toolbits...") |
|
|
| |
| self._sort_combo = QtGui.QComboBox() |
| if self._tool_no_factory: |
| self._sort_combo.addItem("Sort by Toolbit Number", "tool_no") |
| self._sort_combo.addItem("Sort by Label", "label") |
| self._sort_combo.setCurrentIndex(0) |
| self._sort_combo.setVisible(self._tool_no_factory is not None) |
|
|
| |
| self._top_layout = QtGui.QHBoxLayout() |
| self._top_layout.addWidget(self._search_edit, 3) |
| self._top_layout.addWidget(self._sort_combo, 1) |
|
|
| if self._compact_mode: |
| self._tool_list_widget = CompactToolBitListWidget(tool_no_factory=self._tool_no_factory) |
| else: |
| self._tool_list_widget = ToolBitListWidget(tool_no_factory=self._tool_no_factory) |
|
|
| |
| layout = QtGui.QVBoxLayout(self) |
| layout.addLayout(self._top_layout) |
| layout.addWidget(self._tool_list_widget) |
|
|
| |
| self._search_timer = QtCore.QTimer(self) |
| self._search_timer.setSingleShot(True) |
| self._search_timer.setInterval(self._search_timer_interval) |
| self._search_timer.timeout.connect(self._update_list) |
| self._search_edit.textChanged.connect(self._search_timer.start) |
| self._sort_combo.currentIndexChanged.connect(self._on_sort_changed) |
|
|
| |
| self._tool_list_widget.itemDoubleClicked.connect(self._on_item_double_clicked) |
| self._tool_list_widget.itemSelectionChanged.connect(self._on_item_selection_changed) |
|
|
| |
| self._tool_list_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) |
| self._tool_list_widget.customContextMenuRequested.connect(self._show_context_menu) |
|
|
| |
| self._add_shortcuts() |
|
|
| |
| |
| |
| self.tool_fetcher = tool_fetcher or self._tool_fetcher |
|
|
| def showEvent(self, event): |
| """Handles the widget show event to trigger initial data fetch.""" |
| super().showEvent(event) |
| |
| if not self._all_assets and not self._is_fetching: |
| self.refresh() |
| |
| self._search_edit.setFocus() |
|
|
| def _tool_fetcher(self) -> Sequence[ToolBit]: |
| return cast( |
| List[ToolBit], |
| self._asset_manager.fetch( |
| asset_type="toolbit", |
| depth=0, |
| store=self._store_name, |
| ), |
| ) |
|
|
| def select_by_uri(self, uris: List[str]): |
| if not uris: |
| return |
|
|
| |
| is_first = True |
| for i in range(self._tool_list_widget.count()): |
| item = self._tool_list_widget.item(i) |
| if item.data(ToolBitUriRole) in uris: |
| self._tool_list_widget.setCurrentItem(item) |
| if is_first: |
| |
| is_first = False |
| self._tool_list_widget.scrollToItem(item) |
|
|
| def refresh(self): |
| """Fetches all ToolBit assets and stores them in memory, then updates the UI.""" |
| if self._is_fetching: |
| return |
| self._is_fetching = True |
| try: |
| self._all_assets = self.tool_fetcher() |
| finally: |
| self._is_fetching = False |
| Path.Log.debug(f"Loaded {len(self._all_assets)} ToolBits.") |
|
|
| self._sort_assets() |
| self._update_list() |
|
|
| def _sort_assets(self): |
| """Sorts the in-memory assets based on the current sort key.""" |
| if self._sort_key == "label": |
| self._all_assets.sort(key=lambda x: natural_sort_key(x.label)) |
| elif self._sort_key == "tool_no" and self._tool_no_factory: |
| self._all_assets.sort( |
| key=lambda x: int(self._tool_no_factory(x) or 0) if self._tool_no_factory else 0 |
| ) |
|
|
| def _matches_search(self, toolbit, search_term): |
| """Checks if a ToolBit matches the search term.""" |
| search_term = search_term.lower() |
| return search_term in toolbit.label.lower() or search_term in toolbit.summary.lower() |
|
|
| def _update_list(self): |
| """Updates the list widget based on current search and sort.""" |
| if self._is_fetching: |
| return |
|
|
| self._current_search = self._search_edit.text() |
| filtered_assets = [ |
| asset |
| for asset in self._all_assets |
| if not self._current_search or self._matches_search(asset, self._current_search) |
| ] |
|
|
| |
| current_items = {} |
| for i in range(self._tool_list_widget.count()): |
| item = self._tool_list_widget.item(i) |
| uri = item.data(ToolBitUriRole) |
| if uri: |
| current_items[uri] = item |
|
|
| |
| for i, asset in enumerate(filtered_assets): |
| uri = str(asset.get_uri()) |
| if uri in current_items: |
| |
| item = current_items[uri] |
| row = self._tool_list_widget.row(item) |
| self._tool_list_widget.takeItem(row) |
| self._tool_list_widget.insert_toolbit(i, asset) |
| del current_items[uri] |
| else: |
| |
| self._tool_list_widget.insert_toolbit(i, asset) |
|
|
| |
| for uri, item in current_items.items(): |
| row = self._tool_list_widget.row(item) |
| self._tool_list_widget.takeItem(row) |
|
|
| |
| if self._selected_uris: |
| first_selected_item = None |
| for i in range(self._tool_list_widget.count()): |
| item = self._tool_list_widget.item(i) |
| uri = item.data(ToolBitUriRole) |
| if uri in self._selected_uris: |
| item.setSelected(True) |
| if first_selected_item is None: |
| first_selected_item = item |
| if first_selected_item: |
| self._tool_list_widget.scrollToItem(first_selected_item) |
|
|
| |
| self._tool_list_widget.apply_filter(self._current_search) |
|
|
| def set_sort_order(self, key: str): |
| for i in range(self._sort_combo.count()): |
| if self._sort_combo.itemData(i) == key: |
| if self._sort_combo.currentIndex() != i: |
| self._sort_combo.setCurrentIndex(i) |
| break |
| else: |
| return |
| self._sort_key = key |
| self._sort_assets() |
| self._update_list() |
|
|
| def _on_sort_changed(self): |
| """Handles sort order change from the dropdown.""" |
| key = self._sort_combo.itemData(self._sort_combo.currentIndex()) |
| self.set_sort_order(key) |
|
|
| def _on_item_double_clicked(self, item): |
| """Handles double-click on a list item to request editing.""" |
| uri_string = item.data(ToolBitUriRole) |
| if not uri_string: |
| return |
| try: |
| toolbit = self._asset_manager.get(AssetUri(uri_string)) |
| if toolbit: |
| self.itemDoubleClicked.emit(toolbit) |
| except FileNotFoundError: |
| |
| QMessageBox.warning( |
| self, |
| FreeCAD.Qt.translate("CAM", "Missing Toolbit"), |
| FreeCAD.Qt.translate( |
| "CAM", |
| "This toolbit is missing from your local store. It may be a placeholder for a toolbit that was not found during library import.", |
| ), |
| ) |
| except Exception as e: |
| |
| QMessageBox.critical( |
| self, |
| FreeCAD.Qt.translate("CAM", "Error"), |
| FreeCAD.Qt.translate("CAM", f"Failed to load toolbit: {e}"), |
| ) |
|
|
| def _on_item_selection_changed(self): |
| """Emits toolSelected signal and tracks selected URIs.""" |
| selected_uris = self._tool_list_widget.get_selected_toolbit_uris() |
| self._selected_uris = selected_uris |
| if not selected_uris: |
| return |
| self.toolSelected.emit(selected_uris[0]) |
|
|
| def _get_first_selected_bit(self) -> Optional[ToolBit]: |
| uris = self.get_selected_bit_uris() |
| if not uris: |
| return None |
| uri_string = uris[0] |
| return cast(ToolBit, self._asset_manager.get(AssetUri(uri_string))) |
|
|
| def _on_edit_requested(self): |
| """Opens the ToolBitEditor for the selected toolbit.""" |
| toolbit = self._get_first_selected_bit() |
| if not toolbit: |
| return |
|
|
| |
| editor = ToolBitEditor(toolbit) |
| result = editor.show() |
| if result != QDialog.Accepted: |
| return |
|
|
| |
| self._asset_manager.add(toolbit) |
| Path.Log.info(f"Toolbit {toolbit.get_id()} saved.") |
| self.refresh() |
| self._update_list() |
|
|
| def _add_shortcuts(self): |
| """Adds keyboard shortcuts for common actions.""" |
| copy_action = QAction(self) |
| copy_action.setShortcut(QKeySequence.Copy) |
| copy_action.triggered.connect(self._on_copy_requested) |
| self.addAction(copy_action) |
|
|
| delete_action = QAction(self) |
| delete_action.setShortcut(QKeySequence("Shift+Delete")) |
| delete_action.triggered.connect(self._on_delete_requested) |
| self.addAction(delete_action) |
|
|
| edit_action = QAction(self) |
| edit_action.setShortcut(QKeySequence("F2")) |
| edit_action.triggered.connect(self._on_edit_requested) |
| self.addAction(edit_action) |
|
|
| def _create_base_context_menu(self): |
| """Creates the base context menu with Edit, Copy, and Delete actions.""" |
| selected_items = self._tool_list_widget.selectedItems() |
| has_selection = bool(selected_items) |
|
|
| context_menu = QMenu(self) |
|
|
| edit_action = context_menu.addAction("Edit", self._on_edit_requested) |
| edit_action.setEnabled(has_selection) |
| context_menu.addSeparator() |
| action = context_menu.addAction("Copy", self._on_copy_requested) |
| action.setShortcut(QKeySequence.Copy) |
| action = context_menu.addAction("Delete from disk", self._on_delete_requested) |
| action.setShortcut(QKeySequence("Shift+Delete")) |
|
|
| return context_menu |
|
|
| def _show_context_menu(self, position): |
| """Shows the context menu at the given position.""" |
| context_menu = self._create_base_context_menu() |
| context_menu.exec_(self._tool_list_widget.mapToGlobal(position)) |
|
|
| def _to_clipboard( |
| self, |
| uris: List[str], |
| mode: str = "copy", |
| extra_data: Optional[dict] = None, |
| ): |
| """Copies selected toolbits to the clipboard as YAML.""" |
| if not uris: |
| return |
|
|
| selected_bits = [cast(ToolBit, self._asset_manager.get(AssetUri(uri))) for uri in uris] |
| selected_bits = [bit for bit in selected_bits if bit] |
| if not selected_bits: |
| return |
|
|
| |
| serialized_toolbits_data = [] |
| for toolbit in selected_bits: |
| yaml_data = YamlToolBitSerializer.serialize(toolbit) |
| serialized_toolbits_data.append(yaml_data.decode("utf-8")) |
|
|
| |
| clipboard_data_dict = { |
| "operation": mode, |
| "toolbits": serialized_toolbits_data, |
| } |
|
|
| |
| if extra_data: |
| clipboard_data_dict.update(extra_data) |
|
|
| |
| clipboard_content_yaml = yaml.dump(clipboard_data_dict, default_flow_style=False) |
|
|
| |
| mime_data = QMimeData() |
| mime_type = "application/x-freecad-toolbit-list-yaml" |
| mime_data.setData(mime_type, clipboard_content_yaml.encode("utf-8")) |
|
|
| |
| toolbit_list = [yaml.safe_load(d) for d in serialized_toolbits_data] |
| mime_data.setText(yaml.dump(toolbit_list, default_flow_style=False)) |
|
|
| clipboard = QApplication.clipboard() |
| clipboard.setMimeData(mime_data) |
|
|
| def _on_copy_requested(self): |
| """Copies selected toolbits to the clipboard as YAML.""" |
| uris = self.get_selected_bit_uris() |
| self._to_clipboard(uris, mode="copy") |
|
|
| def _on_delete_requested(self): |
| """Deletes selected toolbits and removes them from all libraries.""" |
| Path.Log.debug("ToolBitBrowserWidget._on_delete_requested: Function entered.") |
| uris = self.get_selected_bit_uris() |
| if not uris: |
| Path.Log.debug("_on_delete_requested: No URIs selected. Returning.") |
| return |
|
|
| |
| reply = QMessageBox.question( |
| self, |
| FreeCAD.Qt.translate("CAM", "Confirm Deletion"), |
| FreeCAD.Qt.translate( |
| "CAM", |
| "Are you sure you want to delete the selected toolbit(s)? This is not reversible. The toolbits will be removed from disk and from all libraries that contain them.", |
| ), |
| QMessageBox.Yes | QMessageBox.No, |
| QMessageBox.No, |
| ) |
|
|
| if reply != QMessageBox.Yes: |
| return |
|
|
| deleted_count = 0 |
| libraries_modified = [] |
|
|
| for uri_string in uris: |
| try: |
| toolbit_uri = AssetUri(uri_string) |
|
|
| |
| libraries_to_update = self._find_libraries_containing_toolbit(toolbit_uri) |
| for library in libraries_to_update: |
| library.remove_bit_by_uri(uri_string) |
| if library not in libraries_modified: |
| libraries_modified.append(library) |
| Path.Log.info( |
| f"Removed toolbit {toolbit_uri.asset_id} from library {library.label}" |
| ) |
|
|
| |
| self._asset_manager.delete(toolbit_uri) |
| deleted_count += 1 |
| Path.Log.info(f"Deleted toolbit file {uri_string}") |
|
|
| except Exception as e: |
| Path.Log.error(f"Failed to delete toolbit {uri_string}: {e}") |
|
|
| |
| for library in libraries_modified: |
| try: |
| self._asset_manager.add(library) |
| Path.Log.info(f"Saved updated library {library.label}") |
| except Exception as e: |
| Path.Log.error(f"Failed to save library {library.label}: {e}") |
|
|
| if deleted_count > 0: |
| Path.Log.info( |
| f"Deleted {deleted_count} toolbit(s) and updated {len(libraries_modified)} libraries." |
| ) |
| self.refresh() |
|
|
| def _find_libraries_containing_toolbit(self, toolbit_uri: AssetUri) -> List: |
| """Find all libraries that contain the specified toolbit.""" |
| from ...library.models.library import Library |
|
|
| libraries_with_toolbit = [] |
| try: |
| |
| all_libraries = self._asset_manager.fetch("toolbitlibrary", store="local", depth=1) |
|
|
| for library in all_libraries: |
| if isinstance(library, Library): |
| |
| for toolbit in library: |
| if toolbit.get_uri() == toolbit_uri: |
| libraries_with_toolbit.append(library) |
| break |
|
|
| except Exception as e: |
| Path.Log.error(f"Error finding libraries containing toolbit {toolbit_uri}: {e}") |
|
|
| return libraries_with_toolbit |
|
|
| def get_selected_bit_uris(self) -> List[str]: |
| """ |
| Returns a list of URIs for the currently selected ToolBit items. |
| Delegates to the underlying list widget. |
| """ |
| return self._tool_list_widget.get_selected_toolbit_uris() |
|
|
| def get_selected_bits(self) -> List[ToolBit]: |
| """ |
| Returns a list of selected ToolBit objects. |
| Retrieves the full ToolBit objects using the asset manager. |
| """ |
| selected_bits = [] |
| selected_uris = self.get_selected_bit_uris() |
| for uri_string in selected_uris: |
| toolbit = self._asset_manager.get(AssetUri(uri_string)) |
| if toolbit: |
| selected_bits.append(toolbit) |
| return selected_bits |
|
|