From 6e63b0ec9272c1d44cddca5202af1ddc21e7a255 Mon Sep 17 00:00:00 2001 From: Sean Krueger Date: Wed, 11 Dec 2024 16:00:27 -0800 Subject: [PATCH] feat: reimplement drag drop files (Port #153) (#528) * feat: Drag and drop files in and out of TagStudio (#153) * Ability to drop local files in to TagStudio to add to library * Added renaming option to drop import * Improved readability and switched to pathLib * format * Apply suggestions from code review Co-authored-by: yed podtrzitko * Revert Change * Update tagstudio/src/qt/modals/drop_import.py Co-authored-by: yed podtrzitko * Added support for folders * formatting * Progress bars added * Added Ability to Drag out of window * f * format * Ability to drop local files in to TagStudio to add to library * Added renaming option to drop import * Improved readability and switched to pathLib * format * Apply suggestions from code review Co-authored-by: yed podtrzitko * Revert Change * Update tagstudio/src/qt/modals/drop_import.py Co-authored-by: yed podtrzitko * Added support for folders * formatting * Progress bars added * Added Ability to Drag out of window * f * format * format * formatting and refactor * format again * formatting for mypy * convert lambda to func for clarity * mypy fixes * fixed dragout only worked on selected * Refactor typo, Add license * Reformat QMessageBox * Disable drops when no library is open Co-authored-by: Sean Krueger * Rebased onto SQL migration * Updated logic to based on selected grid_idx instead of selected ids * Add newly dragged-in files to SQL database * Fix buttons being inconsistant across platforms * Fix ruff formatting * Rename "override" button to "overwrite" --------- Co-authored-by: yed podtrzitko Co-authored-by: Travis Abendshien Co-authored-by: Sean Krueger * refactor: Update dialog and simplify drop import logic * Handle Qt events for main window in ts_qt.py * Replace magic values with enums * Match import duplicate file dialog to delete missing entry dialog * Remove excessive progess widgets * Add docstrings and logging * refactor: add function for common ProgressWidget use Extracts the create_progress_bar function from drop_import to the ProgressWidget class. Instead of creating a ProgressWidget, FunctionIterator, and CustomRunnable every time a thread-safe progress widget is needed, the from_iterable function in ProgressWidget now handles all of that. --------- Co-authored-by: Creepler13 Co-authored-by: yed podtrzitko Co-authored-by: Travis Abendshien --- tagstudio/src/qt/modals/delete_unlinked.py | 31 +-- tagstudio/src/qt/modals/drop_import.py | 229 ++++++++++++++++++ tagstudio/src/qt/modals/fix_unlinked.py | 32 +-- tagstudio/src/qt/modals/merge_dupe_entries.py | 16 +- tagstudio/src/qt/modals/mirror_entities.py | 30 +-- tagstudio/src/qt/modals/relink_unlinked.py | 31 +-- tagstudio/src/qt/ts_qt.py | 68 +++--- tagstudio/src/qt/widgets/item_thumb.py | 31 ++- tagstudio/src/qt/widgets/progress.py | 30 ++- 9 files changed, 361 insertions(+), 137 deletions(-) create mode 100644 tagstudio/src/qt/modals/drop_import.py diff --git a/tagstudio/src/qt/modals/delete_unlinked.py b/tagstudio/src/qt/modals/delete_unlinked.py index 04b7f8f52..b382d0831 100644 --- a/tagstudio/src/qt/modals/delete_unlinked.py +++ b/tagstudio/src/qt/modals/delete_unlinked.py @@ -4,7 +4,7 @@ import typing -from PySide6.QtCore import Qt, QThreadPool, Signal +from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QStandardItem, QStandardItemModel from PySide6.QtWidgets import ( QHBoxLayout, @@ -15,8 +15,6 @@ QWidget, ) from src.core.utils.missing_files import MissingRegistry -from src.qt.helpers.custom_runnable import CustomRunnable -from src.qt.helpers.function_iterator import FunctionIterator from src.qt.widgets.progress import ProgressWidget # Only import for type checking/autocompletion, will not be imported at runtime. @@ -77,9 +75,14 @@ def refresh_list(self): self.model.clear() for i in self.tracker.missing_files: - self.model.appendRow(QStandardItem(str(i.path))) + item = QStandardItem(str(i.path)) + item.setEditable(False) + self.model.appendRow(item) def delete_entries(self): + def displayed_text(x): + return f"Deleting {x}/{self.tracker.missing_files_count} Unlinked Entries" + pw = ProgressWidget( window_title="Deleting Entries", label_text="", @@ -87,23 +90,5 @@ def delete_entries(self): minimum=0, maximum=self.tracker.missing_files_count, ) - pw.show() - - iterator = FunctionIterator(self.tracker.execute_deletion) - files_count = self.tracker.missing_files_count - iterator.value.connect( - lambda idx: ( - pw.update_progress(idx), - pw.update_label(f"Deleting {idx}/{files_count} Unlinked Entries"), - ) - ) - r = CustomRunnable(iterator.run) - QThreadPool.globalInstance().start(r) - r.done.connect( - lambda: ( - pw.hide(), - pw.deleteLater(), - self.done.emit(), - ) - ) + pw.from_iterable_function(self.tracker.execute_deletion, displayed_text, self.done.emit) diff --git a/tagstudio/src/qt/modals/drop_import.py b/tagstudio/src/qt/modals/drop_import.py new file mode 100644 index 000000000..ec94a3e50 --- /dev/null +++ b/tagstudio/src/qt/modals/drop_import.py @@ -0,0 +1,229 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import enum +import shutil +from pathlib import Path +from typing import TYPE_CHECKING + +import structlog +from PySide6.QtCore import Qt, QUrl +from PySide6.QtGui import QStandardItem, QStandardItemModel +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QListView, + QPushButton, + QVBoxLayout, + QWidget, +) +from src.qt.widgets.progress import ProgressWidget + +if TYPE_CHECKING: + from src.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class DuplicateChoice(enum.StrEnum): + SKIP = "Skipped" + OVERWRITE = "Overwritten" + RENAME = "Renamed" + CANCEL = "Cancelled" + + +class DropImportModal(QWidget): + DUPE_NAME_LIMT: int = 5 + + def __init__(self, driver: "QtDriver"): + super().__init__() + + self.driver: QtDriver = driver + + # Widget ====================== + self.setWindowTitle("Conflicting File(s)") + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setMinimumSize(500, 400) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6, 6, 6, 6) + + self.desc_widget = QLabel() + self.desc_widget.setObjectName("descriptionLabel") + self.desc_widget.setWordWrap(True) + self.desc_widget.setText("The following files have filenames already exist in the library") + self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Duplicate File List ======== + self.list_view = QListView() + self.model = QStandardItemModel() + self.list_view.setModel(self.model) + + # Buttons ==================== + self.button_container = QWidget() + self.button_layout = QHBoxLayout(self.button_container) + self.button_layout.setContentsMargins(6, 6, 6, 6) + self.button_layout.addStretch(1) + + self.skip_button = QPushButton() + self.skip_button.setText("&Skip") + self.skip_button.setDefault(True) + self.skip_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.SKIP)) + self.button_layout.addWidget(self.skip_button) + + self.overwrite_button = QPushButton() + self.overwrite_button.setText("&Overwrite") + self.overwrite_button.clicked.connect( + lambda: self.begin_transfer(DuplicateChoice.OVERWRITE) + ) + self.button_layout.addWidget(self.overwrite_button) + + self.rename_button = QPushButton() + self.rename_button.setText("&Rename") + self.rename_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.RENAME)) + self.button_layout.addWidget(self.rename_button) + + self.cancel_button = QPushButton() + self.cancel_button.setText("&Cancel") + self.cancel_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.CANCEL)) + self.button_layout.addWidget(self.cancel_button) + + # Layout ===================== + self.root_layout.addWidget(self.desc_widget) + self.root_layout.addWidget(self.list_view) + self.root_layout.addWidget(self.button_container) + + def import_urls(self, urls: list[QUrl]): + """Add a colleciton of urls to the library.""" + self.files: list[Path] = [] + self.dirs_in_root: list[Path] = [] + self.duplicate_files: list[Path] = [] + + self.collect_files_to_import(urls) + + if len(self.duplicate_files) > 0: + self.ask_duplicates_choice() + else: + self.begin_transfer() + + def collect_files_to_import(self, urls: list[QUrl]): + """Collect one or more files from drop event urls.""" + for url in urls: + if not url.isLocalFile(): + continue + + file = Path(url.toLocalFile()) + + if file.is_dir(): + for f in file.glob("**/*"): + if f.is_dir(): + continue + + self.files.append(f) + if (self.driver.lib.library_dir / self._get_relative_path(file)).exists(): + self.duplicate_files.append(f) + + self.dirs_in_root.append(file.parent) + else: + self.files.append(file) + + if file.parent not in self.dirs_in_root: + self.dirs_in_root.append( + file.parent + ) # to create relative path of files not in folder + + if (Path(self.driver.lib.library_dir) / file.name).exists(): + self.duplicate_files.append(file) + + def ask_duplicates_choice(self): + """Display the message widgeth with a list of the duplicated files.""" + self.desc_widget.setText( + f"The following {len(self.duplicate_files)} file(s) have filenames already exist in the library." # noqa: E501 + ) + + self.model.clear() + for dupe in self.duplicate_files: + item = QStandardItem(str(self._get_relative_path(dupe))) + item.setEditable(False) + self.model.appendRow(item) + + self.driver.main_window.raise_() + self.show() + + def begin_transfer(self, choice: DuplicateChoice | None = None): + """Display a progress bar and begin copying files into library.""" + self.hide() + self.choice: DuplicateChoice | None = choice + logger.info("duplicated choice selected", choice=self.choice) + if self.choice == DuplicateChoice.CANCEL: + return + + def displayed_text(x): + text = ( + f"Importing New Files...\n{x[0] + 1} File{'s' if x[0] + 1 != 1 else ''} Imported." + ) + if self.choice: + text += f" {x[1]} {self.choice.value}" + + return text + + pw = ProgressWidget( + window_title="Import Files", + label_text="Importing New Files...", + cancel_button_text=None, + minimum=0, + maximum=len(self.files), + ) + + pw.from_iterable_function( + self.copy_files, + displayed_text, + self.driver.add_new_files_callback, + self.deleteLater, + ) + + def copy_files(self): + """Copy files from original location to the library directory.""" + file_count = 0 + duplicated_files_progress = 0 + for file in self.files: + if file.is_dir(): + continue + + dest_file = self._get_relative_path(file) + + if file in self.duplicate_files: + duplicated_files_progress += 1 + if self.choice == DuplicateChoice.SKIP: + file_count += 1 + continue + elif self.choice == DuplicateChoice.RENAME: + new_name = self._get_renamed_duplicate_filename(dest_file) + dest_file = dest_file.with_name(new_name) + + (self.driver.lib.library_dir / dest_file).parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(file, self.driver.lib.library_dir / dest_file) + + file_count += 1 + yield [file_count, duplicated_files_progress] + + def _get_relative_path(self, path: Path) -> Path: + for dir in self.dirs_in_root: + if path.is_relative_to(dir): + return path.relative_to(dir) + return Path(path.name) + + def _get_renamed_duplicate_filename(self, filepath: Path) -> str: + index = 2 + o_filename = filepath.name + + try: + dot_idx = o_filename.index(".") + except ValueError: + dot_idx = len(o_filename) + + while (self.driver.lib.library_dir / filepath).exists(): + filepath = filepath.with_name( + o_filename[:dot_idx] + f" ({index})" + o_filename[dot_idx:] + ) + index += 1 + return filepath.name diff --git a/tagstudio/src/qt/modals/fix_unlinked.py b/tagstudio/src/qt/modals/fix_unlinked.py index 0e51144fd..3c4627dfd 100644 --- a/tagstudio/src/qt/modals/fix_unlinked.py +++ b/tagstudio/src/qt/modals/fix_unlinked.py @@ -5,12 +5,10 @@ import typing -from PySide6.QtCore import Qt, QThreadPool +from PySide6.QtCore import Qt from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget from src.core.library import Library from src.core.utils.missing_files import MissingRegistry -from src.qt.helpers.custom_runnable import CustomRunnable -from src.qt.helpers.function_iterator import FunctionIterator from src.qt.modals.delete_unlinked import DeleteUnlinkedEntriesModal from src.qt.modals.merge_dupe_entries import MergeDuplicateEntries from src.qt.modals.relink_unlinked import RelinkUnlinkedEntries @@ -85,7 +83,7 @@ def __init__(self, library: "Library", driver: "QtDriver"): self.delete_modal = DeleteUnlinkedEntriesModal(self.driver, self.tracker) self.delete_modal.done.connect( lambda: ( - self.set_missing_count(self.tracker.missing_files_count), + self.set_missing_count(), # refresh the grid self.driver.filter_items(), ) @@ -125,23 +123,19 @@ def refresh_missing_files(self): maximum=self.lib.entries_count, ) - pw.show() - - iterator = FunctionIterator(self.tracker.refresh_missing_files) - iterator.value.connect(lambda v: pw.update_progress(v + 1)) - r = CustomRunnable(iterator.run) - QThreadPool.globalInstance().start(r) - r.done.connect( - lambda: ( - pw.hide(), - pw.deleteLater(), - self.set_missing_count(self.tracker.missing_files_count), - self.delete_modal.refresh_list(), - ) + pw.from_iterable_function( + self.tracker.refresh_missing_files, + None, + self.set_missing_count, + self.delete_modal.refresh_list, ) - def set_missing_count(self, count: int): - self.missing_count = count + def set_missing_count(self, count: int | None = None): + if count is not None: + self.missing_count = count + else: + self.missing_count = self.tracker.missing_files_count + if self.missing_count < 0: self.search_button.setDisabled(True) self.delete_button.setDisabled(True) diff --git a/tagstudio/src/qt/modals/merge_dupe_entries.py b/tagstudio/src/qt/modals/merge_dupe_entries.py index cc81949dd..66c94dda7 100644 --- a/tagstudio/src/qt/modals/merge_dupe_entries.py +++ b/tagstudio/src/qt/modals/merge_dupe_entries.py @@ -4,11 +4,9 @@ import typing -from PySide6.QtCore import QObject, QThreadPool, Signal +from PySide6.QtCore import QObject, Signal from src.core.library import Library from src.core.utils.dupe_files import DupeRegistry -from src.qt.helpers.custom_runnable import CustomRunnable -from src.qt.helpers.function_iterator import FunctionIterator from src.qt.widgets.progress import ProgressWidget # Only import for type checking/autocompletion, will not be imported at runtime. @@ -26,20 +24,12 @@ def __init__(self, library: "Library", driver: "QtDriver"): self.tracker = DupeRegistry(library=self.lib) def merge_entries(self): - iterator = FunctionIterator(self.tracker.merge_dupe_entries) - pw = ProgressWidget( window_title="Merging Duplicate Entries", - label_text="", + label_text="Merging Duplicate Entries...", cancel_button_text=None, minimum=0, maximum=self.tracker.groups_count, ) - pw.show() - - iterator.value.connect(lambda x: pw.update_progress(x)) - iterator.value.connect(lambda: (pw.update_label("Merging Duplicate Entries..."))) - r = CustomRunnable(iterator.run) - r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit())) - QThreadPool.globalInstance().start(r) + pw.from_iterable_function(self.tracker.merge_dupe_entries, None, self.done.emit) diff --git a/tagstudio/src/qt/modals/mirror_entities.py b/tagstudio/src/qt/modals/mirror_entities.py index d7178ec4a..6ab199570 100644 --- a/tagstudio/src/qt/modals/mirror_entities.py +++ b/tagstudio/src/qt/modals/mirror_entities.py @@ -6,7 +6,7 @@ import typing from time import sleep -from PySide6.QtCore import Qt, QThreadPool, Signal +from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QStandardItem, QStandardItemModel from PySide6.QtWidgets import ( QHBoxLayout, @@ -17,8 +17,6 @@ QWidget, ) from src.core.utils.dupe_files import DupeRegistry -from src.qt.helpers.custom_runnable import CustomRunnable -from src.qt.helpers.function_iterator import FunctionIterator from src.qt.widgets.progress import ProgressWidget # Only import for type checking/autocompletion, will not be imported at runtime. @@ -83,28 +81,22 @@ def refresh_list(self): self.model.appendRow(QStandardItem(str(i))) def mirror_entries(self): - iterator = FunctionIterator(self.mirror_entries_runnable) + def displayed_text(x): + return f"Mirroring {x + 1}/{self.tracker.groups_count} Entries..." + pw = ProgressWidget( window_title="Mirroring Entries", - label_text=f"Mirroring 1/{self.tracker.groups_count} Entries...", + label_text="", cancel_button_text=None, minimum=0, maximum=self.tracker.groups_count, ) - pw.show() - iterator.value.connect(lambda x: pw.update_progress(x + 1)) - iterator.value.connect( - lambda x: pw.update_label(f"Mirroring {x + 1}/{self.tracker.groups_count} Entries...") - ) - r = CustomRunnable(iterator.run) - QThreadPool.globalInstance().start(r) - r.done.connect( - lambda: ( - pw.hide(), - pw.deleteLater(), - self.driver.preview_panel.update_widgets(), - self.done.emit(), - ) + + pw.from_iterable_function( + self.mirror_entries_runnable, + displayed_text, + self.driver.preview_panel.update_widgets, + self.done.emit, ) def mirror_entries_runnable(self): diff --git a/tagstudio/src/qt/modals/relink_unlinked.py b/tagstudio/src/qt/modals/relink_unlinked.py index e567f8b54..ab2a1d408 100644 --- a/tagstudio/src/qt/modals/relink_unlinked.py +++ b/tagstudio/src/qt/modals/relink_unlinked.py @@ -3,10 +3,8 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from PySide6.QtCore import QObject, QThreadPool, Signal +from PySide6.QtCore import QObject, Signal from src.core.utils.missing_files import MissingRegistry -from src.qt.helpers.custom_runnable import CustomRunnable -from src.qt.helpers.function_iterator import FunctionIterator from src.qt.widgets.progress import ProgressWidget @@ -18,7 +16,10 @@ def __init__(self, tracker: MissingRegistry): self.tracker = tracker def repair_entries(self): - iterator = FunctionIterator(self.tracker.fix_missing_files) + def displayed_text(x): + text = f"Attempting to Relink {x}/{self.tracker.missing_files_count} Entries. \n" + text += f"{self.tracker.files_fixed_count} Successfully Relinked." + return text pw = ProgressWidget( window_title="Relinking Entries", @@ -28,24 +29,4 @@ def repair_entries(self): maximum=self.tracker.missing_files_count, ) - pw.show() - - iterator.value.connect( - lambda idx: ( - pw.update_progress(idx), - pw.update_label( - f"Attempting to Relink {idx}/{self.tracker.missing_files_count} Entries. " - f"{self.tracker.files_fixed_count} Successfully Relinked." - ), - ) - ) - - r = CustomRunnable(iterator.run) - r.done.connect( - lambda: ( - pw.hide(), - pw.deleteLater(), - self.done.emit(), - ) - ) - QThreadPool.globalInstance().start(r) + pw.from_iterable_function(self.tracker.fix_missing_files, displayed_text, self.done.emit) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 3d378c51e..5c157376a 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -37,6 +37,9 @@ from PySide6.QtGui import ( QAction, QColor, + QDragEnterEvent, + QDragMoveEvent, + QDropEvent, QFontDatabase, QGuiApplication, QIcon, @@ -82,6 +85,7 @@ from src.qt.helpers.function_iterator import FunctionIterator from src.qt.main_window import Ui_MainWindow from src.qt.modals.build_tag import BuildTagPanel +from src.qt.modals.drop_import import DropImportModal from src.qt.modals.file_extension import FileExtensionModal from src.qt.modals.fix_dupes import FixDupeFilesModal from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal @@ -234,19 +238,10 @@ def start(self) -> None: # self.main_window = loader.load(home_path) self.main_window = Ui_MainWindow(self) self.main_window.setWindowTitle(self.base_title) - self.main_window.mousePressEvent = self.mouse_navigation # type: ignore - # self.main_window.setStyleSheet( - # f'QScrollBar::{{background:red;}}' - # ) - - # # self.main_window.windowFlags() & - # # self.main_window.setWindowFlag(Qt.WindowType.FramelessWindowHint, True) - # self.main_window.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True) - # self.main_window.setWindowFlag(Qt.WindowType.WindowTransparentForInput, False) - # self.main_window.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - - # self.windowFX = WindowEffect() - # self.windowFX.setAcrylicEffect(self.main_window.winId()) + self.main_window.mousePressEvent = self.mouse_navigation # type: ignore[method-assign] + self.main_window.dragEnterEvent = self.drag_enter_event # type: ignore[method-assign] + self.main_window.dragMoveEvent = self.drag_move_event # type: ignore[method-assign] + self.main_window.dropEvent = self.drop_event # type: ignore[method-assign] splash_pixmap = QPixmap(":/images/splash.png") splash_pixmap.setDevicePixelRatio(self.main_window.devicePixelRatio()) @@ -719,26 +714,6 @@ def add_new_files_runnable(self, tracker: RefreshDirTracker): Threaded method. """ - # pb = QProgressDialog( - # f"Running Configured Macros on 1/{len(new_ids)} New Entries", None, 0, len(new_ids) - # ) - # pb.setFixedSize(432, 112) - # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) - # pb.setWindowTitle('Running Macros') - # pb.setWindowModality(Qt.WindowModality.ApplicationModal) - # pb.show() - - # r = CustomRunnable(lambda: self.new_file_macros_runnable(pb, new_ids)) - # r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.filter_items(''))) - # r.run() - # # QThreadPool.globalInstance().start(r) - - # # self.main_window.statusbar.showMessage( - # # f"Running configured Macros on {len(new_ids)} new Entries...", 3 - # # ) - - # # pb.hide() - files_count = tracker.files_count iterator = FunctionIterator(tracker.save_new_files) @@ -750,6 +725,7 @@ def add_new_files_runnable(self, tracker: RefreshDirTracker): maximum=files_count, ) pw.show() + iterator.value.connect( lambda x: ( pw.update_progress(x + 1), @@ -908,6 +884,7 @@ def _init_thumb_grid(self): item_thumb = ItemThumb( None, self.lib, self, (self.thumb_size, self.thumb_size), grid_idx ) + layout.addWidget(item_thumb) self.item_thumbs.append(item_thumb) @@ -1237,6 +1214,7 @@ def init_library(self, path: Path, open_status: LibraryStatus): self.update_libs_list(path) title_text = f"{self.base_title} - Library '{self.lib.library_dir}'" self.main_window.setWindowTitle(title_text) + self.main_window.setAcceptDrops(True) self.selected.clear() self.preview_panel.update_widgets() @@ -1246,3 +1224,27 @@ def init_library(self, path: Path, open_status: LibraryStatus): self.main_window.toggle_landing_page(enabled=False) return open_status + + def drop_event(self, event: QDropEvent): + if event.source() is self: + return + + if not event.mimeData().hasUrls(): + return + + urls = event.mimeData().urls() + logger.info("New items dragged in", urls=urls) + drop_import = DropImportModal(self) + drop_import.import_urls(urls) + + def drag_enter_event(self, event: QDragEnterEvent): + if event.mimeData().hasUrls(): + event.accept() + else: + event.ignore() + + def drag_move_event(self, event: QDragMoveEvent): + if event.mimeData().hasUrls(): + event.accept() + else: + event.ignore() diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 07bf4862f..ab8b44efb 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -10,8 +10,8 @@ import structlog from PIL import Image, ImageQt -from PySide6.QtCore import QEvent, QSize, Qt -from PySide6.QtGui import QAction, QEnterEvent, QPixmap +from PySide6.QtCore import QEvent, QMimeData, QSize, Qt, QUrl +from PySide6.QtGui import QAction, QDrag, QEnterEvent, QPixmap from PySide6.QtWidgets import ( QBoxLayout, QCheckBox, @@ -127,6 +127,7 @@ def __init__( self.thumb_size: tuple[int, int] = thumb_size self.setMinimumSize(*thumb_size) self.setMaximumSize(*thumb_size) + self.setMouseTracking(True) check_size = 24 # +----------+ @@ -480,3 +481,29 @@ def toggle_item_tag( if self.driver.preview_panel.is_open: self.driver.preview_panel.update_widgets() + + def mouseMoveEvent(self, event): # noqa: N802 + if event.buttons() is not Qt.MouseButton.LeftButton: + return + + drag = QDrag(self.driver) + paths = [] + mimedata = QMimeData() + + selected_idxs = self.driver.selected + if self.grid_idx not in selected_idxs: + selected_idxs = [self.grid_idx] + + for grid_idx in selected_idxs: + id = self.driver.item_thumbs[grid_idx].item_id + entry = self.lib.get_entry(id) + if not entry: + continue + + url = QUrl.fromLocalFile(Path(self.lib.library_dir) / entry.path) + paths.append(url) + + mimedata.setUrls(paths) + drag.setMimeData(mimedata) + drag.exec(Qt.DropAction.CopyAction) + logger.info("dragged files to external program", thumbnail_indexs=selected_idxs) diff --git a/tagstudio/src/qt/widgets/progress.py b/tagstudio/src/qt/widgets/progress.py index 8adfb0943..f9247ebd7 100644 --- a/tagstudio/src/qt/widgets/progress.py +++ b/tagstudio/src/qt/widgets/progress.py @@ -2,11 +2,12 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +from typing import Callable, Optional -from typing import Optional - -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QThreadPool from PySide6.QtWidgets import QProgressDialog, QVBoxLayout, QWidget +from src.qt.helpers.custom_runnable import CustomRunnable +from src.qt.helpers.function_iterator import FunctionIterator class ProgressWidget(QWidget): @@ -39,3 +40,26 @@ def update_label(self, text: str): def update_progress(self, value: int): self.pb.setValue(value) + + def _update_progress_unknown_iterable(self, value): + if hasattr(value, "__getitem__"): + self.update_progress(value[0] + 1) + else: + self.update_progress(value + 1) + + def from_iterable_function( + self, function: Callable, update_label_callback: Callable | None, *done_callbacks + ): + """Display the progress widget from a threaded iterable function.""" + iterator = FunctionIterator(function) + iterator.value.connect(lambda x: self._update_progress_unknown_iterable(x)) + if update_label_callback: + iterator.value.connect(lambda x: self.update_label(update_label_callback(x))) + + self.show() + + r = CustomRunnable(lambda: iterator.run()) + r.done.connect( + lambda: (self.hide(), self.deleteLater(), [callback() for callback in done_callbacks]) + ) + QThreadPool.globalInstance().start(r)