Skip to content

Commit 4f4f1de

Browse files
authored
ui: improve thumbnail grid browsing (#1541)
* perf(ui): batch thumb grid updates, reuse page results, cache Qt paint objects * perf: fetch entry IDs for searches with faster direct `connection()` Fetching entry IDs from session.connection() instead of the previous methods is able to return them without a bunch of ORM overhead which isn't needed for these searches. * perf(ui): decode JPEGs using draft mode * fix: improve some hangups on shutdown * ui: tweak pagination style * fix(ui): fix banner animation height weirdness - Fix the banner height dipping when one is animating out while the other is animating in - Fix a banner animating out when closing a library to cause the landing logo to strobe while it animates in * fix: don't set selection on shutdown, clean up loggers * perf(ui): use draft mode in the archive and ebook renderers * fix(ui): cache theme variants of thumbs separately * feat(ui): clear thumb grid selection by clicking background
1 parent 2288772 commit 4f4f1de

22 files changed

Lines changed: 435 additions & 167 deletions

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def show_all(cls) -> BrowsingState:
106106

107107
@classmethod
108108
def from_search_query(cls, search_query: str) -> BrowsingState:
109-
return cls(query=search_query)
109+
return cls(query=search_query if search_query.strip() else None)
110110

111111
@classmethod
112112
def from_tag_id(cls, tag_id: int | str, state: BrowsingState | None = None) -> BrowsingState:
@@ -152,7 +152,7 @@ def with_sorting_direction(self, ascending: bool) -> BrowsingState:
152152
return self._with_new_results(ascending=ascending)
153153

154154
def with_search_query(self, search_query: str) -> BrowsingState:
155-
return self._with_new_results(query=search_query)
155+
return self._with_new_results(query=search_query if search_query.strip() else None)
156156

157157
def with_show_hidden_entries(self, show_hidden_entries: bool) -> BrowsingState:
158158
return self._with_new_results(show_hidden_entries=show_hidden_entries)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -993,14 +993,14 @@ def search_library(
993993

994994
start_time = time.time()
995995
if page_size:
996-
rows = session.execute(statement).fetchall()
996+
rows = session.connection().execute(statement).fetchall()
997997
ids = []
998998
total_count = 0
999999
for row in rows:
10001000
ids.append(row[0])
10011001
total_count = row[1]
10021002
else:
1003-
ids = list(session.scalars(statement))
1003+
ids = list(session.connection().execute(statement).scalars())
10041004
total_count = len(ids)
10051005
end_time = time.time()
10061006
logger.info(f"SQL Execution finished ({format_timespan(end_time - start_time)})")

‎src/tagstudio/previews/base_preview.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@ class BasePreview:
1717
"""A base preview renderer class.
1818
1919
Attributes:
20-
has_small_variant (bool): Whether `is_small` renders a different image than the preview.
20+
has_small_variant (bool): Whether this renderer has a small variant (for thumbnails).
21+
has_theme_variants (bool): Whether this renderer has variants for the light/dark theme.
2122
media_type_name (str): Used for identifying the MediaType.
2223
priority (int): Render priority over other Preview classes.
2324
"""
2425

2526
has_small_variant: bool = False
27+
has_theme_variants: bool = False
2628
media_type_name: str
2729
priority: int = 50
2830

‎src/tagstudio/previews/file_renderer.py‎

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
# SPDX-License-Identifier: MIT
33

44

5-
import contextlib
65
import hashlib
76
import importlib
87
import inspect
@@ -20,7 +19,6 @@
2019
from tagstudio.core.media_types import MediaTypes
2120
from tagstudio.core.query_lang.file_groups import SEARCH
2221
from tagstudio.core.utils.decompression import decompressed, get_inner_ext, is_compressed
23-
from tagstudio.core.utils.types import unwrap
2422
from tagstudio.previews.base_preview import RENDER, BasePreview
2523
from tagstudio.qt.app_settings import (
2624
DEFAULT_CACHED_THUMB_RES,
@@ -125,19 +123,23 @@ def find_renderer(filepath: Path) -> type[BasePreview] | None:
125123
return renderer
126124

127125
@staticmethod
128-
def previously_failed(filepath: Path, date_modified: float | None = None) -> bool:
126+
def previously_failed(filepath: Path, theme: Theme, date_modified: float | None = None) -> bool:
129127
"""Return whether rendering a file failed before."""
130-
file_name = FileRenderer._cached_thumb_name(filepath, date_modified)
128+
file_name = FileRenderer._cached_thumb_name(filepath, theme, date_modified)
131129
return file_name in FileRenderer._failed_render_filenames
132130

133131
@staticmethod
134132
def cached_thumbnail(
135-
cache: CacheManager | None, filepath: Path, date_modified: float | None = None
133+
cache: CacheManager | None,
134+
filepath: Path,
135+
theme: Theme,
136+
date_modified: float | None = None,
136137
) -> Image.Image | None:
137138
"""Return a file's cached thumbnail, if it has one."""
138139
if not cache:
139140
return None
140-
cached_path = cache.get_file_path(FileRenderer._cached_thumb_name(filepath, date_modified))
141+
file_name = FileRenderer._cached_thumb_name(filepath, theme, date_modified)
142+
cached_path = cache.get_file_path(file_name)
141143
if not (cached_path and cached_path.is_file()):
142144
return None
143145
try:
@@ -149,15 +151,20 @@ def cached_thumbnail(
149151
return None
150152

151153
@staticmethod
152-
def _cached_thumb_name(filepath: Path, date_modified: float | None = None) -> Path:
154+
def _cached_thumb_name(
155+
filepath: Path, theme: Theme, date_modified: float | None = None
156+
) -> Path:
153157
"""Return the cache filename for a file's thumbnail.
154158
155159
Args:
156160
filepath (Path): The path of the file to check for a cached image of.
161+
theme (Theme): The light/dark theme, used if the file's renderer has theme variants.
157162
date_modified (float | None): The file's stored date modified timestamp, if it has one.
158163
"""
159164
mod_time: str = "" if date_modified is None else str(date_modified)
160-
hashable_str: str = f"{str(filepath)}{mod_time}"
165+
renderer = FileRenderer.find_renderer(filepath)
166+
theme_name = theme.name if renderer is not None and renderer.has_theme_variants else ""
167+
hashable_str: str = f"{str(filepath)}{mod_time}{theme_name}"
161168
hash_value = hashlib.shake_128(hashable_str.encode("utf-8")).hexdigest(8)
162169
return Path(f"{hash_value}{FileRenderer._cached_img_ext}")
163170

@@ -187,10 +194,9 @@ def render(
187194

188195
is_unlinked = not filepath.exists()
189196
is_ignored = False
190-
with contextlib.suppress(TypeError):
191-
is_ignored = Ignore.matcher.is_ignored(
192-
filepath.relative_to(unwrap(self.lib.library_dir))
193-
)
197+
library_dir = self.lib.library_dir
198+
if library_dir is not None and filepath.is_relative_to(library_dir):
199+
is_ignored = Ignore.matcher.is_ignored(filepath.relative_to(library_dir))
194200

195201
if is_thumb:
196202
image = self._get_thumbnail(cache, filepath, size, dpi_scale, theme, date_modified)
@@ -219,7 +225,7 @@ def _get_thumbnail(
219225
theme (Theme): A theme enum to determine the light/dark theme.
220226
date_modified (float | None): The date modified timestamp of the file. Used for caches.
221227
"""
222-
image = FileRenderer.cached_thumbnail(cache, filepath, date_modified)
228+
image = FileRenderer.cached_thumbnail(cache, filepath, theme, date_modified)
223229
if not image and self.settings.generate_thumbs:
224230
res = self.settings.cached_thumb_resolution
225231
if not MIN_CACHED_THUMB_RES <= res <= MAX_CACHED_THUMB_RES:
@@ -254,7 +260,7 @@ def _render(
254260
is_small (bool): Is this specifically a thumbnail? Use for specifying small variants.
255261
cache (CacheManager | None): A cache manager to save the rendered image to, if any.
256262
"""
257-
file_name = FileRenderer._cached_thumb_name(filepath, date_modified)
263+
file_name = FileRenderer._cached_thumb_name(filepath, theme, date_modified)
258264
scaled_size = math.ceil(max(size) * dpi_scale)
259265
image: Image.Image | None = None
260266

‎src/tagstudio/previews/media_probe.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def probe_file(filepath: Path) -> dict[str, Any] | None:
3333
@lru_cache(maxsize=256)
3434
def _cached_probe(filepath: Path, _mod_time: float) -> dict[str, Any] | None:
3535
try:
36-
return probe(filepath)
36+
return probe(filepath, timeout=10)
3737
except ffmpeg.Error:
3838
return None
3939

‎src/tagstudio/previews/renderers/archive.py‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def render(
5454
size: tuple[int, int],
5555
dpi_scale: float,
5656
) -> Image | None:
57-
return archive_thumb(filepath)
57+
return archive_thumb(filepath, size=size)
5858

5959

6060
class SevenZipFile(py7zr.SevenZipFile):
@@ -123,11 +123,12 @@ def open_archive(filepath: Path) -> Archive:
123123
return archiver(filepath, "r")
124124

125125

126-
def first_image_in_archive(archive: Archive) -> Image | None:
126+
def first_image_in_archive(archive: Archive, size: tuple[int, int] | None = None) -> Image | None:
127127
"""Find and extract the first renderable image in the archive.
128128
129129
Args:
130130
archive (Archive): The current archive.
131+
size (tuple[int, int] | None): The size of the thumbnail, if known.
131132
132133
Returns:
133134
Image: The first renderable image in the archive.
@@ -137,21 +138,22 @@ def first_image_in_archive(archive: Archive) -> Image | None:
137138
ext = archive_path.suffix.lower() if archive_path.suffix else archive_path.stem
138139
if MediaTypes.image_raster.contains(ext, RENDER):
139140
image_data = archive.read(file_name) # pyright: ignore[reportUnknownVariableType]
140-
return image_from_bytes(BytesIO(image_data))
141+
return image_from_bytes(BytesIO(image_data), min_size=size)
141142

142143
return None
143144

144145

145146
def archive_thumb(
146147
filepath: Path,
147148
image_names: list[Path] | list[str] | None = None,
149+
size: tuple[int, int] | None = None,
148150
) -> Image | None:
149151
"""Extract an embedded preview image from an archive.
150152
151153
Args:
152154
filepath (Path): The path to the archive.
153155
image_names: (list[Path] | list[str] | None): List of embedded image names to search for.
154-
ext (str): The file extension. Used to help determine more specific archive type.
156+
size (tuple[int, int] | None): The size of the thumbnail, if known.
155157
156158
Returns:
157159
Image: The first image found in the archive.
@@ -160,16 +162,15 @@ def archive_thumb(
160162
with open_archive(filepath) as archive:
161163
# If no list of image names to search for was provided, default to the first image.
162164
if not image_names:
163-
return first_image_in_archive(archive)
165+
return first_image_in_archive(archive, size)
164166

165167
for image_name in image_names:
166168
if image_name in archive.namelist():
167169
file_data = archive.read(str(image_name)) # pyright: ignore[reportUnknownVariableType]
168-
return image_from_bytes(BytesIO(file_data))
170+
return image_from_bytes(BytesIO(file_data), min_size=size)
169171

170172
# If no images were found with the given names, fallback to the first image found.
171-
if not image_names:
172-
return first_image_in_archive(archive)
173+
return first_image_in_archive(archive, size)
173174

174175
except Exception as e:
175176
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)

‎src/tagstudio/previews/renderers/audio.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
class AudioPreview(BasePreview):
3434
media_type_name = "audio"
35+
has_theme_variants = True
3536
priority = 70
3637

3738
@override

‎src/tagstudio/previews/renderers/ebook.py‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,15 @@ def render(
5454
size: tuple[int, int],
5555
dpi_scale: float,
5656
) -> Image | None:
57-
return epub_thumb(filepath)
57+
return epub_thumb(filepath, size)
5858

5959

60-
def epub_thumb(filepath: Path) -> Image | None:
60+
def epub_thumb(filepath: Path, size: tuple[int, int] | None = None) -> Image | None:
6161
"""Extracts the cover specified by ComicInfo.xml or first image found in the ePub file.
6262
6363
Args:
6464
filepath (Path): The path to the ePub file.
65-
ext (str): The file extension.
65+
size (tuple[int, int] | None): The size of the thumbnail, if known.
6666
6767
Returns:
6868
Image: The cover specified in ComicInfo.xml,
@@ -73,25 +73,28 @@ def epub_thumb(filepath: Path) -> Image | None:
7373
with open_archive(filepath) as archive:
7474
if "ComicInfo.xml" in archive.namelist():
7575
comic_info = ET.fromstring(archive.read("ComicInfo.xml"))
76-
im = cover_from_comic_info(archive, comic_info, "FrontCover")
76+
im = cover_from_comic_info(archive, comic_info, "FrontCover", size)
7777
if not im:
78-
im = cover_from_comic_info(archive, comic_info, "InnerCover")
78+
im = cover_from_comic_info(archive, comic_info, "InnerCover", size)
7979

8080
if not im:
81-
im = first_image_in_archive(archive)
81+
im = first_image_in_archive(archive, size)
8282
except Exception as e:
8383
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
8484

8585
return im
8686

8787

88-
def cover_from_comic_info(archive: Archive, comic_info: Element, cover_type: str) -> Image | None:
88+
def cover_from_comic_info(
89+
archive: Archive, comic_info: Element, cover_type: str, size: tuple[int, int] | None = None
90+
) -> Image | None:
8991
"""Extract the cover specified in ComicInfo.xml.
9092
9193
Args:
9294
archive (Archive): The current ePub file.
9395
comic_info (Element): The parsed ComicInfo.xml.
9496
cover_type (str): The type of cover to load.
97+
size (tuple[int, int] | None): The size of the thumbnail, if known.
9598
9699
Returns:
97100
Image: The cover specified in ComicInfo.xml.
@@ -105,6 +108,6 @@ def cover_from_comic_info(archive: Archive, comic_info: Element, cover_type: str
105108
ext = Path(page_name).suffix.lower()
106109
if MediaTypes.image_raster.contains(ext, RENDER):
107110
image_data = archive.read(page_name) # pyright: ignore[reportUnknownVariableType]
108-
im = image_from_bytes(BytesIO(image_data))
111+
im = image_from_bytes(BytesIO(image_data), min_size=size)
109112

110113
return im

‎src/tagstudio/previews/renderers/font.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
class FontPreview(BasePreview):
2727
media_type_name = "font"
2828
has_small_variant = True
29+
has_theme_variants = True
2930

3031
@override
3132
@classmethod

‎src/tagstudio/previews/renderers/raster_image.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,22 +65,23 @@ def render(
6565
size: tuple[int, int],
6666
dpi_scale: float,
6767
) -> Image | None:
68-
return raster_image_thumb(filepath)
68+
return raster_image_thumb(filepath, size)
6969

7070

71-
def raster_image_thumb(filepath: Path) -> Image | None:
71+
def raster_image_thumb(filepath: Path, size: tuple[int, int] | None = None) -> Image | None:
7272
"""Render a thumbnail for a standard image type.
7373
7474
Args:
7575
filepath (Path): The path of the file.
76+
size (tuple[int, int] | None): The size of the thumbnail, if known.
7677
"""
7778
im: Image | None = None
7879
try:
7980
if filepath.suffix.lower() == ".exr":
8081
return exr_image_thumb(filepath)
8182

8283
with filepath.open("rb") as file:
83-
im = image_from_bytes(BytesIO(file.read()))
84+
im = image_from_bytes(BytesIO(file.read()), min_size=size)
8485
except (
8586
DecompressionBombError,
8687
FileNotFoundError,
@@ -123,16 +124,20 @@ def exr_image_thumb(filepath: Path) -> Image | None:
123124
return im
124125

125126

126-
def image_from_bytes(image_data: BytesIO) -> Image:
127+
def image_from_bytes(image_data: BytesIO, min_size: tuple[int, int] | None = None) -> Image:
127128
"""Load a raster image and add a background if it's transparent.
128129
129130
Args:
130131
image_data (BytesIO): The binary image data.
132+
min_size (tuple[int, int] | None): The smallest size to decode the image at, if known.
133+
Only JPEGs can be decoded at a reduced size.
131134
132135
Returns:
133136
Image.Image: The loaded raster image, with a background if needed.
134137
"""
135138
im: Image = open_image(image_data)
139+
if min_size is not None:
140+
im.draft(None, min_size)
136141
if im.mode != "RGB" and im.mode != "RGBA":
137142
im = im.convert(mode="RGBA")
138143
if im.mode == "RGBA":

0 commit comments

Comments
 (0)