mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-01-28 22:01:24 +00:00
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
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -151,7 +151,7 @@ class FileAttributes(QWidget):
|
||||
self.layout().setSpacing(0)
|
||||
self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.file_label.setText(f"<i>{Translations['preview.no_selection']}</i>")
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
392
src/tagstudio/qt/thumb_grid_layout.py
Normal file
392
src/tagstudio/qt/thumb_grid_layout.py
Normal file
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": []}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user