From 4e08702a5a8895c9744227824805217c58d631bd Mon Sep 17 00:00:00 2001 From: Bzero Date: Fri, 25 Oct 2024 22:03:52 +0200 Subject: [PATCH] Add copy, paste and delete functionality to fs_explorer --- tests/test_fs_explorer.py | 33 ++++++++++ typstwriter/actions.py | 2 + typstwriter/fs_explorer.py | 120 +++++++++++++++++++++++++++++++++++-- 3 files changed, 149 insertions(+), 6 deletions(-) diff --git a/tests/test_fs_explorer.py b/tests/test_fs_explorer.py index 8f111d3..a46a4b2 100644 --- a/tests/test_fs_explorer.py +++ b/tests/test_fs_explorer.py @@ -146,6 +146,39 @@ def test_rename(self, tmp_path, qtbot, caplog, monkeypatch): assert from_path.exists() assert to_path.exists() + def test_copy_from_to(self, tmp_path, qtbot, caplog, monkeypatch): + """Make sure copying works.""" + fse = fs_explorer.FSExplorer() + qtbot.addWidget(fse) + monkeypatch.setattr(QtWidgets.QMessageBox, "warning", lambda *args: QtWidgets.QMessageBox.Ok) + + name = "test_file_name" + from_dir = tmp_path / "from" + to_dir = tmp_path / "to" + from_dir.mkdir() + to_dir.mkdir() + from_path = from_dir / name + to_path = to_dir / name + + from_path.touch() + + # Make sure the file can be copied + assert from_path.exists() + assert not to_path.exists() + fse.copy_from_to(str(from_path), str(to_dir)) + assert from_path.exists() + assert to_path.exists() + + # Make sure the file is not overwritten + from_path.touch() + to_path.touch() + assert from_path.exists() + assert to_path.exists() + fse.copy_from_to(str(from_path), str(to_dir)) + assert "already exists" in caplog.text + assert from_path.exists() + assert to_path.exists() + def test_delete(self, tmp_path, qtbot, monkeypatch): """Make sure a folder or file can be deleted.""" fse = fs_explorer.FSExplorer() diff --git a/typstwriter/actions.py b/typstwriter/actions.py index f8c0509..0f3c940 100644 --- a/typstwriter/actions.py +++ b/typstwriter/actions.py @@ -65,11 +65,13 @@ def __init__(self, parent): self.copy = QtWidgets.QAction(self) self.copy.setIcon(QtGui.QIcon.fromTheme(QtGui.QIcon.EditCopy, QtGui.QIcon(util.icon_path("copy.svg")))) self.copy.setShortcut(QtGui.QKeySequence.Copy) + self.copy.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut) self.copy.setText("Copy") self.paste = QtWidgets.QAction(self) self.paste.setIcon(QtGui.QIcon.fromTheme(QtGui.QIcon.EditPaste, QtGui.QIcon(util.icon_path("paste.svg")))) self.paste.setShortcut(QtGui.QKeySequence.Paste) + self.paste.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut) self.paste.setText("Paste") self.search = QtWidgets.QAction(self) diff --git a/typstwriter/fs_explorer.py b/typstwriter/fs_explorer.py index 493e1ff..f5214d3 100644 --- a/typstwriter/fs_explorer.py +++ b/typstwriter/fs_explorer.py @@ -3,6 +3,7 @@ from qtpy import QtWidgets import os +import shutil from typstwriter import util @@ -37,6 +38,8 @@ def __init__(self, parent): self.action_delete = QtWidgets.QAction("Delete", triggered=self.handle_delete) self.setRoot = QtWidgets.QAction("Set as Working Directory", triggered=self.handle_set_root) self.action_copy_path = QtWidgets.QAction("Copy path", triggered=self.handle_copy_path) + self.action_copy = QtWidgets.QAction("Copy", triggered=self.handle_copy) + self.action_paste = QtWidgets.QAction("Paste", triggered=self.handle_paste) self.action_main_file = QtWidgets.QAction("Set as main file", triggered=self.handle_set_as_main_file) def handle_open(self): @@ -71,6 +74,14 @@ def handle_copy_path(self): """Trigger copying path to the clipboard.""" QtGui.QGuiApplication.clipboard().setText(self.context_path) + def handle_copy(self): + """Trigger copying file or folder to the clipboard.""" + self.parent().copy_to_clipboard(self.context_path) + + def handle_paste(self): + """Trigger pasting from the clipboard.""" + self.parent().paste_from_clipboard(self.context_path) + def handle_set_as_main_file(self): """Trigger setting the current file as main file.""" state.main_file.Value = self.context_path @@ -85,6 +96,7 @@ def __init__(self, parent): self.addAction(self.action_new_file) self.addAction(self.action_new_folder) + self.addAction(self.action_paste) class FileContextMenu(FSContextMenu): @@ -98,6 +110,7 @@ def __init__(self, parent): self.addAction(self.action_open_external) self.addAction(self.action_rename) self.addAction(self.action_delete) + self.addAction(self.action_copy) self.addAction(self.action_copy_path) self.addAction(self.action_main_file) @@ -111,11 +124,25 @@ def __init__(self, parent): self.addAction(self.action_new_file) self.addAction(self.action_new_folder) + self.addAction(self.action_copy) + self.addAction(self.action_copy_path) + self.addAction(self.action_paste) self.addAction(self.action_rename) self.addAction(self.action_delete) self.addAction(self.setRoot) +class SelectionContextMenu(FSContextMenu): + """Context menu when clicking on a selection.""" + + def __init__(self, parent): + """Assemble Menu.""" + FSContextMenu.__init__(self, parent) + + self.addAction(self.action_delete) + self.addAction(self.action_copy) + + class FSExplorer(QtWidgets.QWidget): """A filesystem explorer widget.""" @@ -152,6 +179,7 @@ def __init__(self): self.filesystem_model.setIconProvider(util.FileIconProvider()) self.tree_view.setModel(self.filesystem_model) self.tree_view.setRootIsDecorated(True) + self.tree_view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) self.tree_view.hideColumn(1) self.tree_view.hideColumn(2) self.tree_view.header().setSectionsMovable(False) @@ -178,6 +206,7 @@ def __init__(self): self.NoItemContextMenu = NoItemContextMenu(self) self.FileContextMenu = FileContextMenu(self) self.FolderContextMenu = FolderContextMenu(self) + self.SelectionContextMenu = SelectionContextMenu(self) # Set initial state root_dir = os.path.expanduser(config.get("General", "working_directory")) @@ -243,12 +272,19 @@ def rightclicked(self, event): index = self.tree_view.indexAt(event) if not index.isValid(): self.open_no_item_menu() + return else: + selected_indices = self.tree_view.selectionModel().selectedRows() + if index in selected_indices and len(selected_indices) > 1: + self.open_selection_menu(index) + return path = self.filesystem_model.filePath(index) if os.path.isfile(path): self.open_file_menu(index) + return if os.path.isdir(path): self.open_folder_menu(index) + return def open_no_item_menu(self): """Open context menu when clicking on no item.""" @@ -268,6 +304,31 @@ def open_file_menu(self, index): self.FileContextMenu.context_path = path self.FileContextMenu.popup(QtGui.QCursor.pos()) + def open_selection_menu(self, index): + """Open context menu when clicking on a selection.""" + paths = self.selected_paths() + self.SelectionContextMenu.context_path = paths + self.SelectionContextMenu.popup(QtGui.QCursor.pos()) + + def keyPressEvent(self, e): # noqa: N802 This is an overriding function + """Intercept keyPressEvent.""" + if e.key() == QtCore.Qt.Key_Delete and e.modifiers() == QtCore.Qt.NoModifier: + self.delete(self.selected_paths()) + if e.key() == QtCore.Qt.Key_C and e.modifiers() == QtCore.Qt.ControlModifier: + self.copy_to_clipboard(self.selected_paths()) + if e.key() == QtCore.Qt.Key_V and e.modifiers() == QtCore.Qt.ControlModifier: + self.paste_from_clipboard(self.filesystem_model.rootPath()) + super().keyPressEvent(e) + + def selected_paths(self): + """Return the paths of the currently selected items.""" + selected_paths = [] + for index in self.tree_view.selectionModel().selectedRows(): + if index.isValid(): + path = self.filesystem_model.filePath(index) + selected_paths.append(path) + return selected_paths + # TODO: Possibly move the functions below to an own class or module as they dont directly relate to the FSExplorer def new_file_in_dir(self, head_path): """Prompts the user to ender a filename and creates that file in head_path.""" @@ -317,10 +378,57 @@ def rename(self, path_from, path_to, overwrite=False): logger.warning("{!r} already exists. Will not overwrite.", path_to) QtWidgets.QMessageBox.warning(self, "Typstwriter", f"'{path_to}' already exists.\nWill not overwrite.") - def delete(self, path): + def copy_to_clipboard(self, paths): + """Copy a list of files or foler to the clipboard.""" + if not isinstance(paths, list): + paths = [paths] + + urls = [QtCore.QUrl.fromLocalFile(path) for path in paths if os.path.exists(path)] + + if urls: + mime_data = QtCore.QMimeData() + mime_data.setUrls(urls) + QtGui.QGuiApplication.clipboard().setMimeData(mime_data) + + def paste_from_clipboard(self, path): + """Paste a file or folder from cliboard into path.""" + mime_data = QtGui.QGuiApplication.clipboard().mimeData() + + if mime_data.hasUrls(): + for uri in mime_data.data("text/uri-list").data().decode().split(): + fs = QtCore.QUrl(uri).toLocalFile() + if os.path.exists(fs): + self.copy_from_to(fs, path) + + def copy_from_to(self, path_from, path_to, overwrite=False): + """Copy a file or folder from path_from into the directory path_to.""" + if not os.path.isdir(path_to): + return + + if not os.path.exists(path_from): + return + + head, tail = os.path.split(path_from) + path_to = os.path.join(path_to, tail) + + if not overwrite and os.path.exists(path_to): + logger.warning("{!r} already exists. Will not overwrite.", path_to) + QtWidgets.QMessageBox.warning(self, "Typstwriter", f"'{path_to}' already exists.\nWill not overwrite.") + return + + if os.path.isdir(path_from): + shutil.copytree(path_from, path_to, dirs_exist_ok=overwrite) + else: + shutil.copy2(path_from, path_to) + + def delete(self, paths): """Delete a folder or file.""" - msg = QtWidgets.QMessageBox.question(self, "Typstwriter", f"Should '{path}' be deleted?") - if msg == QtWidgets.QMessageBox.StandardButton.Yes: - deletion_succeeded = QtCore.QFile.moveToTrash(path) - if not deletion_succeeded: - logger.warning("Could not move {!r} to trash.", path) + if not isinstance(paths, list): + paths = [paths] + + for path in paths: + msg = QtWidgets.QMessageBox.question(self, "Typstwriter", f"Should '{path}' be deleted?") + if msg == QtWidgets.QMessageBox.StandardButton.Yes: + deletion_succeeded = QtCore.QFile.moveToTrash(path) + if not deletion_succeeded: + logger.warning("Could not move {!r} to trash.", path)