diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index bb2660509..6788cce48 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -5,7 +5,7 @@ on: [ push, pull_request ] jobs: pytest: name: Run tests - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Checkout repo @@ -20,11 +20,12 @@ jobs: - name: Install system dependencies run: | # dont run update, it is slow - # sudo apt-get update + sudo apt-get update sudo apt-get install -y --no-install-recommends \ libxkbcommon-x11-0 \ x11-utils \ libyaml-dev \ + libgl1 \ libegl1 \ libxcb-icccm4 \ libxcb-image0 \ diff --git a/docs/assets/ffmpeg_windows_download.png b/docs/assets/ffmpeg_windows_download.png new file mode 100644 index 000000000..3a079d9e0 Binary files /dev/null and b/docs/assets/ffmpeg_windows_download.png differ diff --git a/docs/help/ffmpeg.md b/docs/help/ffmpeg.md index 4678de3f7..a5e525130 100644 --- a/docs/help/ffmpeg.md +++ b/docs/help/ffmpeg.md @@ -3,19 +3,29 @@ FFmpeg is required for thumbnail previews and playback features on audio and video files. FFmpeg is a free Open Source project dedicated to the handling of multimedia (video, audio, etc) files. For more information, see their official website at [ffmpeg.org](https://www.ffmpeg.org/). ## Installation on Windows + ### Prebuilt Binaries -Pre-built binaries from trusted sources are available on the [FFmpeg website](https://www.ffmpeg.org/download.html#build-windows). To install: + +Pre-built binaries from trusted sources are available on the [FFmpeg website](https://www.ffmpeg.org/download.html). Under "More downloading options" click on the Windows section, then under "Windows EXE Files" select a source to download a build from. Follow any further download instructions from whichever build website you choose. + +![Windows Download Location](../assets/ffmpeg_windows_download.png) + +!!! note + Do NOT download the source code by mistake! + +To Install: 1. Download 7z or zip file and extract it (right click > Extract All) 2. Move extracted contents to a unique folder (i.e; `c:\ffmpeg` or `c:\Program Files\ffmpeg`) -3. Add FFmpeg to your PATH +3. Add FFmpeg to your system PATH - 1. Go to "Edit the system environment variables" - 2. Under "User Variables", select "Path" then edit - 3. Click new and add `\bin` (e.g; `c:\ffmpeg\bin` or `c:\Program Files\ffmpeg\bin`) - 4. Click okay + 1. In Windows, search for or go to "Edit the system environment variables" under the Control Panel + 2. Under "User Variables", select "Path" then edit + 3. Click new and add `\bin` (e.g; `c:\ffmpeg\bin` or `c:\Program Files\ffmpeg\bin`) + 4. Click "Okay" ### Package Managers + FFmpeg is also available from: 1. WinGet (`winget install ffmpeg`) @@ -23,13 +33,15 @@ FFmpeg is also available from: 3. Chocolatey (`choco install ffmpeg-full`) ## Installation on Mac + ### Homebrew -FFmpeg is available via [Homebrew](https://brew.sh/) and can be installed via: -`brew install ffmpeg` +FFmpeg is available under the macOS section of the [FFmpeg website](https://www.ffmpeg.org/download.html) or can be installed via [Homebrew](https://brew.sh/) using `brew install ffmpeg`. ## Installation on Linux + ### Package Managers + FFmpeg may be installed by default on some Linux distributions, but if not, it is available via your distro's package manager of choice: 1. Debian/Ubuntu (`sudo apt install ffmpeg`) @@ -37,4 +49,5 @@ FFmpeg may be installed by default on some Linux distributions, but if not, it i 3. Arch (`sudo pacman -S ffmpeg`) # Help + For additional help, please join the [Discord](https://discord.gg/hRNnVKhF2G) or create an Issue on the [GitHub repository](https://github.com/TagStudioDev/TagStudio) 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 0591d8aa1..59043060f 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -29,6 +29,9 @@ from PySide6.QtGui import ( QAction, QColor, + QDragEnterEvent, + QDragMoveEvent, + QDropEvent, QFontDatabase, QGuiApplication, QIcon, @@ -74,6 +77,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 @@ -226,19 +230,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()) @@ -737,26 +732,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) @@ -768,6 +743,7 @@ def add_new_files_runnable(self, tracker: RefreshDirTracker): maximum=files_count, ) pw.show() + iterator.value.connect( lambda x: ( pw.update_progress(x + 1), @@ -933,6 +909,7 @@ def _init_thumb_grid(self): self.settings.value(SettingItems.SHOW_FILENAMES, defaultValue=True, type=bool) ), ) + layout.addWidget(item_thumb) self.item_thumbs.append(item_thumb) @@ -1262,6 +1239,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() @@ -1271,3 +1249,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 baf0c62c7..495270afe 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, @@ -131,6 +131,9 @@ def __init__( self.show_filename_label: bool = show_filename_label self.label_height = 12 self.label_spacing = 4 + self.setMinimumSize(*thumb_size) + self.setMaximumSize(*thumb_size) + self.setMouseTracking(True) check_size = 24 self.setFixedSize( thumb_size[0], @@ -532,3 +535,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)