| | |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | """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 |
| |
|