From 6e6a91aaf42f38e1ec0f096889a031389f31be00 Mon Sep 17 00:00:00 2001 From: TheBobBobs <84781603+TheBobBobs@users.noreply.github.com> Date: Fri, 12 Sep 2025 05:15:58 +0000 Subject: [PATCH] feat: add infinite scrolling, improve page performance (#1119) * perf: remove unnecessary path conversions * perf: search_library if no limit set don't do extra count * perf: improve responsiveness of ui when rendering thumbnails * feat: infinite scrolling thumbnail grid * fix: update tests * perf: don't run update for thumb grid if rows haven't changed * fix: update blank thumbnails on initial page load * fix: do partial updates when selecting items * fix: remove badges on loading thumbnails * fix: move all extra item_thumbs off screen * load a few hidden rows when scrolling * cleanup * update imports * remove todo * support pagination * allow setting page_size to 0 for no limit * add ui setting for infinite scrolling * undo render thread affinity changes * always load a few off-screen rows --- src/tagstudio/core/library/alchemy/library.py | 29 +- src/tagstudio/qt/global_settings.py | 1 + src/tagstudio/qt/mixed/file_attributes.py | 4 +- src/tagstudio/qt/mixed/item_thumb.py | 69 ++- src/tagstudio/qt/mixed/settings_panel.py | 9 +- src/tagstudio/qt/previews/renderer.py | 6 +- src/tagstudio/qt/thumb_grid_layout.py | 392 ++++++++++++++++++ src/tagstudio/qt/ts_qt.py | 243 +++-------- src/tagstudio/qt/utils/file_opener.py | 18 +- src/tagstudio/qt/views/main_window.py | 10 +- src/tagstudio/qt/views/preview_thumb_view.py | 2 +- src/tagstudio/resources/translations/en.json | 1 + tests/conftest.py | 5 +- tests/qt/test_item_thumb.py | 3 +- tests/qt/test_qt_driver.py | 14 +- 15 files changed, 528 insertions(+), 278 deletions(-) create mode 100644 src/tagstudio/qt/thumb_grid_layout.py diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 98618320..a25231e9 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -994,7 +994,14 @@ class Library: assert self.library_dir with Session(unwrap(self.engine), expire_on_commit=False) as session: - statement = select(Entry.id, func.count().over()) + if page_size: + statement = ( + select(Entry.id, func.count().over()) + .offset(search.page_index * page_size) + .limit(page_size) + ) + else: + statement = select(Entry.id) if search.ast: start_time = time.time() @@ -1017,8 +1024,6 @@ class Library: sort_on = func.sin(Entry.id * search.random_seed) statement = statement.order_by(asc(sort_on) if search.ascending else desc(sort_on)) - if page_size is not None: - statement = statement.limit(page_size).offset(search.page_index * page_size) logger.info( "searching library", @@ -1027,17 +1032,21 @@ class Library: ) start_time = time.time() - rows = session.execute(statement).fetchall() - ids = [] - count = 0 - for row in rows: - id, count = row._tuple() # pyright: ignore[reportPrivateUsage] - ids.append(id) + if page_size: + rows = session.execute(statement).fetchall() + ids = [] + total_count = 0 + for row in rows: + ids.append(row[0]) + total_count = row[1] + else: + ids = list(session.scalars(statement)) + total_count = len(ids) end_time = time.time() logger.info(f"SQL Execution finished ({format_timespan(end_time - start_time)})") res = SearchResult( - total_count=count, + total_count=total_count, ids=ids, ) diff --git a/src/tagstudio/qt/global_settings.py b/src/tagstudio/qt/global_settings.py index 8cb6ab9c..052d815e 100644 --- a/src/tagstudio/qt/global_settings.py +++ b/src/tagstudio/qt/global_settings.py @@ -66,6 +66,7 @@ class GlobalSettings(BaseModel): loop: bool = Field(default=True) show_filenames_in_grid: bool = Field(default=True) page_size: int = Field(default=100) + infinite_scroll: bool = Field(default=True) show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT) tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT) theme: Theme = Field(default=Theme.SYSTEM) diff --git a/src/tagstudio/qt/mixed/file_attributes.py b/src/tagstudio/qt/mixed/file_attributes.py index dd1b0dd3..5704e6e9 100644 --- a/src/tagstudio/qt/mixed/file_attributes.py +++ b/src/tagstudio/qt/mixed/file_attributes.py @@ -151,7 +151,7 @@ class FileAttributes(QWidget): self.layout().setSpacing(0) self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.file_label.setText(f"{Translations['preview.no_selection']}") - self.file_label.set_file_path("") + self.file_label.set_file_path(Path()) self.file_label.setCursor(Qt.CursorShape.ArrowCursor) self.dimensions_label.setText("") self.dimensions_label.setHidden(True) @@ -264,6 +264,6 @@ class FileAttributes(QWidget): self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.file_label.setText(Translations.format("preview.multiple_selection", count=count)) self.file_label.setCursor(Qt.CursorShape.ArrowCursor) - self.file_label.set_file_path("") + self.file_label.set_file_path(Path()) self.dimensions_label.setText("") self.dimensions_label.setHidden(True) diff --git a/src/tagstudio/qt/mixed/item_thumb.py b/src/tagstudio/qt/mixed/item_thumb.py index 8d2b0762..106ea828 100644 --- a/src/tagstudio/qt/mixed/item_thumb.py +++ b/src/tagstudio/qt/mixed/item_thumb.py @@ -3,7 +3,6 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import time from enum import Enum from functools import wraps from pathlib import Path @@ -21,13 +20,13 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.media_types import MediaCategories, MediaType from tagstudio.core.utils.types import unwrap from tagstudio.qt.platform_strings import open_file_str, trash_term -from tagstudio.qt.previews.renderer import ThumbRenderer from tagstudio.qt.translations import Translations from tagstudio.qt.utils.file_opener import FileOpenerHelper from tagstudio.qt.views.layouts.flow_layout import FlowWidget from tagstudio.qt.views.thumb_button import ThumbButton if TYPE_CHECKING: + from tagstudio.core.library.alchemy.models import Entry from tagstudio.qt.ts_qt import QtDriver logger = structlog.get_logger(__name__) @@ -66,8 +65,6 @@ def badge_update_lock(func): class ItemThumb(FlowWidget): """The thumbnail widget for a library item (Entry, Collation, Tag Group, etc.).""" - update_cutoff: float = time.time() - collation_icon_128: Image.Image = Image.open( str(Path(__file__).parents[2] / "resources/qt/images/collation_icon_128.png") ) @@ -119,6 +116,8 @@ class ItemThumb(FlowWidget): self.mode: ItemType | None = mode self.driver = driver self.item_id: int = -1 + self.item_path: Path | None = None + self.rendered_path: Path | None = None self.thumb_size: tuple[int, int] = thumb_size self.show_filename_label: bool = show_filename_label self.label_height = 12 @@ -195,20 +194,11 @@ class ItemThumb(FlowWidget): self.thumb_layout.addWidget(self.bottom_container) self.thumb_button = ThumbButton(self.thumb_container, thumb_size) - self.renderer = ThumbRenderer(driver, self.lib) - self.renderer.updated.connect( - lambda timestamp, image, size, filename: ( - self.update_thumb(image, timestamp), - self.update_size(size, timestamp), - self.set_filename_text(filename, timestamp), - self.set_extension(filename, timestamp), - ) - ) self.thumb_button.setFlat(True) self.thumb_button.setLayout(self.thumb_layout) self.thumb_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.opener = FileOpenerHelper("") + self.opener = FileOpenerHelper(Path()) open_file_action = QAction(Translations["file.open_file"], self) open_file_action.triggered.connect(self.opener.open_file) open_explorer_action = QAction(open_file_str(), self) @@ -219,6 +209,12 @@ class ItemThumb(FlowWidget): self, ) + def _on_delete(): + if self.item_id != -1 and self.item_path is not None: + self.driver.delete_files_callback(self.item_path, self.item_id) + + self.delete_action.triggered.connect(lambda checked=False: _on_delete()) + self.thumb_button.addAction(open_file_action) self.thumb_button.addAction(open_explorer_action) self.thumb_button.addAction(self.delete_action) @@ -338,7 +334,7 @@ class ItemThumb(FlowWidget): self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=True) self.thumb_button.unsetCursor() self.thumb_button.setHidden(True) - elif mode == ItemType.ENTRY and self.mode != ItemType.ENTRY: + elif mode == ItemType.ENTRY: self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False) self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor) self.thumb_button.setHidden(False) @@ -348,7 +344,7 @@ class ItemThumb(FlowWidget): self.count_badge.setStyleSheet(ItemThumb.small_text_style) self.count_badge.setHidden(True) self.ext_badge.setHidden(True) - elif mode == ItemType.COLLATION and self.mode != ItemType.COLLATION: + elif mode == ItemType.COLLATION: self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False) self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor) self.thumb_button.setHidden(False) @@ -357,7 +353,7 @@ class ItemThumb(FlowWidget): self.count_badge.setStyleSheet(ItemThumb.med_text_style) self.count_badge.setHidden(False) self.item_type_badge.setHidden(False) - elif mode == ItemType.TAG_GROUP and self.mode != ItemType.TAG_GROUP: + elif mode == ItemType.TAG_GROUP: self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False) self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor) self.thumb_button.setHidden(False) @@ -366,15 +362,12 @@ class ItemThumb(FlowWidget): self.item_type_badge.setHidden(False) self.mode = mode - def set_extension(self, filename: Path, timestamp: float | None = None) -> None: - if timestamp and timestamp < ItemThumb.update_cutoff: - return - + def set_extension(self, filename: Path) -> None: ext = filename.suffix.lower() if ext and ext.startswith(".") is False: ext = "." + ext media_types: set[MediaType] = MediaCategories.get_types(ext) - if ( + if ext and ( not MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES) or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES) or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_VECTOR_TYPES) @@ -408,11 +401,7 @@ class ItemThumb(FlowWidget): self.ext_badge.setHidden(True) self.count_badge.setHidden(True) - def set_filename_text(self, filename: Path, timestamp: float | None = None): - if timestamp and timestamp < ItemThumb.update_cutoff: - return - - self.set_item_path(filename) + def set_filename_text(self, filename: Path): self.file_label.setText(str(filename.name)) def set_filename_visibility(self, set_visible: bool): @@ -430,48 +419,44 @@ class ItemThumb(FlowWidget): self.setFixedHeight(self.thumb_size[1]) self.show_filename_label = set_visible - def update_thumb(self, image: QPixmap | None = None, timestamp: float | None = None): + def update_thumb(self, image: QPixmap | None = None, file_path: Path | None = None): """Update attributes of a thumbnail element.""" - if timestamp and timestamp < ItemThumb.update_cutoff: - return - self.thumb_button.setIcon(image if image else QPixmap()) + self.rendered_path = file_path - def update_size(self, size: QSize, timestamp: float | None = None): + def update_size(self, size: QSize): """Updates attributes of a thumbnail element. Args: - timestamp (float | None): The UTC timestamp for when this call was - originally dispatched. Used to skip outdated jobs. - size (QSize): The new thumbnail size to set. """ - if timestamp and timestamp < ItemThumb.update_cutoff: - return - self.thumb_size = size.width(), size.height() self.thumb_button.setIconSize(size) self.thumb_button.setMinimumSize(size) self.thumb_button.setMaximumSize(size) + def set_item(self, entry: "Entry"): + self.set_item_id(entry.id) + self.set_item_path(entry.path) + def set_item_id(self, item_id: int): self.item_id = item_id - def set_item_path(self, path: Path | str): + def set_item_path(self, path: Path): """Set the absolute filepath for the item. Used for locating on disk.""" + self.item_path = path self.opener.set_filepath(path) def assign_badge(self, badge_type: BadgeType, value: bool) -> None: mode = self.mode # blank mode to avoid recursive badge updates - self.mode = None badge = self.badges[badge_type] self.badge_active[badge_type] = value if badge.isChecked() != value: + self.mode = None badge.setChecked(value) badge.setHidden(not value) - - self.mode = mode + self.mode = mode def show_check_badges(self, show: bool): if self.mode != ItemType.TAG_GROUP: diff --git a/src/tagstudio/qt/mixed/settings_panel.py b/src/tagstudio/qt/mixed/settings_panel.py index 70424061..5c78a03d 100644 --- a/src/tagstudio/qt/mixed/settings_panel.py +++ b/src/tagstudio/qt/mixed/settings_panel.py @@ -189,12 +189,17 @@ class SettingsPanel(PanelWidget): def on_page_size_changed(): text = self.page_size_line_edit.text() - if not text.isdigit() or int(text) < 1: + if not text.isdigit(): self.page_size_line_edit.setText(str(self.driver.settings.page_size)) self.page_size_line_edit.editingFinished.connect(on_page_size_changed) form_layout.addRow(Translations["settings.page_size"], self.page_size_line_edit) + # Infinite Scrolling + self.infinite_scroll = QCheckBox() + self.infinite_scroll.setChecked(self.driver.settings.infinite_scroll) + form_layout.addRow(Translations["settings.infinite_scroll"], self.infinite_scroll) + # Show Filepath self.filepath_combobox = QComboBox() for k in SettingsPanel.filepath_option_map: @@ -288,6 +293,7 @@ class SettingsPanel(PanelWidget): "autoplay": self.autoplay_checkbox.isChecked(), "show_filenames_in_grid": self.show_filenames_checkbox.isChecked(), "page_size": int(self.page_size_line_edit.text()), + "infinite_scroll": self.infinite_scroll.isChecked(), "show_filepath": self.filepath_combobox.currentData(), "theme": self.theme_combobox.currentData(), "tag_click_action": self.tag_click_action_combobox.currentData(), @@ -307,6 +313,7 @@ class SettingsPanel(PanelWidget): driver.settings.thumb_cache_size = settings["thumb_cache_size"] driver.settings.show_filenames_in_grid = settings["show_filenames_in_grid"] driver.settings.page_size = settings["page_size"] + driver.settings.infinite_scroll = settings["infinite_scroll"] driver.settings.show_filepath = settings["show_filepath"] driver.settings.theme = settings["theme"] driver.settings.tag_click_action = settings["tag_click_action"] diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 2c76908d..f47d534a 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -80,7 +80,6 @@ from tagstudio.qt.previews.vendored.pydub.audio_segment import ( from tagstudio.qt.resource_manager import ResourceManager if TYPE_CHECKING: - from tagstudio.core.library.alchemy.library import Library from tagstudio.qt.ts_qt import QtDriver ImageFile.LOAD_TRUNCATED_IMAGES = True @@ -138,11 +137,10 @@ class ThumbRenderer(QObject): updated_ratio = Signal(float) cached_img_ext: str = ".webp" - def __init__(self, driver: "QtDriver", library: "Library") -> None: + def __init__(self, driver: "QtDriver") -> None: """Initialize the class.""" super().__init__() self.driver = driver - self.lib = library settings_res = self.driver.settings.cached_thumb_resolution self.cached_img_res = ( @@ -1534,7 +1532,7 @@ class ThumbRenderer(QObject): image and Ignore.compiled_patterns and Ignore.compiled_patterns.match( - filepath.relative_to(unwrap(self.lib.library_dir)) + filepath.relative_to(unwrap(self.driver.lib.library_dir)) ) ): image = render_ignored((adj_size, adj_size), pixel_ratio, image) diff --git a/src/tagstudio/qt/thumb_grid_layout.py b/src/tagstudio/qt/thumb_grid_layout.py new file mode 100644 index 00000000..27ad3114 --- /dev/null +++ b/src/tagstudio/qt/thumb_grid_layout.py @@ -0,0 +1,392 @@ +import math +import time +from pathlib import Path +from typing import TYPE_CHECKING, Any, override + +from PySide6.QtCore import QPoint, QRect, QSize +from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import QLayout, QLayoutItem, QScrollArea + +from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE +from tagstudio.core.library.alchemy.enums import ItemType +from tagstudio.core.library.alchemy.models import Entry +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.mixed.item_thumb import BadgeType, ItemThumb +from tagstudio.qt.previews.renderer import ThumbRenderer + +if TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + + +class ThumbGridLayout(QLayout): + def __init__(self, driver: "QtDriver", scroll_area: QScrollArea) -> None: + super().__init__(None) + self.driver: QtDriver = driver + self.scroll_area: QScrollArea = scroll_area + + self._item_thumbs: list[ItemThumb] = [] + self._items: list[QLayoutItem] = [] + # Entry.id -> _entry_ids[index] + self._selected: dict[int, int] = {} + # _entry_ids[index] + self._last_selected: int | None = None + + self._entry_ids: list[int] = [] + self._entries: dict[int, Entry] = {} + # Tag.id -> {Entry.id} + self._tag_entries: dict[int, set[int]] = {} + self._entry_paths: dict[Path, int] = {} + # Entry.id -> _items[index] + self._entry_items: dict[int, int] = {} + + self._render_results: dict[Path, Any] = {} + self._renderer: ThumbRenderer = ThumbRenderer(self.driver) + self._renderer.updated.connect(self._on_rendered) + self._render_cutoff: float = 0.0 + + # _entry_ids[StartIndex:EndIndex] + self._last_page_update: tuple[int, int] | None = None + + def set_entries(self, entry_ids: list[int]): + self.scroll_area.verticalScrollBar().setValue(0) + + self._selected.clear() + self._last_selected = None + + self._entry_ids = entry_ids + self._entries.clear() + self._tag_entries.clear() + self._entry_paths.clear() + + self._entry_items.clear() + self._render_results.clear() + self.driver.thumb_job_queue.queue.clear() + self._render_cutoff = time.time() + + base_size: tuple[int, int] = ( + self.driver.main_window.thumb_size, + self.driver.main_window.thumb_size, + ) + self.driver.thumb_job_queue.put( + ( + self._renderer.render, + ( + self._render_cutoff, + Path(), + base_size, + self.driver.main_window.devicePixelRatio(), + True, + True, + ), + ) + ) + + self._last_page_update = None + + def select_all(self): + self._selected.clear() + for index, id in enumerate(self._entry_ids): + self._selected[id] = index + self._last_selected = index + + for entry_id in self._entry_items: + self._set_selected(entry_id) + + def select_inverse(self): + selected = {} + for index, id in enumerate(self._entry_ids): + if id not in self._selected: + selected[id] = index + self._last_selected = index + + for id in self._selected: + if id not in selected: + self._set_selected(id, value=False) + for id in selected: + self._set_selected(id) + + self._selected = selected + + def select_entry(self, entry_id: int): + if entry_id in self._selected: + index = self._selected.pop(entry_id) + if index == self._last_selected: + self._last_selected = None + self._set_selected(entry_id, value=False) + else: + try: + index = self._entry_ids.index(entry_id) + except ValueError: + index = -1 + + self._selected[entry_id] = index + self._last_selected = index + self._set_selected(entry_id) + + def select_to_entry(self, entry_id: int): + index = self._entry_ids.index(entry_id) + if len(self._selected) == 0: + self.select_entry(entry_id) + return + if self._last_selected is None: + self._last_selected = min(self._selected.values(), key=lambda i: abs(index - i)) + + start = self._last_selected + self._last_selected = index + + if start > index: + index, start = start, index + else: + index += 1 + + for i in range(start, index): + entry_id = self._entry_ids[i] + self._selected[entry_id] = i + self._set_selected(entry_id) + + def clear_selected(self): + for entry_id in self._entry_items: + self._set_selected(entry_id, value=False) + + self._selected.clear() + self._last_selected = None + + def _set_selected(self, entry_id: int, value: bool = True): + if entry_id not in self._entry_items: + return + index = self._entry_items[entry_id] + if index < len(self._item_thumbs): + self._item_thumbs[index].thumb_button.set_selected(value) + + def add_tags(self, entry_ids: list[int], tag_ids: list[int]): + for tag_id in tag_ids: + self._tag_entries.setdefault(tag_id, set()).update(entry_ids) + + def remove_tags(self, entry_ids: list[int], tag_ids: list[int]): + for tag_id in tag_ids: + self._tag_entries.setdefault(tag_id, set()).difference_update(entry_ids) + + def _fetch_entries(self, ids: list[int]): + ids = [id for id in ids if id not in self._entries] + entries = self.driver.lib.get_entries(ids) + for entry in entries: + self._entry_paths[unwrap(self.driver.lib.library_dir) / entry.path] = entry.id + self._entries[entry.id] = entry + + tag_ids = [TAG_ARCHIVED, TAG_FAVORITE] + tag_entries = self.driver.lib.get_tag_entries(tag_ids, ids) + for tag_id, entries in tag_entries.items(): + self._tag_entries.setdefault(tag_id, set()).update(entries) + + def _on_rendered(self, timestamp: float, image: QPixmap, size: QSize, file_path: Path): + if timestamp < self._render_cutoff: + return + self._render_results[file_path] = (timestamp, image, size, file_path) + + # If this is the loading image update all item_thumbs with pending thumbnails + if file_path == Path(): + for path, entry_id in self._entry_paths.items(): + if self._render_results.get(path, None) is None: + self._update_thumb(entry_id, image, size, file_path) + return + + if file_path not in self._entry_paths: + return + entry_id = self._entry_paths[file_path] + self._update_thumb(entry_id, image, size, file_path) + + def _update_thumb(self, entry_id: int, image: QPixmap, size: QSize, file_path: Path): + index = self._entry_items.get(entry_id) + if index is None: + return + item_thumb = self._item_thumbs[index] + item_thumb.update_thumb(image, file_path) + item_thumb.update_size(size) + item_thumb.set_filename_text(file_path) + item_thumb.set_extension(file_path) + + def _item_thumb(self, index: int) -> ItemThumb: + if w := getattr(self.driver, "main_window", None): + base_size = (w.thumb_size, w.thumb_size) + else: + base_size = (128, 128) + while index >= len(self._item_thumbs): + show_filename = self.driver.settings.show_filenames_in_grid + item = ItemThumb( + ItemType.ENTRY, + self.driver.lib, + self.driver, + base_size, + show_filename_label=show_filename, + ) + self._item_thumbs.append(item) + self.addWidget(item) + return self._item_thumbs[index] + + def _size(self, width: int) -> tuple[int, int, int]: + if len(self._entry_ids) == 0: + return 0, 0, 0 + spacing = self.spacing() + + _item_thumb = self._item_thumb(0) + item = self._items[0] + item_size = item.sizeHint() + item_width = item_size.width() + item_height = item_size.height() + + width_offset = item_width + spacing + height_offset = item_height + spacing + + if width_offset == 0: + return 0, 0, height_offset + per_row = int(width / width_offset) + + return per_row, width_offset, height_offset + + @override + def heightForWidth(self, arg__1: int) -> int: + width = arg__1 + per_row, _, height_offset = self._size(width) + if per_row == 0: + return height_offset + return math.ceil(len(self._entry_ids) / per_row) * height_offset + + @override + def setGeometry(self, arg__1: QRect) -> None: + super().setGeometry(arg__1) + rect = arg__1 + if len(self._entry_ids) == 0: + for item in self._item_thumbs: + item.setGeometry(32_000, 32_000, 0, 0) + return + + per_row, width_offset, height_offset = self._size(rect.right()) + view_height = self.parentWidget().parentWidget().height() + offset = self.scroll_area.verticalScrollBar().value() + + visible_rows = math.ceil((view_height + (offset % height_offset)) / height_offset) + offset = int(offset / height_offset) + start = offset * per_row + end = start + (visible_rows * per_row) + + # Load closest off screen rows + start -= per_row * 3 + end += per_row * 3 + + start = max(0, start) + end = min(len(self._entry_ids), end) + if (start, end) == self._last_page_update: + return + self._last_page_update = (start, end) + + # Clear render queue if len > 2 pages + if len(self.driver.thumb_job_queue.queue) > (per_row * visible_rows * 2): + self.driver.thumb_job_queue.queue.clear() + pending = [] + for k, v in self._render_results.items(): + if v is None and k != Path(): + pending.append(k) + for k in pending: + self._render_results.pop(k) + + # Reorder items so previously rendered rows will reuse same item_thumbs + # When scrolling down top row gets moved to end of list + _ = self._item_thumb(end - start - 1) + for item_index, i in enumerate(range(start, end)): + if i >= len(self._entry_ids): + continue + entry_id = self._entry_ids[i] + if entry_id not in self._entry_items: + continue + prev_item_index = self._entry_items[entry_id] + if item_index == prev_item_index: + break + diff = prev_item_index - item_index + self._items = self._items[diff:] + self._items[:diff] + self._item_thumbs = self._item_thumbs[diff:] + self._item_thumbs[:diff] + break + self._entry_items.clear() + + # Move unused item_thumbs off screen + count = end - start + for item in self._item_thumbs[count:]: + item.setGeometry(32_000, 32_000, 0, 0) + + ratio = self.driver.main_window.devicePixelRatio() + base_size: tuple[int, int] = ( + self.driver.main_window.thumb_size, + self.driver.main_window.thumb_size, + ) + timestamp = time.time() + for item_index, i in enumerate(range(start, end)): + entry_id = self._entry_ids[i] + if entry_id not in self._entries: + ids = self._entry_ids[start:end] + self._fetch_entries(ids) + + entry = self._entries[entry_id] + row = int(i / per_row) + self._entry_items[entry_id] = item_index + item_thumb = self._item_thumb(item_index) + item = self._items[item_index] + col = i % per_row + item_x = width_offset * col + item_y = height_offset * row + item_thumb.setGeometry(QRect(QPoint(item_x, item_y), item.sizeHint())) + file_path = unwrap(self.driver.lib.library_dir) / entry.path + item_thumb.set_item(entry) + + if result := self._render_results.get(file_path): + _t, im, s, p = result + if item_thumb.rendered_path == p: + continue + self._update_thumb(entry_id, im, s, p) + else: + if Path() in self._render_results: + _t, im, s, p = self._render_results[Path()] + self._update_thumb(entry_id, im, s, p) + + if file_path not in self._render_results: + self._render_results[file_path] = None + self.driver.thumb_job_queue.put( + ( + self._renderer.render, + (timestamp, file_path, base_size, ratio, False, True), + ) + ) + + # set_selected causes stutters making thumbs after selected not show for a frame + # setting it after positioning thumbs fixes this + for i in range(start, end): + if i >= len(self._entry_ids): + continue + entry_id = self._entry_ids[i] + item_index = self._entry_items[entry_id] + item_thumb = self._item_thumbs[item_index] + item_thumb.thumb_button.set_selected(entry_id in self._selected) + + item_thumb.assign_badge(BadgeType.ARCHIVED, entry_id in self._tag_entries[TAG_ARCHIVED]) + item_thumb.assign_badge(BadgeType.FAVORITE, entry_id in self._tag_entries[TAG_FAVORITE]) + + @override + def addItem(self, arg__1: QLayoutItem) -> None: + self._items.append(arg__1) + + @override + def count(self) -> int: + return len(self._entries) + + @override + def hasHeightForWidth(self) -> bool: + return True + + @override + def itemAt(self, index: int) -> QLayoutItem: + if index >= len(self._items): + return None + return self._items[index] + + @override + def sizeHint(self) -> QSize: + self._item_thumb(0) + return self._items[0].minimumSize() diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 2bc9677c..b2e20f49 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -52,7 +52,6 @@ from tagstudio.core.enums import MacroID, SettingItems, ShowFilepathOption from tagstudio.core.library.alchemy.enums import ( BrowsingState, FieldTypeEnum, - ItemType, SortingModeEnum, ) from tagstudio.core.library.alchemy.fields import FieldID @@ -83,7 +82,7 @@ from tagstudio.qt.mixed.drop_import_modal import DropImportModal from tagstudio.qt.mixed.fix_dupe_files import FixDupeFilesModal from tagstudio.qt.mixed.fix_unlinked import FixUnlinkedEntriesModal from tagstudio.qt.mixed.folders_to_tags import FoldersToTagsModal -from tagstudio.qt.mixed.item_thumb import BadgeType, ItemThumb +from tagstudio.qt.mixed.item_thumb import BadgeType from tagstudio.qt.mixed.migration_modal import JsonMigrationModal from tagstudio.qt.mixed.progress_bar import ProgressWidget from tagstudio.qt.mixed.settings_panel import SettingsPanel @@ -92,7 +91,6 @@ from tagstudio.qt.mixed.tag_database import TagDatabasePanel from tagstudio.qt.mixed.tag_search import TagSearchModal from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color from tagstudio.qt.platform_strings import trash_term -from tagstudio.qt.previews.renderer import ThumbRenderer from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.translations import Translations @@ -216,8 +214,6 @@ class QtDriver(DriverMixin, QObject): # self.buffer = {} self.thumb_job_queue: Queue = Queue() self.thumb_threads: list[Consumer] = [] - self.thumb_cutoff: float = time.time() - self.selected: list[int] = [] # Selected Entry IDs self.SIGTERM.connect(self.handle_sigterm) @@ -256,6 +252,10 @@ class QtDriver(DriverMixin, QObject): Translations.change_language(self.settings.language) + @property + def selected(self) -> list[int]: + return list(self.main_window.thumb_layout._selected.keys()) + def __reset_navigation(self) -> None: self.browsing_history = History(BrowsingState.show_all()) @@ -574,8 +574,6 @@ class QtDriver(DriverMixin, QObject): str(Path(__file__).parents[1] / "resources/qt/fonts/Oxanium-Bold.ttf") ) - self.item_thumbs: list[ItemThumb] = [] - self.thumb_renderers: list[ThumbRenderer] = [] self.init_library_window() self.migration_modal: JsonMigrationModal = None @@ -656,7 +654,6 @@ class QtDriver(DriverMixin, QObject): self.main_window.thumb_size_combobox.currentIndexChanged.connect( lambda: self.thumb_size_callback(self.main_window.thumb_size_combobox.currentIndex()) ) - self._update_thumb_count() self.main_window.back_button.clicked.connect(lambda: self.navigation_callback(-1)) self.main_window.forward_button.clicked.connect(lambda: self.navigation_callback(1)) @@ -691,7 +688,7 @@ class QtDriver(DriverMixin, QObject): self.main_window.menu_bar.ignore_modal_action.triggered.connect(self.ignore_modal.show) def show_grid_filenames(self, value: bool): - for thumb in self.item_thumbs: + for thumb in self.main_window.thumb_layout._item_thumbs: thumb.set_filename_visibility(value) def call_if_library_open(self, func): @@ -745,9 +742,7 @@ class QtDriver(DriverMixin, QObject): self.main_window.setWindowTitle(self.base_title) - self.selected.clear() self.frame_content.clear() - [x.set_mode(None) for x in self.item_thumbs] if self.color_manager_panel: self.color_manager_panel.reset() @@ -757,6 +752,7 @@ class QtDriver(DriverMixin, QObject): if hasattr(self, "library_info_window"): self.library_info_window.close() + self.main_window.thumb_layout.set_entries([]) self.main_window.preview_panel.set_selection(self.selected) self.main_window.toggle_landing_page(enabled=True) self.main_window.pagination.setHidden(True) @@ -828,11 +824,7 @@ class QtDriver(DriverMixin, QObject): def select_all_action_callback(self): """Set the selection to all visible items.""" - self.selected.clear() - for item in self.item_thumbs: - if item.mode and item.item_id not in self.selected and not item.isHidden(): - self.selected.append(item.item_id) - item.thumb_button.set_selected(True) + self.main_window.thumb_layout.select_all() self.set_clipboard_menu_viability() self.set_select_actions_visibility() @@ -841,17 +833,7 @@ class QtDriver(DriverMixin, QObject): def select_inverse_action_callback(self): """Invert the selection of all visible items.""" - new_selected = [] - - for item in self.item_thumbs: - if item.mode and not item.isHidden(): - if item.item_id in self.selected: - item.thumb_button.set_selected(False) - else: - item.thumb_button.set_selected(True) - new_selected.append(item.item_id) - - self.selected = new_selected + self.main_window.thumb_layout.select_inverse() self.set_clipboard_menu_viability() self.set_select_actions_visibility() @@ -859,16 +841,16 @@ class QtDriver(DriverMixin, QObject): self.main_window.preview_panel.set_selection(self.selected, update_preview=False) def clear_select_action_callback(self): - self.selected.clear() - self.set_select_actions_visibility() - for item in self.item_thumbs: - item.thumb_button.set_selected(False) + self.main_window.thumb_layout.clear_selected() + self.set_select_actions_visibility() self.set_clipboard_menu_viability() self.main_window.preview_panel.set_selection(self.selected) def add_tags_to_selected_callback(self, tag_ids: list[int]): - self.lib.add_tags_to_entries(self.selected, tag_ids) + selected = self.selected + self.main_window.thumb_layout.add_tags(selected, tag_ids) + self.lib.add_tags_to_entries(selected, tag_ids) def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = None): """Callback to send on or more files to the system trash. @@ -887,15 +869,17 @@ class QtDriver(DriverMixin, QObject): pending: list[tuple[int, Path]] = [] deleted_count: int = 0 - if len(self.selected) <= 1 and origin_path: + selected = self.selected + + if len(selected) <= 1 and origin_path: origin_id_ = origin_id if not origin_id_: with contextlib.suppress(IndexError): - origin_id_ = self.selected[0] + origin_id_ = selected[0] pending.append((origin_id_, Path(origin_path))) - elif (len(self.selected) > 1) or (len(self.selected) <= 1): - for item in self.selected: + elif (len(selected) > 1) or (len(selected) <= 1): + for item in selected: entry = self.lib.get_entry(item) filepath: Path = entry.path pending.append((item, filepath)) @@ -921,25 +905,26 @@ class QtDriver(DriverMixin, QObject): self.lib.remove_entries([e_id]) deleted_count += 1 - self.selected.clear() + selected.clear() + self.clear_select_action_callback() if deleted_count > 0: self.update_browsing_state() - self.main_window.preview_panel.set_selection(self.selected) + self.main_window.preview_panel.set_selection(selected) - if len(self.selected) <= 1 and deleted_count == 0: + if len(selected) <= 1 and deleted_count == 0: self.main_window.status_bar.showMessage(Translations["status.deleted_none"]) - elif len(self.selected) <= 1 and deleted_count == 1: + elif len(selected) <= 1 and deleted_count == 1: self.main_window.status_bar.showMessage( Translations.format("status.deleted_file_plural", count=deleted_count) ) - elif len(self.selected) > 1 and deleted_count == 0: + elif len(selected) > 1 and deleted_count == 0: self.main_window.status_bar.showMessage(Translations["status.deleted_none"]) - elif len(self.selected) > 1 and deleted_count < len(self.selected): + elif len(selected) > 1 and deleted_count < len(selected): self.main_window.status_bar.showMessage( Translations.format("status.deleted_partial_warning", count=deleted_count) ) - elif len(self.selected) > 1 and deleted_count == len(self.selected): + elif len(selected) > 1 and deleted_count == len(selected): self.main_window.status_bar.showMessage( Translations.format("status.deleted_file_plural", count=deleted_count) ) @@ -1160,7 +1145,7 @@ class QtDriver(DriverMixin, QObject): self.update_thumbs() blank_icon: QIcon = QIcon() - for it in self.item_thumbs: + for it in self.main_window.thumb_layout._item_thumbs: it.thumb_button.setIcon(blank_icon) it.resize(self.main_window.thumb_size, self.main_window.thumb_size) it.thumb_size = (self.main_window.thumb_size, self.main_window.thumb_size) @@ -1204,25 +1189,6 @@ class QtDriver(DriverMixin, QObject): self.update_browsing_state() - def remove_grid_item(self, grid_idx: int): - self.frame_content[grid_idx] = None - self.item_thumbs[grid_idx].hide() - - def _update_thumb_count(self): - missing_count = max(0, self.settings.page_size - len(self.item_thumbs)) - layout = self.main_window.thumb_layout - for _ in range(missing_count): - item_thumb = ItemThumb( - None, - self.lib, - self, - (self.main_window.thumb_size, self.main_window.thumb_size), - self.settings.show_filenames_in_grid, - ) - - layout.addWidget(item_thumb) - self.item_thumbs.append(item_thumb) - def copy_fields_action_callback(self): if len(self.selected) > 0: entry = self.lib.get_entry_full(self.selected[0]) @@ -1269,58 +1235,13 @@ class QtDriver(DriverMixin, QObject): Setting to True acts like "Shift + Click" selecting. """ logger.info("[QtDriver] Selecting Items:", item_id=item_id, append=append, bridge=bridge) - if append: - if item_id not in self.selected: - self.selected.append(item_id) - for it in self.item_thumbs: - if it.item_id == item_id: - it.thumb_button.set_selected(True) - else: - self.selected.remove(item_id) - for it in self.item_thumbs: - if it.item_id == item_id: - it.thumb_button.set_selected(False) - - # TODO: Allow bridge selecting across pages. - elif bridge and self.selected: - last_index = -1 - current_index = -1 - try: - contents = self.frame_content - last_index = self.frame_content.index(self.selected[-1]) - current_index = self.frame_content.index(item_id) - index_range: list = contents[ - min(last_index, current_index) : max(last_index, current_index) + 1 - ] - - # Preserve bridge direction for correct appending order. - if last_index < current_index: - index_range.reverse() - for entry_id in index_range: - for it in self.item_thumbs: - if it.item_id == entry_id: - it.thumb_button.set_selected(True) - if entry_id not in self.selected: - self.selected.append(entry_id) - except Exception as e: - # TODO: Allow bridge selecting across pages. - logger.error( - "[QtDriver] Previous selected item not on current page!", - error=e, - item_id=item_id, - current_index=current_index, - last_index=last_index, - ) - + self.main_window.thumb_layout.select_entry(item_id) + elif bridge: + self.main_window.thumb_layout.select_to_entry(item_id) else: - self.selected.clear() - self.selected.append(item_id) - for it in self.item_thumbs: - if it.item_id in self.selected: - it.thumb_button.set_selected(True) - else: - it.thumb_button.set_selected(False) + self.main_window.thumb_layout.clear_selected() + self.main_window.thumb_layout.select_entry(item_id) self.set_clipboard_menu_viability() self.set_select_actions_visibility() @@ -1429,86 +1350,16 @@ class QtDriver(DriverMixin, QObject): def update_thumbs(self): """Update search thumbnails.""" - self._update_thumb_count() with self.thumb_job_queue.mutex: # Cancels all thumb jobs waiting to be started self.thumb_job_queue.queue.clear() self.thumb_job_queue.all_tasks_done.notify_all() self.thumb_job_queue.not_full.notify_all() - # Stops in-progress jobs from finishing - ItemThumb.update_cutoff = time.time() - - ratio: float = self.main_window.devicePixelRatio() - base_size: tuple[int, int] = (self.main_window.thumb_size, self.main_window.thumb_size) + self.main_window.thumb_layout.set_entries(self.frame_content) self.main_window.thumb_layout.update() self.main_window.update() - is_grid_thumb = True - logger.info("[QtDriver] Loading Entries...") - # TODO: The full entries with joins don't need to be grabbed here. - # Use a method that only selects the frame content but doesn't include the joins. - entries = self.lib.get_entries(self.frame_content) - tag_entries = self.lib.get_tag_entries([TAG_ARCHIVED, TAG_FAVORITE], self.frame_content) - logger.info("[QtDriver] Building Filenames...") - filenames: list[Path] = [self.lib.library_dir / e.path for e in entries] - logger.info("[QtDriver] Done! Processing ItemThumbs...") - for index, item_thumb in enumerate(self.item_thumbs, start=0): - entry = None - item_thumb.set_mode(None) - - try: - entry = entries[index] - except IndexError: - item_thumb.hide() - continue - if not entry: - continue - - with catch_warnings(record=True): - item_thumb.delete_action.triggered.disconnect() - - item_thumb.set_mode(ItemType.ENTRY) - item_thumb.set_item_id(entry.id) - item_thumb.show() - is_loading = True - self.thumb_job_queue.put( - ( - item_thumb.renderer.render, - (sys.float_info.max, "", base_size, ratio, is_loading, is_grid_thumb), - ) - ) - - # Show rendered thumbnails - for index, item_thumb in enumerate(self.item_thumbs, start=0): - entry = None - try: - entry = entries[index] - except IndexError: - item_thumb.hide() - continue - if not entry: - continue - - is_loading = False - self.thumb_job_queue.put( - ( - item_thumb.renderer.render, - (time.time(), filenames[index], base_size, ratio, is_loading, is_grid_thumb), - ) - ) - item_thumb.assign_badge(BadgeType.ARCHIVED, entry.id in tag_entries[TAG_ARCHIVED]) - item_thumb.assign_badge(BadgeType.FAVORITE, entry.id in tag_entries[TAG_FAVORITE]) - item_thumb.delete_action.triggered.connect( - lambda checked=False, f=filenames[index], e_id=entry.id: self.delete_files_callback( - f, e_id - ) - ) - - # Restore Selected Borders - is_selected = item_thumb.item_id in self.selected - item_thumb.thumb_button.set_selected(is_selected) - def update_badges(self, badge_values: dict[BadgeType, bool], origin_id: int, add_tags=True): """Update the tag badges for item_thumbs. @@ -1529,7 +1380,7 @@ class QtDriver(DriverMixin, QObject): origin_id=origin_id, add_tags=add_tags, ) - for it in self.item_thumbs: + for it in self.main_window.thumb_layout._item_thumbs: if it.item_id in item_ids: for badge_type, value in badge_values.items(): if add_tags: @@ -1547,14 +1398,15 @@ class QtDriver(DriverMixin, QObject): pending_entries=pending_entries, ) for badge_type, value in badge_values.items(): + entry_ids = pending_entries.get(badge_type, []) + tag_ids = [BADGE_TAGS[badge_type]] + if value: - self.lib.add_tags_to_entries( - pending_entries.get(badge_type, []), BADGE_TAGS[badge_type] - ) + self.main_window.thumb_layout.add_tags(entry_ids, tag_ids) + self.lib.add_tags_to_entries(entry_ids, tag_ids) else: - self.lib.remove_tags_from_entries( - pending_entries.get(badge_type, []), BADGE_TAGS[badge_type] - ) + self.main_window.thumb_layout.remove_tags(entry_ids, tag_ids) + self.lib.remove_tags_from_entries(entry_ids, tag_ids) def update_browsing_state(self, state: BrowsingState | None = None) -> None: """Navigates to a new BrowsingState when state is given, otherwise updates the results.""" @@ -1574,7 +1426,8 @@ class QtDriver(DriverMixin, QObject): # search the library start_time = time.time() Ignore.get_patterns(self.lib.library_dir, include_global=True) - results = self.lib.search_library(self.browsing_history.current, self.settings.page_size) + page_size = 0 if self.settings.infinite_scroll else self.settings.page_size + results = self.lib.search_library(self.browsing_history.current, page_size) logger.info("items to render", count=len(results)) end_time = time.time() @@ -1592,7 +1445,10 @@ class QtDriver(DriverMixin, QObject): self.update_thumbs() # update pagination - self.pages_count = math.ceil(results.total_count / self.settings.page_size) + if page_size > 0: + self.pages_count = math.ceil(results.total_count / page_size) + else: + self.pages_count = 1 self.main_window.pagination.update_buttons( self.pages_count, self.browsing_history.current.page_index, emit=False ) @@ -1753,7 +1609,6 @@ class QtDriver(DriverMixin, QObject): self.init_ignore_modal() - self.selected.clear() self.set_select_actions_visibility() self.main_window.menu_bar.save_library_backup_action.setEnabled(True) self.main_window.menu_bar.close_library_action.setEnabled(True) diff --git a/src/tagstudio/qt/utils/file_opener.py b/src/tagstudio/qt/utils/file_opener.py index 3f613b78..e1b27c27 100644 --- a/src/tagstudio/qt/utils/file_opener.py +++ b/src/tagstudio/qt/utils/file_opener.py @@ -106,21 +106,21 @@ def open_file(path: str | Path, file_manager: bool = False, windows_start_comman class FileOpenerHelper: - def __init__(self, filepath: str | Path): + def __init__(self, filepath: Path): """Initialize the FileOpenerHelper. Args: - filepath (str): The path to the file to open. + filepath (Path): The path to the file to open. """ - self.filepath = str(filepath) + self.filepath = filepath - def set_filepath(self, filepath: str | Path): + def set_filepath(self, filepath: Path): """Set the filepath to open. Args: - filepath (str): The path to the file to open. + filepath (Path): The path to the file to open. """ - self.filepath = str(filepath) + self.filepath = filepath def open_file(self): """Open the file in the default application.""" @@ -140,15 +140,15 @@ class FileOpenerLabel(QLabel): Args: parent (QWidget, optional): The parent widget. Defaults to None. """ - self.filepath: str | Path | None = None + self.filepath: Path | None = None super().__init__(parent) - def set_file_path(self, filepath: str | Path) -> None: + def set_file_path(self, filepath: Path) -> None: """Set the filepath to open. Args: - filepath (str): The path to the file to open. + filepath (Path): The path to the file to open. """ self.filepath = filepath diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py index 7c23e2ff..ce02ce57 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -42,8 +42,8 @@ from tagstudio.qt.mixed.pagination import Pagination from tagstudio.qt.mnemonics import assign_mnemonics from tagstudio.qt.platform_strings import trash_term from tagstudio.qt.resource_manager import ResourceManager +from tagstudio.qt.thumb_grid_layout import ThumbGridLayout from tagstudio.qt.translations import Translations -from tagstudio.qt.views.layouts.flow_layout import FlowLayout # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: @@ -476,7 +476,7 @@ class MainWindow(QMainWindow): self.entry_list_layout: QVBoxLayout self.entry_scroll_area: QScrollArea self.thumb_grid: QWidget - self.thumb_layout: FlowLayout + self.thumb_layout: ThumbGridLayout self.landing_widget: LandingWidget self.pagination: Pagination @@ -649,11 +649,13 @@ class MainWindow(QMainWindow): self.entry_scroll_area.setFrameShadow(QFrame.Shadow.Plain) self.entry_scroll_area.setWidgetResizable(True) self.entry_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.entry_scroll_area.verticalScrollBar().valueChanged.connect( + lambda value: self.thumb_layout.update() + ) self.thumb_grid = QWidget() self.thumb_grid.setObjectName("thumb_grid") - self.thumb_layout = FlowLayout() - self.thumb_layout.enable_grid_optimizations(value=True) + self.thumb_layout = ThumbGridLayout(driver, self.entry_scroll_area) self.thumb_layout.setSpacing(min(self.thumb_size // 10, 12)) self.thumb_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) self.thumb_grid.setLayout(self.thumb_layout) diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py index 6f4b0c2c..e50509ad 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_thumb_view.py @@ -98,7 +98,7 @@ class PreviewThumbView(QWidget): self.__media_player_page = QWidget() self.__stacked_page_setup(self.__media_player_page, self.__media_player) - self.__thumb_renderer = ThumbRenderer(driver, library) + self.__thumb_renderer = ThumbRenderer(driver) self.__thumb_renderer.updated.connect(self.__thumb_renderer_updated_callback) self.__thumb_renderer.updated_ratio.connect(self.__thumb_renderer_updated_ratio_callback) diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index d7c649ac..edda0231 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -266,6 +266,7 @@ "settings.generate_thumbs": "Thumbnail Generation", "settings.global": "Global Settings", "settings.hourformat.label": "24-Hour Time", + "settings.infinite_scroll": "Infinite Scrolling", "settings.language": "Language", "settings.library": "Library Settings", "settings.open_library_on_start": "Open Library on Start", diff --git a/tests/conftest.py b/tests/conftest.py index a2c97c10..54368a5f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ from tempfile import TemporaryDirectory from unittest.mock import Mock, patch import pytest +from PySide6.QtWidgets import QScrollArea CWD = Path(__file__).parent # this needs to be above `src` imports @@ -19,6 +20,7 @@ from tagstudio.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry, Tag from tagstudio.core.utils.types import unwrap +from tagstudio.qt.thumb_grid_layout import ThumbGridLayout from tagstudio.qt.ts_qt import QtDriver @@ -165,7 +167,8 @@ def qt_driver(library: Library, library_dir: Path): driver.app = Mock() driver.main_window = Mock() driver.main_window.thumb_size = 128 - driver.item_thumbs = [] + driver.main_window.thumb_layout = ThumbGridLayout(driver, QScrollArea()) + driver.main_window.menu_bar.autofill_action = Mock() driver.copy_buffer = {"fields": [], "tags": []} diff --git a/tests/qt/test_item_thumb.py b/tests/qt/test_item_thumb.py index a2662a1d..f022f976 100644 --- a/tests/qt/test_item_thumb.py +++ b/tests/qt/test_item_thumb.py @@ -17,8 +17,7 @@ def test_badge_visual_state(qt_driver: QtDriver, entry_min: int, new_value: bool ) qt_driver.frame_content = [entry_min] - qt_driver.selected = [0] - qt_driver.item_thumbs = [thumb] + qt_driver.toggle_item_selection(0, append=False, bridge=False) thumb.badges[BadgeType.FAVORITE].setChecked(new_value) assert thumb.badges[BadgeType.FAVORITE].isChecked() == new_value diff --git a/tests/qt/test_qt_driver.py b/tests/qt/test_qt_driver.py index 003179a8..3cf042aa 100644 --- a/tests/qt/test_qt_driver.py +++ b/tests/qt/test_qt_driver.py @@ -2,19 +2,17 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio - -from tagstudio.core.library.alchemy.enums import BrowsingState, ItemType +from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.utils.types import unwrap -from tagstudio.qt.mixed.item_thumb import ItemThumb from tagstudio.qt.ts_qt import QtDriver def test_browsing_state_update(qt_driver: QtDriver): # Given - for entry in qt_driver.lib.all_entries(with_joins=True): - thumb = ItemThumb(ItemType.ENTRY, qt_driver.lib, qt_driver, (100, 100)) - qt_driver.item_thumbs.append(thumb) - qt_driver.frame_content.append(entry.id) + entries = qt_driver.lib.all_entries(with_joins=True) + ids = [e.id for e in entries] + qt_driver.frame_content = ids + qt_driver.main_window.thumb_layout.set_entries(ids) # no filter, both items are returned qt_driver.update_browsing_state() @@ -49,7 +47,7 @@ def test_close_library(qt_driver: QtDriver): assert qt_driver.lib.library_dir is None assert not qt_driver.frame_content assert not qt_driver.selected - assert not any(x.mode for x in qt_driver.item_thumbs) + assert len(qt_driver.main_window.thumb_layout._entry_ids) == 0 # close library again to see there's no error qt_driver.close_library()