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:
TheBobBobs
2025-09-12 05:15:58 +00:00
committed by GitHub
parent d7573b3f26
commit 6e6a91aaf4
15 changed files with 528 additions and 278 deletions

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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"]

View File

@@ -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)

View 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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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",

View File

@@ -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": []}

View File

@@ -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

View File

@@ -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()