Skip to content

Commit 6e6a91a

Browse files
authored
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
1 parent d7573b3 commit 6e6a91a

File tree

15 files changed

+528
-278
lines changed

15 files changed

+528
-278
lines changed

src/tagstudio/core/library/alchemy/library.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -994,7 +994,14 @@ def search_library(
994994
assert self.library_dir
995995

996996
with Session(unwrap(self.engine), expire_on_commit=False) as session:
997-
statement = select(Entry.id, func.count().over())
997+
if page_size:
998+
statement = (
999+
select(Entry.id, func.count().over())
1000+
.offset(search.page_index * page_size)
1001+
.limit(page_size)
1002+
)
1003+
else:
1004+
statement = select(Entry.id)
9981005

9991006
if search.ast:
10001007
start_time = time.time()
@@ -1017,8 +1024,6 @@ def search_library(
10171024
sort_on = func.sin(Entry.id * search.random_seed)
10181025

10191026
statement = statement.order_by(asc(sort_on) if search.ascending else desc(sort_on))
1020-
if page_size is not None:
1021-
statement = statement.limit(page_size).offset(search.page_index * page_size)
10221027

10231028
logger.info(
10241029
"searching library",
@@ -1027,17 +1032,21 @@ def search_library(
10271032
)
10281033

10291034
start_time = time.time()
1030-
rows = session.execute(statement).fetchall()
1031-
ids = []
1032-
count = 0
1033-
for row in rows:
1034-
id, count = row._tuple() # pyright: ignore[reportPrivateUsage]
1035-
ids.append(id)
1035+
if page_size:
1036+
rows = session.execute(statement).fetchall()
1037+
ids = []
1038+
total_count = 0
1039+
for row in rows:
1040+
ids.append(row[0])
1041+
total_count = row[1]
1042+
else:
1043+
ids = list(session.scalars(statement))
1044+
total_count = len(ids)
10361045
end_time = time.time()
10371046
logger.info(f"SQL Execution finished ({format_timespan(end_time - start_time)})")
10381047

10391048
res = SearchResult(
1040-
total_count=count,
1049+
total_count=total_count,
10411050
ids=ids,
10421051
)
10431052

src/tagstudio/qt/global_settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ class GlobalSettings(BaseModel):
6666
loop: bool = Field(default=True)
6767
show_filenames_in_grid: bool = Field(default=True)
6868
page_size: int = Field(default=100)
69+
infinite_scroll: bool = Field(default=True)
6970
show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT)
7071
tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT)
7172
theme: Theme = Field(default=Theme.SYSTEM)

src/tagstudio/qt/mixed/file_attributes.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ def update_stats(self, filepath: Path | None = None, stats: FileAttributeData |
151151
self.layout().setSpacing(0)
152152
self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
153153
self.file_label.setText(f"<i>{Translations['preview.no_selection']}</i>")
154-
self.file_label.set_file_path("")
154+
self.file_label.set_file_path(Path())
155155
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
156156
self.dimensions_label.setText("")
157157
self.dimensions_label.setHidden(True)
@@ -264,6 +264,6 @@ def update_multi_selection(self, count: int):
264264
self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
265265
self.file_label.setText(Translations.format("preview.multiple_selection", count=count))
266266
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
267-
self.file_label.set_file_path("")
267+
self.file_label.set_file_path(Path())
268268
self.dimensions_label.setText("")
269269
self.dimensions_label.setHidden(True)

src/tagstudio/qt/mixed/item_thumb.py

Lines changed: 27 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
44

55

6-
import time
76
from enum import Enum
87
from functools import wraps
98
from pathlib import Path
@@ -21,13 +20,13 @@
2120
from tagstudio.core.media_types import MediaCategories, MediaType
2221
from tagstudio.core.utils.types import unwrap
2322
from tagstudio.qt.platform_strings import open_file_str, trash_term
24-
from tagstudio.qt.previews.renderer import ThumbRenderer
2523
from tagstudio.qt.translations import Translations
2624
from tagstudio.qt.utils.file_opener import FileOpenerHelper
2725
from tagstudio.qt.views.layouts.flow_layout import FlowWidget
2826
from tagstudio.qt.views.thumb_button import ThumbButton
2927

3028
if TYPE_CHECKING:
29+
from tagstudio.core.library.alchemy.models import Entry
3130
from tagstudio.qt.ts_qt import QtDriver
3231

3332
logger = structlog.get_logger(__name__)
@@ -66,8 +65,6 @@ def wrapper(self, *args, **kwargs):
6665
class ItemThumb(FlowWidget):
6766
"""The thumbnail widget for a library item (Entry, Collation, Tag Group, etc.)."""
6867

69-
update_cutoff: float = time.time()
70-
7168
collation_icon_128: Image.Image = Image.open(
7269
str(Path(__file__).parents[2] / "resources/qt/images/collation_icon_128.png")
7370
)
@@ -119,6 +116,8 @@ def __init__(
119116
self.mode: ItemType | None = mode
120117
self.driver = driver
121118
self.item_id: int = -1
119+
self.item_path: Path | None = None
120+
self.rendered_path: Path | None = None
122121
self.thumb_size: tuple[int, int] = thumb_size
123122
self.show_filename_label: bool = show_filename_label
124123
self.label_height = 12
@@ -195,20 +194,11 @@ def __init__(
195194
self.thumb_layout.addWidget(self.bottom_container)
196195

197196
self.thumb_button = ThumbButton(self.thumb_container, thumb_size)
198-
self.renderer = ThumbRenderer(driver, self.lib)
199-
self.renderer.updated.connect(
200-
lambda timestamp, image, size, filename: (
201-
self.update_thumb(image, timestamp),
202-
self.update_size(size, timestamp),
203-
self.set_filename_text(filename, timestamp),
204-
self.set_extension(filename, timestamp),
205-
)
206-
)
207197
self.thumb_button.setFlat(True)
208198
self.thumb_button.setLayout(self.thumb_layout)
209199
self.thumb_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
210200

211-
self.opener = FileOpenerHelper("")
201+
self.opener = FileOpenerHelper(Path())
212202
open_file_action = QAction(Translations["file.open_file"], self)
213203
open_file_action.triggered.connect(self.opener.open_file)
214204
open_explorer_action = QAction(open_file_str(), self)
@@ -219,6 +209,12 @@ def __init__(
219209
self,
220210
)
221211

212+
def _on_delete():
213+
if self.item_id != -1 and self.item_path is not None:
214+
self.driver.delete_files_callback(self.item_path, self.item_id)
215+
216+
self.delete_action.triggered.connect(lambda checked=False: _on_delete())
217+
222218
self.thumb_button.addAction(open_file_action)
223219
self.thumb_button.addAction(open_explorer_action)
224220
self.thumb_button.addAction(self.delete_action)
@@ -338,7 +334,7 @@ def set_mode(self, mode: ItemType | None) -> None:
338334
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=True)
339335
self.thumb_button.unsetCursor()
340336
self.thumb_button.setHidden(True)
341-
elif mode == ItemType.ENTRY and self.mode != ItemType.ENTRY:
337+
elif mode == ItemType.ENTRY:
342338
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False)
343339
self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor)
344340
self.thumb_button.setHidden(False)
@@ -348,7 +344,7 @@ def set_mode(self, mode: ItemType | None) -> None:
348344
self.count_badge.setStyleSheet(ItemThumb.small_text_style)
349345
self.count_badge.setHidden(True)
350346
self.ext_badge.setHidden(True)
351-
elif mode == ItemType.COLLATION and self.mode != ItemType.COLLATION:
347+
elif mode == ItemType.COLLATION:
352348
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False)
353349
self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor)
354350
self.thumb_button.setHidden(False)
@@ -357,7 +353,7 @@ def set_mode(self, mode: ItemType | None) -> None:
357353
self.count_badge.setStyleSheet(ItemThumb.med_text_style)
358354
self.count_badge.setHidden(False)
359355
self.item_type_badge.setHidden(False)
360-
elif mode == ItemType.TAG_GROUP and self.mode != ItemType.TAG_GROUP:
356+
elif mode == ItemType.TAG_GROUP:
361357
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False)
362358
self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor)
363359
self.thumb_button.setHidden(False)
@@ -366,15 +362,12 @@ def set_mode(self, mode: ItemType | None) -> None:
366362
self.item_type_badge.setHidden(False)
367363
self.mode = mode
368364

369-
def set_extension(self, filename: Path, timestamp: float | None = None) -> None:
370-
if timestamp and timestamp < ItemThumb.update_cutoff:
371-
return
372-
365+
def set_extension(self, filename: Path) -> None:
373366
ext = filename.suffix.lower()
374367
if ext and ext.startswith(".") is False:
375368
ext = "." + ext
376369
media_types: set[MediaType] = MediaCategories.get_types(ext)
377-
if (
370+
if ext and (
378371
not MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES)
379372
or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES)
380373
or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_VECTOR_TYPES)
@@ -408,11 +401,7 @@ def set_count(self, count: str) -> None:
408401
self.ext_badge.setHidden(True)
409402
self.count_badge.setHidden(True)
410403

411-
def set_filename_text(self, filename: Path, timestamp: float | None = None):
412-
if timestamp and timestamp < ItemThumb.update_cutoff:
413-
return
414-
415-
self.set_item_path(filename)
404+
def set_filename_text(self, filename: Path):
416405
self.file_label.setText(str(filename.name))
417406

418407
def set_filename_visibility(self, set_visible: bool):
@@ -430,48 +419,44 @@ def set_filename_visibility(self, set_visible: bool):
430419
self.setFixedHeight(self.thumb_size[1])
431420
self.show_filename_label = set_visible
432421

433-
def update_thumb(self, image: QPixmap | None = None, timestamp: float | None = None):
422+
def update_thumb(self, image: QPixmap | None = None, file_path: Path | None = None):
434423
"""Update attributes of a thumbnail element."""
435-
if timestamp and timestamp < ItemThumb.update_cutoff:
436-
return
437-
438424
self.thumb_button.setIcon(image if image else QPixmap())
425+
self.rendered_path = file_path
439426

440-
def update_size(self, size: QSize, timestamp: float | None = None):
427+
def update_size(self, size: QSize):
441428
"""Updates attributes of a thumbnail element.
442429
443430
Args:
444-
timestamp (float | None): The UTC timestamp for when this call was
445-
originally dispatched. Used to skip outdated jobs.
446-
447431
size (QSize): The new thumbnail size to set.
448432
"""
449-
if timestamp and timestamp < ItemThumb.update_cutoff:
450-
return
451-
452433
self.thumb_size = size.width(), size.height()
453434
self.thumb_button.setIconSize(size)
454435
self.thumb_button.setMinimumSize(size)
455436
self.thumb_button.setMaximumSize(size)
456437

438+
def set_item(self, entry: "Entry"):
439+
self.set_item_id(entry.id)
440+
self.set_item_path(entry.path)
441+
457442
def set_item_id(self, item_id: int):
458443
self.item_id = item_id
459444

460-
def set_item_path(self, path: Path | str):
445+
def set_item_path(self, path: Path):
461446
"""Set the absolute filepath for the item. Used for locating on disk."""
447+
self.item_path = path
462448
self.opener.set_filepath(path)
463449

464450
def assign_badge(self, badge_type: BadgeType, value: bool) -> None:
465451
mode = self.mode
466452
# blank mode to avoid recursive badge updates
467-
self.mode = None
468453
badge = self.badges[badge_type]
469454
self.badge_active[badge_type] = value
470455
if badge.isChecked() != value:
456+
self.mode = None
471457
badge.setChecked(value)
472458
badge.setHidden(not value)
473-
474-
self.mode = mode
459+
self.mode = mode
475460

476461
def show_check_badges(self, show: bool):
477462
if self.mode != ItemType.TAG_GROUP:

src/tagstudio/qt/mixed/settings_panel.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,12 +189,17 @@ def __build_global_settings(self):
189189

190190
def on_page_size_changed():
191191
text = self.page_size_line_edit.text()
192-
if not text.isdigit() or int(text) < 1:
192+
if not text.isdigit():
193193
self.page_size_line_edit.setText(str(self.driver.settings.page_size))
194194

195195
self.page_size_line_edit.editingFinished.connect(on_page_size_changed)
196196
form_layout.addRow(Translations["settings.page_size"], self.page_size_line_edit)
197197

198+
# Infinite Scrolling
199+
self.infinite_scroll = QCheckBox()
200+
self.infinite_scroll.setChecked(self.driver.settings.infinite_scroll)
201+
form_layout.addRow(Translations["settings.infinite_scroll"], self.infinite_scroll)
202+
198203
# Show Filepath
199204
self.filepath_combobox = QComboBox()
200205
for k in SettingsPanel.filepath_option_map:
@@ -288,6 +293,7 @@ def get_settings(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
288293
"autoplay": self.autoplay_checkbox.isChecked(),
289294
"show_filenames_in_grid": self.show_filenames_checkbox.isChecked(),
290295
"page_size": int(self.page_size_line_edit.text()),
296+
"infinite_scroll": self.infinite_scroll.isChecked(),
291297
"show_filepath": self.filepath_combobox.currentData(),
292298
"theme": self.theme_combobox.currentData(),
293299
"tag_click_action": self.tag_click_action_combobox.currentData(),
@@ -307,6 +313,7 @@ def update_settings(self, driver: "QtDriver"):
307313
driver.settings.thumb_cache_size = settings["thumb_cache_size"]
308314
driver.settings.show_filenames_in_grid = settings["show_filenames_in_grid"]
309315
driver.settings.page_size = settings["page_size"]
316+
driver.settings.infinite_scroll = settings["infinite_scroll"]
310317
driver.settings.show_filepath = settings["show_filepath"]
311318
driver.settings.theme = settings["theme"]
312319
driver.settings.tag_click_action = settings["tag_click_action"]

src/tagstudio/qt/previews/renderer.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@
8080
from tagstudio.qt.resource_manager import ResourceManager
8181

8282
if TYPE_CHECKING:
83-
from tagstudio.core.library.alchemy.library import Library
8483
from tagstudio.qt.ts_qt import QtDriver
8584

8685
ImageFile.LOAD_TRUNCATED_IMAGES = True
@@ -138,11 +137,10 @@ class ThumbRenderer(QObject):
138137
updated_ratio = Signal(float)
139138
cached_img_ext: str = ".webp"
140139

141-
def __init__(self, driver: "QtDriver", library: "Library") -> None:
140+
def __init__(self, driver: "QtDriver") -> None:
142141
"""Initialize the class."""
143142
super().__init__()
144143
self.driver = driver
145-
self.lib = library
146144

147145
settings_res = self.driver.settings.cached_thumb_resolution
148146
self.cached_img_res = (
@@ -1534,7 +1532,7 @@ def fetch_cached_image(file_name: Path):
15341532
image
15351533
and Ignore.compiled_patterns
15361534
and Ignore.compiled_patterns.match(
1537-
filepath.relative_to(unwrap(self.lib.library_dir))
1535+
filepath.relative_to(unwrap(self.driver.lib.library_dir))
15381536
)
15391537
):
15401538
image = render_ignored((adj_size, adj_size), pixel_ratio, image)

0 commit comments

Comments
 (0)