# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * Copyright (c) 2019 sliptonic * # * 2020 Schildkroet * # * 2025 Samuel Abels * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * # * as published by the Free Software Foundation; either version 2 of * # * the License, or (at your option) any later version. * # * for detail see the LICENCE text file. * # * * # * This program is distributed in the hope that it will be useful, * # * but WITHOUT ANY WARRANTY; without even the implied warranty of * # * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * # * GNU Library General Public License for more details. * # * * # * You should have received a copy of the GNU Library General Public * # * License along with this program; if not, write to the Free Software * # * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * # * USA * # * * # *************************************************************************** import yaml import pathlib import FreeCAD import FreeCADGui import Path from PySide.QtGui import ( QStandardItem, QStandardItemModel, QPixmap, QDialog, QMessageBox, QWidget, ) from PySide.QtCore import Qt, QEvent from typing import List, cast, Tuple, Optional from ...assets import AssetUri from ...assets.ui import AssetOpenDialog, AssetSaveDialog from ...camassets import cam_assets, ensure_assets_initialized from ...shape.ui.shapeselector import ShapeSelector from ...toolbit import ToolBit from ...toolbit.serializers import all_serializers as toolbit_serializers from ...toolbit.ui import ToolBitEditor from ...toolbit.ui.toollist import ToolBitUriListMimeType from ...toolbit.ui.util import natural_sort_key from ...toolbit.util import setToolBitSchema from ..serializers import all_serializers as library_serializers from ..models import Library from .browser import LibraryBrowserWidget from .properties import LibraryPropertyDialog if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) else: Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) _LibraryRole = Qt.UserRole + 1 translate = FreeCAD.Qt.translate class LibraryEditor(QWidget): """LibraryEditor is the controller for displaying/selecting/creating/editing a collection of ToolBits.""" def __init__(self, parent=None): super().__init__(parent=parent) Path.Log.track() ensure_assets_initialized(cam_assets) self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitLibraryEdit.ui") self.form.installEventFilter(self) # to forward keypress events self._base_title = self.form.windowTitle() # Create the library list. self.listModel = QStandardItemModel() self.form.TableList.setModel(self.listModel) self.form.TableList.clicked.connect(self._on_library_selected) # Enable drop support for the library list self.form.TableList.viewport().installEventFilter(self) # Also on viewport # Create the LibraryBrowserWidget self.browser = LibraryBrowserWidget( asset_manager=cam_assets, parent=self, ) self.browser.setDragEnabled(True) self.form.verticalLayout_2.layout().replaceWidget(self.form.toolTable, self.browser) self.form.toolTable.hide() # Connect signals. self.browser.itemDoubleClicked.connect(self.browser._on_edit_requested) self.form.addLibraryButton.clicked.connect(self._on_add_library_requested) self.form.removeLibraryButton.clicked.connect(self._on_remove_library_requested) self.form.renameLibraryButton.clicked.connect(self._on_rename_library_requested) self.form.importLibraryButton.clicked.connect(self._on_import_library_requested) self.form.exportLibraryButton.clicked.connect(self._on_export_library_requested) self.form.addToolBitButton.clicked.connect(self._on_add_toolbit_requested) self.form.importToolBitButton.clicked.connect(self._on_import_toolbit_requested) self.form.exportToolBitButton.clicked.connect(self._on_export_toolbit_requested) # Populate the UI. self._refresh_library_list() self._select_last_library() self._update_button_states() def _highlight_row(self, index): """Highlights the row at the given index using the selection model.""" if not index.isValid(): return self.form.TableList.setCurrentIndex(index) def _clear_highlight(self): """Clears the highlighting from the previously highlighted row.""" self.form.TableList.selectionModel().clear() def eventFilter(self, obj, event): if event.type() == QEvent.KeyPress and self.form.TableList.hasFocus(): if event.key() == Qt.Key_F2: Path.Log.debug("F2 pressed on library list.") self._on_rename_library_requested() return True elif event.key() == Qt.Key_Delete: Path.Log.debug("Del pressed on library list.") self._on_remove_library_requested() return True if obj == self.form.TableList.viewport(): if event.type() == QEvent.DragEnter or event.type() == QEvent.DragMove: return self._handle_drag_enter(event) elif event.type() == QEvent.DragLeave: self._handle_drag_leave(event) return True elif event.type() == QEvent.Drop: return self._handle_drop(event) return super().eventFilter(obj, event) def _handle_drag_enter(self, event): """Handle drag enter and move events for the library list.""" mime_data = event.mimeData() Path.Log.debug(f"_handle_drag_enter: MIME formats: {mime_data.formats()}") if not mime_data.hasFormat(ToolBitUriListMimeType): Path.Log.debug("_handle_drag_enter: Invalid MIME type, ignoring") return True # Get the row being hovered. pos = event.pos() event.acceptProposedAction() index = self.form.TableList.indexAt(pos) if not index.isValid(): self._clear_highlight() return True # Prevent drop into "All Toolbits" item = self.listModel.itemFromIndex(index) if not item or item.data(_LibraryRole) == "all_tools": self._clear_highlight() return True self._highlight_row(index) return True def _handle_drag_leave(self, event): """Handle drag leave event for the library list.""" self._clear_highlight() def _handle_drop(self, event): """Handle drop events to move or copy toolbits to the target library.""" mime_data = event.mimeData() if not (mime_data.hasFormat(ToolBitUriListMimeType)): event.ignore() return True self._clear_highlight() pos = event.pos() index = self.form.TableList.indexAt(pos) if not index.isValid(): event.ignore() return True item = self.listModel.itemFromIndex(index) if not item or item.data(_LibraryRole) == "all_tools": event.ignore() return True target_library_id = item.data(_LibraryRole) target_library_uri = f"toolbitlibrary://{target_library_id}" target_library = cast(Library, cam_assets.get(target_library_uri, depth=1)) try: clipboard_content_yaml = mime_data.data(ToolBitUriListMimeType).data().decode("utf-8") clipboard_data_dict = yaml.safe_load(clipboard_content_yaml) if not isinstance(clipboard_data_dict, dict) or "toolbits" not in clipboard_data_dict: event.ignore() return True uris = clipboard_data_dict["toolbits"] new_uris = set() # Get the current library from the browser current_library = self.browser.get_current_library() for uri in uris: try: toolbit = cast(ToolBit, cam_assets.get(AssetUri(uri), depth=0)) if toolbit: added_toolbit = target_library.add_bit(toolbit) if added_toolbit: new_uris.add(str(toolbit.get_uri())) # Remove the toolbit from the current library if it exists and # it's not "all_tools" if current_library and current_library.get_id() != "all_tools": current_library.remove_bit(toolbit) except Exception as e: Path.Log.error(f"Failed to load toolbit from URI {uri}: {e}") continue if new_uris: cam_assets.add(target_library) # Save the current library if it was modified if current_library and current_library.get_id() != "all_tools": cam_assets.add(current_library) self.browser.refresh() self.browser.select_by_uri(list(new_uris)) self._update_button_states() event.acceptProposedAction() except Exception as e: Path.Log.error(f"Failed to process drop event: {e}") event.ignore() return True def get_selected_library_id(self) -> Optional[str]: index = self.form.TableList.currentIndex() if not index.isValid(): return None item = self.listModel.itemFromIndex(index) if not item: return None return item.data(_LibraryRole) def get_selected_library(self, depth=1) -> Optional[Library]: library_id = self.get_selected_library_id() if not library_id: return None uri = f"toolbitlibrary://{library_id}" return cast(Library, cam_assets.get(uri, depth=depth)) def select_library_by_uri(self, uri: AssetUri): # Find it in the list. index = 0 for i in range(self.listModel.rowCount()): item = self.listModel.item(i) if item and item.data(_LibraryRole) == uri.asset_id: index = i break else: return # Select it. if index <= self.listModel.rowCount(): item = self.listModel.item(index) if item: # Should always be true, but... self.form.TableList.setCurrentIndex(self.listModel.index(index, 0)) self._on_library_selected() def _select_last_library(self): # Find the last used library. last_used_lib_identifier = Path.Preferences.getLastToolLibrary() if last_used_lib_identifier: uri = Library.resolve_name(last_used_lib_identifier) self.select_library_by_uri(uri) def open(self): Path.Log.track() return self.form.exec_() def _refresh_library_list(self): """Clears and repopulates the self.listModel with available libraries.""" Path.Log.track() self.listModel.clear() # Add "All Toolbits" item all_tools_item = QStandardItem(translate("CAM", "All Toolbits")) all_tools_item.setData("all_tools", _LibraryRole) # all_tools_item.setIcon(QPixmap(":/icons/CAM_ToolTable.svg")) # Make the "All Toolbits" item bold and italic font = all_tools_item.font() font.setBold(True) font.setItalic(True) all_tools_item.setFont(font) self.listModel.appendRow(all_tools_item) # Use AssetManager to fetch library assets (depth=0 for shallow fetch) try: # Fetch library assets themselves, not their deep dependencies (toolbits). # depth=0 means "fetch this asset, but not its dependencies" # The 'fetch' method returns actual Asset objects. libraries = cast(List[Library], cam_assets.fetch(asset_type="toolbitlibrary", depth=0)) except Exception as e: Path.Log.error(f"Failed to fetch toolbit libraries: {e}") return # Sort by label for consistent ordering, falling back to asset_id if label is missing for library in sorted( libraries, key=lambda library: natural_sort_key(library.label or library.get_id()), ): lib_uri_str = str(library.get_uri()) libItem = QStandardItem(library.label or library.get_id()) libItem.setToolTip(f"ID: {library.get_id()}\nURI: {lib_uri_str}") libItem.setData(library.get_id(), _LibraryRole) # Store the library ID libItem.setIcon(QPixmap(":/icons/CAM_ToolTable.svg")) self.listModel.appendRow(libItem) Path.Log.debug("model rows: {}".format(self.listModel.rowCount())) self.listModel.setHorizontalHeaderLabels(["Library"]) def _on_library_selected(self): """Sets the current library in the browser when a library is selected.""" Path.Log.debug("_on_library_selected: Called.") index = self.form.TableList.currentIndex() item = self.listModel.itemFromIndex(index) if not item: return if item.data(_LibraryRole) == "all_tools": selected_library = None else: selected_library = self.get_selected_library() self.browser.set_current_library(selected_library) self._update_window_title() self._update_button_states() def _update_window_title(self): """Updates the window title with the current library name.""" current_library = self.browser.get_current_library() if current_library: title = f"{self._base_title} - {current_library.label}" else: title = self._base_title self.form.setWindowTitle(title) def _update_button_states(self): """Updates the enabled state of library management buttons.""" library_selected = self.browser.get_current_library() is not None self.form.addLibraryButton.setEnabled(True) self.form.removeLibraryButton.setEnabled(library_selected) self.form.renameLibraryButton.setEnabled(library_selected) self.form.exportLibraryButton.setEnabled(library_selected) self.form.importLibraryButton.setEnabled(True) self.form.addToolBitButton.setEnabled( True ) # Always enabled - can create standalone toolbits # TODO: self.form.exportToolBitButton.setEnabled(toolbit_selected) def _save_library(self): """Internal method to save the current tool library asset""" Path.Log.track() library = self.browser.get_current_library() if not library: return # Save the modified library asset. try: cam_assets.add(library) Path.Log.debug(f"Library {library.get_uri()} saved") except Exception as e: Path.Log.error(f"Failed to save library {library.get_uri()}: {e}") QMessageBox.critical( self.form, translate("CAM_ToolBit", "Error Saving Library"), str(e), ) raise def _on_add_library_requested(self): Path.Log.debug("_on_add_library_requested: Called.") new_library = Library(FreeCAD.Qt.translate("CAM", "New Library")) dialog = LibraryPropertyDialog(new_library, new=True, parent=self) if dialog.exec_() != QDialog.Accepted: return uri = cam_assets.add(new_library) Path.Log.debug(f"_on_add_library_requested: New library URI = {uri}") self._refresh_library_list() self.select_library_by_uri(uri) self._update_button_states() def _on_remove_library_requested(self): """Handles request to remove the selected library.""" Path.Log.debug("_on_remove_library_requested: Called.") current_library = self.browser.get_current_library() if not current_library: return reply = QMessageBox.question( self, FreeCAD.Qt.translate("CAM", "Confirm Library Removal"), FreeCAD.Qt.translate( "CAM", "Are you sure you want to remove the library '{0}'?\n" "This will not delete the toolbits contained within it.", ).format(current_library.label), QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if reply != QMessageBox.Yes: return try: library_uri = current_library.get_uri() cam_assets.delete(library_uri) Path.Log.info(f"Library {current_library.label} deleted.") self._refresh_library_list() self.browser.refresh() self._update_button_states() except FileNotFoundError as e: Path.Log.error(f"Failed to delete library {current_library.label}: {e}") QMessageBox.critical( self, FreeCAD.Qt.translate("CAM", "Error"), FreeCAD.Qt.translate("CAM", "Failed to delete library '{0}': {1}").format( current_library.label, str(e) ), ) def _on_rename_library_requested(self): """Handles request to rename the selected library.""" Path.Log.debug("_on_rename_library_requested: Called.") current_library = self.browser.get_current_library() if not current_library: return dialog = LibraryPropertyDialog(current_library, new=False, parent=self) if dialog.exec_() != QDialog.Accepted: return cam_assets.add(current_library) self._refresh_library_list() self._update_button_states() def _on_import_library_requested(self): """Handles request to import a library.""" Path.Log.debug("_on_import_library_requested: Called.") dialog = AssetOpenDialog( cam_assets, asset_class=Library, serializers=library_serializers, parent=self ) response = dialog.exec_() if not response: return file_path, library = cast(Tuple[pathlib.Path, Library], response) try: cam_assets.add(library) self._refresh_library_list() self._update_button_states() except Exception as e: Path.Log.error(f"Failed to import library: {file_path} {e}") QMessageBox.critical( self, FreeCAD.Qt.translate("CAM", "Error"), FreeCAD.Qt.translate("CAM", f"Failed to import library: {file_path} {e}"), ) def _on_export_library_requested(self): """Handles request to export the selected library.""" Path.Log.debug("_on_export_library_requested: Called.") current_library = self.browser.get_current_library() if not current_library: return dialog = AssetSaveDialog(asset_class=Library, serializers=library_serializers, parent=self) dialog.exec_(current_library) self._update_button_states() def _on_add_toolbit_requested(self): """Handles request to add a new toolbit to the current library or create standalone.""" Path.Log.debug("_on_add_toolbit_requested: Called.") current_library = self.browser.get_current_library() # Select the shape for the new toolbit selector = ShapeSelector() shape = selector.show() if shape is None: # user canceled return try: # Find the appropriate ToolBit subclass based on the shape name tool_bit_classes = {b.SHAPE_CLASS.name: b for b in ToolBit.__subclasses__()} tool_bit_class = tool_bit_classes.get(shape.name) if not tool_bit_class: raise ValueError(f"No ToolBit subclass found for shape '{shape.name}'") # Create a new ToolBit instance new_toolbit = tool_bit_class(shape) new_toolbit.label = FreeCAD.Qt.translate("CAM", "New Toolbit") # Save the individual toolbit asset first tool_asset_uri = cam_assets.add(new_toolbit) Path.Log.debug(f"_on_add_toolbit_requested: Saved tool with URI: {tool_asset_uri}") # Add the toolbit to the current library if one is selected if current_library: toolno = current_library.add_bit(new_toolbit) Path.Log.debug( f"_on_add_toolbit_requested: Added toolbit {new_toolbit.get_id()} (URI: {new_toolbit.get_uri()}) " f"to current_library with number {toolno}." ) # Save the library cam_assets.add(current_library) else: Path.Log.debug( f"_on_add_toolbit_requested: Created standalone toolbit {new_toolbit.get_id()} (URI: {new_toolbit.get_uri()})" ) except Exception as e: Path.Log.error(f"Failed to create or add new toolbit: {e}") QMessageBox.critical( self, FreeCAD.Qt.translate("CAM", "Error Creating Toolbit"), str(e), ) raise setToolBitSchema() # Ensure correct schema is set for the new toolbit self.browser.refresh() self.browser.select_by_uri([str(new_toolbit.get_uri())]) self._update_button_states() def _on_import_toolbit_requested(self): """Handles request to import a toolbit.""" Path.Log.debug("_on_import_toolbit_requested: Called.") current_library = self.browser.get_current_library() if not current_library: Path.Log.warning("Cannot import toolbit: No library selected.") QMessageBox.warning( self, FreeCAD.Qt.translate("CAM", "Warning"), FreeCAD.Qt.translate("CAM", "Please select a library first."), ) return dialog = AssetOpenDialog( cam_assets, asset_class=ToolBit, serializers=toolbit_serializers, parent=self ) response = dialog.exec_() if not response: return file_path, toolbit = cast(Tuple[pathlib.Path, ToolBit], response) # Debug logging for imported toolbit Path.Log.info( f"IMPORT TOOLBIT: file_path={file_path}, toolbit.id={toolbit.id}, toolbit.label={toolbit.label}" ) import traceback stack = traceback.format_stack() caller_info = "".join(stack[-3:-1]) Path.Log.info(f"IMPORT TOOLBIT CALLER:\n{caller_info}") # Check if toolbit already exists in asset manager toolbit_uri = toolbit.get_uri() Path.Log.info(f"IMPORT CHECK: toolbit_uri={toolbit_uri}") existing_toolbit = None try: existing_toolbit = cam_assets.get(toolbit_uri, store=["local", "builtin"], depth=0) Path.Log.info( f"IMPORT CHECK: Toolbit {toolbit.id} already exists, using existing reference" ) Path.Log.info( f"IMPORT CHECK: existing_toolbit.id={existing_toolbit.id}, existing_toolbit.label={existing_toolbit.label}" ) except FileNotFoundError: # Toolbit doesn't exist, save it as new Path.Log.info(f"IMPORT CHECK: Toolbit {toolbit.id} is new, saving to disk") new_uri = cam_assets.add(toolbit) Path.Log.info(f"IMPORT CHECK: Toolbit saved with new URI: {new_uri}") existing_toolbit = toolbit # Add the toolbit (existing or new) to the current library Path.Log.info( f"IMPORT ADD: Adding toolbit {existing_toolbit.id} to library {current_library.label}" ) added_toolbit = current_library.add_bit(existing_toolbit) if added_toolbit: Path.Log.info(f"IMPORT ADD: Successfully added toolbit to library") cam_assets.add(current_library) # Save the modified library self.browser.refresh() self.browser.select_by_uri([str(existing_toolbit.get_uri())]) self._update_button_states() else: Path.Log.warning(f"IMPORT ADD: Failed to add toolbit {existing_toolbit.id} to library") Path.Log.warning( f"IMPORT FAILED: Failed to import toolbit from {file_path} to library {current_library.label}." ) QMessageBox.warning( self, FreeCAD.Qt.translate("CAM", "Warning"), FreeCAD.Qt.translate( "CAM", f"Failed to import toolbit from '{file_path}' to library '{current_library.label}'.", ), ) def _on_export_toolbit_requested(self): """Handles request to export the selected toolbit.""" Path.Log.debug("_on_export_toolbit_requested: Called.") selected_toolbits = self.browser.get_selected_bits() if not selected_toolbits: Path.Log.warning("Cannot export toolbit: No toolbit selected.") QMessageBox.warning( self, FreeCAD.Qt.translate("CAM", "Warning"), FreeCAD.Qt.translate("CAM", "Please select a toolbit to export."), ) return if len(selected_toolbits) > 1: Path.Log.warning("Cannot export multiple toolbits: Please select only one.") QMessageBox.warning( self, FreeCAD.Qt.translate("CAM", "Warning"), FreeCAD.Qt.translate("CAM", "Please select only one toolbit to export."), ) return toolbit_to_export = selected_toolbits[0] dialog = AssetSaveDialog(asset_class=ToolBit, serializers=toolbit_serializers, parent=self) dialog.exec_(toolbit_to_export) # This will open the save dialog and handle the export self._update_button_states()