diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py
index 0fb06d485..bc987dfa0 100644
--- a/src/tagstudio/core/library/alchemy/enums.py
+++ b/src/tagstudio/core/library/alchemy/enums.py
@@ -106,7 +106,7 @@ def show_all(cls) -> BrowsingState:
@classmethod
def from_search_query(cls, search_query: str) -> BrowsingState:
- return cls(query=search_query)
+ return cls(query=search_query if search_query.strip() else None)
@classmethod
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:
return self._with_new_results(ascending=ascending)
def with_search_query(self, search_query: str) -> BrowsingState:
- return self._with_new_results(query=search_query)
+ return self._with_new_results(query=search_query if search_query.strip() else None)
def with_show_hidden_entries(self, show_hidden_entries: bool) -> BrowsingState:
return self._with_new_results(show_hidden_entries=show_hidden_entries)
diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py
index 068ee99a6..f2e4e33cb 100644
--- a/src/tagstudio/core/library/alchemy/library.py
+++ b/src/tagstudio/core/library/alchemy/library.py
@@ -993,14 +993,14 @@ def search_library(
start_time = time.time()
if page_size:
- rows = session.execute(statement).fetchall()
+ rows = session.connection().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))
+ ids = list(session.connection().execute(statement).scalars())
total_count = len(ids)
end_time = time.time()
logger.info(f"SQL Execution finished ({format_timespan(end_time - start_time)})")
diff --git a/src/tagstudio/previews/base_preview.py b/src/tagstudio/previews/base_preview.py
index 98c2fc851..6f4d32135 100644
--- a/src/tagstudio/previews/base_preview.py
+++ b/src/tagstudio/previews/base_preview.py
@@ -17,12 +17,14 @@ class BasePreview:
"""A base preview renderer class.
Attributes:
- has_small_variant (bool): Whether `is_small` renders a different image than the preview.
+ has_small_variant (bool): Whether this renderer has a small variant (for thumbnails).
+ has_theme_variants (bool): Whether this renderer has variants for the light/dark theme.
media_type_name (str): Used for identifying the MediaType.
priority (int): Render priority over other Preview classes.
"""
has_small_variant: bool = False
+ has_theme_variants: bool = False
media_type_name: str
priority: int = 50
diff --git a/src/tagstudio/previews/file_renderer.py b/src/tagstudio/previews/file_renderer.py
index 80af6dfb0..de9835f2d 100644
--- a/src/tagstudio/previews/file_renderer.py
+++ b/src/tagstudio/previews/file_renderer.py
@@ -2,7 +2,6 @@
# SPDX-License-Identifier: MIT
-import contextlib
import hashlib
import importlib
import inspect
@@ -20,7 +19,6 @@
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.query_lang.file_groups import SEARCH
from tagstudio.core.utils.decompression import decompressed, get_inner_ext, is_compressed
-from tagstudio.core.utils.types import unwrap
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.qt.app_settings import (
DEFAULT_CACHED_THUMB_RES,
@@ -125,19 +123,23 @@ def find_renderer(filepath: Path) -> type[BasePreview] | None:
return renderer
@staticmethod
- def previously_failed(filepath: Path, date_modified: float | None = None) -> bool:
+ def previously_failed(filepath: Path, theme: Theme, date_modified: float | None = None) -> bool:
"""Return whether rendering a file failed before."""
- file_name = FileRenderer._cached_thumb_name(filepath, date_modified)
+ file_name = FileRenderer._cached_thumb_name(filepath, theme, date_modified)
return file_name in FileRenderer._failed_render_filenames
@staticmethod
def cached_thumbnail(
- cache: CacheManager | None, filepath: Path, date_modified: float | None = None
+ cache: CacheManager | None,
+ filepath: Path,
+ theme: Theme,
+ date_modified: float | None = None,
) -> Image.Image | None:
"""Return a file's cached thumbnail, if it has one."""
if not cache:
return None
- cached_path = cache.get_file_path(FileRenderer._cached_thumb_name(filepath, date_modified))
+ file_name = FileRenderer._cached_thumb_name(filepath, theme, date_modified)
+ cached_path = cache.get_file_path(file_name)
if not (cached_path and cached_path.is_file()):
return None
try:
@@ -149,15 +151,20 @@ def cached_thumbnail(
return None
@staticmethod
- def _cached_thumb_name(filepath: Path, date_modified: float | None = None) -> Path:
+ def _cached_thumb_name(
+ filepath: Path, theme: Theme, date_modified: float | None = None
+ ) -> Path:
"""Return the cache filename for a file's thumbnail.
Args:
filepath (Path): The path of the file to check for a cached image of.
+ theme (Theme): The light/dark theme, used if the file's renderer has theme variants.
date_modified (float | None): The file's stored date modified timestamp, if it has one.
"""
mod_time: str = "" if date_modified is None else str(date_modified)
- hashable_str: str = f"{str(filepath)}{mod_time}"
+ renderer = FileRenderer.find_renderer(filepath)
+ theme_name = theme.name if renderer is not None and renderer.has_theme_variants else ""
+ hashable_str: str = f"{str(filepath)}{mod_time}{theme_name}"
hash_value = hashlib.shake_128(hashable_str.encode("utf-8")).hexdigest(8)
return Path(f"{hash_value}{FileRenderer._cached_img_ext}")
@@ -187,10 +194,9 @@ def render(
is_unlinked = not filepath.exists()
is_ignored = False
- with contextlib.suppress(TypeError):
- is_ignored = Ignore.matcher.is_ignored(
- filepath.relative_to(unwrap(self.lib.library_dir))
- )
+ library_dir = self.lib.library_dir
+ if library_dir is not None and filepath.is_relative_to(library_dir):
+ is_ignored = Ignore.matcher.is_ignored(filepath.relative_to(library_dir))
if is_thumb:
image = self._get_thumbnail(cache, filepath, size, dpi_scale, theme, date_modified)
@@ -219,7 +225,7 @@ def _get_thumbnail(
theme (Theme): A theme enum to determine the light/dark theme.
date_modified (float | None): The date modified timestamp of the file. Used for caches.
"""
- image = FileRenderer.cached_thumbnail(cache, filepath, date_modified)
+ image = FileRenderer.cached_thumbnail(cache, filepath, theme, date_modified)
if not image and self.settings.generate_thumbs:
res = self.settings.cached_thumb_resolution
if not MIN_CACHED_THUMB_RES <= res <= MAX_CACHED_THUMB_RES:
@@ -254,7 +260,7 @@ def _render(
is_small (bool): Is this specifically a thumbnail? Use for specifying small variants.
cache (CacheManager | None): A cache manager to save the rendered image to, if any.
"""
- file_name = FileRenderer._cached_thumb_name(filepath, date_modified)
+ file_name = FileRenderer._cached_thumb_name(filepath, theme, date_modified)
scaled_size = math.ceil(max(size) * dpi_scale)
image: Image.Image | None = None
diff --git a/src/tagstudio/previews/media_probe.py b/src/tagstudio/previews/media_probe.py
index b575d20ac..c3ae543de 100644
--- a/src/tagstudio/previews/media_probe.py
+++ b/src/tagstudio/previews/media_probe.py
@@ -33,7 +33,7 @@ def probe_file(filepath: Path) -> dict[str, Any] | None:
@lru_cache(maxsize=256)
def _cached_probe(filepath: Path, _mod_time: float) -> dict[str, Any] | None:
try:
- return probe(filepath)
+ return probe(filepath, timeout=10)
except ffmpeg.Error:
return None
diff --git a/src/tagstudio/previews/renderers/archive.py b/src/tagstudio/previews/renderers/archive.py
index 1f1b612cf..b6c798314 100644
--- a/src/tagstudio/previews/renderers/archive.py
+++ b/src/tagstudio/previews/renderers/archive.py
@@ -54,7 +54,7 @@ def render(
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
- return archive_thumb(filepath)
+ return archive_thumb(filepath, size=size)
class SevenZipFile(py7zr.SevenZipFile):
@@ -123,11 +123,12 @@ def open_archive(filepath: Path) -> Archive:
return archiver(filepath, "r")
-def first_image_in_archive(archive: Archive) -> Image | None:
+def first_image_in_archive(archive: Archive, size: tuple[int, int] | None = None) -> Image | None:
"""Find and extract the first renderable image in the archive.
Args:
archive (Archive): The current archive.
+ size (tuple[int, int] | None): The size of the thumbnail, if known.
Returns:
Image: The first renderable image in the archive.
@@ -137,7 +138,7 @@ def first_image_in_archive(archive: Archive) -> Image | None:
ext = archive_path.suffix.lower() if archive_path.suffix else archive_path.stem
if MediaTypes.image_raster.contains(ext, RENDER):
image_data = archive.read(file_name) # pyright: ignore[reportUnknownVariableType]
- return image_from_bytes(BytesIO(image_data))
+ return image_from_bytes(BytesIO(image_data), min_size=size)
return None
@@ -145,13 +146,14 @@ def first_image_in_archive(archive: Archive) -> Image | None:
def archive_thumb(
filepath: Path,
image_names: list[Path] | list[str] | None = None,
+ size: tuple[int, int] | None = None,
) -> Image | None:
"""Extract an embedded preview image from an archive.
Args:
filepath (Path): The path to the archive.
image_names: (list[Path] | list[str] | None): List of embedded image names to search for.
- ext (str): The file extension. Used to help determine more specific archive type.
+ size (tuple[int, int] | None): The size of the thumbnail, if known.
Returns:
Image: The first image found in the archive.
@@ -160,16 +162,15 @@ def archive_thumb(
with open_archive(filepath) as archive:
# If no list of image names to search for was provided, default to the first image.
if not image_names:
- return first_image_in_archive(archive)
+ return first_image_in_archive(archive, size)
for image_name in image_names:
if image_name in archive.namelist():
file_data = archive.read(str(image_name)) # pyright: ignore[reportUnknownVariableType]
- return image_from_bytes(BytesIO(file_data))
+ return image_from_bytes(BytesIO(file_data), min_size=size)
# If no images were found with the given names, fallback to the first image found.
- if not image_names:
- return first_image_in_archive(archive)
+ return first_image_in_archive(archive, size)
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
diff --git a/src/tagstudio/previews/renderers/audio.py b/src/tagstudio/previews/renderers/audio.py
index a243e047b..2ff644ae0 100644
--- a/src/tagstudio/previews/renderers/audio.py
+++ b/src/tagstudio/previews/renderers/audio.py
@@ -32,6 +32,7 @@
class AudioPreview(BasePreview):
media_type_name = "audio"
+ has_theme_variants = True
priority = 70
@override
diff --git a/src/tagstudio/previews/renderers/ebook.py b/src/tagstudio/previews/renderers/ebook.py
index 8e1834e8e..51cae577c 100644
--- a/src/tagstudio/previews/renderers/ebook.py
+++ b/src/tagstudio/previews/renderers/ebook.py
@@ -54,15 +54,15 @@ def render(
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
- return epub_thumb(filepath)
+ return epub_thumb(filepath, size)
-def epub_thumb(filepath: Path) -> Image | None:
+def epub_thumb(filepath: Path, size: tuple[int, int] | None = None) -> Image | None:
"""Extracts the cover specified by ComicInfo.xml or first image found in the ePub file.
Args:
filepath (Path): The path to the ePub file.
- ext (str): The file extension.
+ size (tuple[int, int] | None): The size of the thumbnail, if known.
Returns:
Image: The cover specified in ComicInfo.xml,
@@ -73,25 +73,28 @@ def epub_thumb(filepath: Path) -> Image | None:
with open_archive(filepath) as archive:
if "ComicInfo.xml" in archive.namelist():
comic_info = ET.fromstring(archive.read("ComicInfo.xml"))
- im = cover_from_comic_info(archive, comic_info, "FrontCover")
+ im = cover_from_comic_info(archive, comic_info, "FrontCover", size)
if not im:
- im = cover_from_comic_info(archive, comic_info, "InnerCover")
+ im = cover_from_comic_info(archive, comic_info, "InnerCover", size)
if not im:
- im = first_image_in_archive(archive)
+ im = first_image_in_archive(archive, size)
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
-def cover_from_comic_info(archive: Archive, comic_info: Element, cover_type: str) -> Image | None:
+def cover_from_comic_info(
+ archive: Archive, comic_info: Element, cover_type: str, size: tuple[int, int] | None = None
+) -> Image | None:
"""Extract the cover specified in ComicInfo.xml.
Args:
archive (Archive): The current ePub file.
comic_info (Element): The parsed ComicInfo.xml.
cover_type (str): The type of cover to load.
+ size (tuple[int, int] | None): The size of the thumbnail, if known.
Returns:
Image: The cover specified in ComicInfo.xml.
@@ -105,6 +108,6 @@ def cover_from_comic_info(archive: Archive, comic_info: Element, cover_type: str
ext = Path(page_name).suffix.lower()
if MediaTypes.image_raster.contains(ext, RENDER):
image_data = archive.read(page_name) # pyright: ignore[reportUnknownVariableType]
- im = image_from_bytes(BytesIO(image_data))
+ im = image_from_bytes(BytesIO(image_data), min_size=size)
return im
diff --git a/src/tagstudio/previews/renderers/font.py b/src/tagstudio/previews/renderers/font.py
index f79c49491..c502e46d4 100644
--- a/src/tagstudio/previews/renderers/font.py
+++ b/src/tagstudio/previews/renderers/font.py
@@ -26,6 +26,7 @@
class FontPreview(BasePreview):
media_type_name = "font"
has_small_variant = True
+ has_theme_variants = True
@override
@classmethod
diff --git a/src/tagstudio/previews/renderers/raster_image.py b/src/tagstudio/previews/renderers/raster_image.py
index 4e7eea97e..f13ffcd59 100644
--- a/src/tagstudio/previews/renderers/raster_image.py
+++ b/src/tagstudio/previews/renderers/raster_image.py
@@ -65,14 +65,15 @@ def render(
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
- return raster_image_thumb(filepath)
+ return raster_image_thumb(filepath, size)
-def raster_image_thumb(filepath: Path) -> Image | None:
+def raster_image_thumb(filepath: Path, size: tuple[int, int] | None = None) -> Image | None:
"""Render a thumbnail for a standard image type.
Args:
filepath (Path): The path of the file.
+ size (tuple[int, int] | None): The size of the thumbnail, if known.
"""
im: Image | None = None
try:
@@ -80,7 +81,7 @@ def raster_image_thumb(filepath: Path) -> Image | None:
return exr_image_thumb(filepath)
with filepath.open("rb") as file:
- im = image_from_bytes(BytesIO(file.read()))
+ im = image_from_bytes(BytesIO(file.read()), min_size=size)
except (
DecompressionBombError,
FileNotFoundError,
@@ -123,16 +124,20 @@ def exr_image_thumb(filepath: Path) -> Image | None:
return im
-def image_from_bytes(image_data: BytesIO) -> Image:
+def image_from_bytes(image_data: BytesIO, min_size: tuple[int, int] | None = None) -> Image:
"""Load a raster image and add a background if it's transparent.
Args:
image_data (BytesIO): The binary image data.
+ min_size (tuple[int, int] | None): The smallest size to decode the image at, if known.
+ Only JPEGs can be decoded at a reduced size.
Returns:
Image.Image: The loaded raster image, with a background if needed.
"""
im: Image = open_image(image_data)
+ if min_size is not None:
+ im.draft(None, min_size)
if im.mode != "RGB" and im.mode != "RGBA":
im = im.convert(mode="RGBA")
if im.mode == "RGBA":
diff --git a/src/tagstudio/previews/renderers/text.py b/src/tagstudio/previews/renderers/text.py
index 715f2b19b..8dc0339f4 100644
--- a/src/tagstudio/previews/renderers/text.py
+++ b/src/tagstudio/previews/renderers/text.py
@@ -42,6 +42,7 @@
class TextPreview(BasePreview):
media_type_name = "plaintext"
+ has_theme_variants = True
font = ImageFont.load_default(20)
@override
diff --git a/src/tagstudio/previews/vendored/probe.py b/src/tagstudio/previews/vendored/probe.py
index e03b260bf..217a6fc33 100644
--- a/src/tagstudio/previews/vendored/probe.py
+++ b/src/tagstudio/previews/vendored/probe.py
@@ -23,8 +23,8 @@ def probe(filename: Path | str, timeout: int | None = None, **kwargs: ...):
"""Run ffprobe on the specified file and return a JSON representation of the output.
Raises:
- Error: If ffprobe returns a non-zero exit code, an Error is raised
- with a generic error message. The stderr output can be retrieved
+ Error: If ffprobe returns a non-zero exit code or runs longer than `timeout` seconds,
+ an Error is raised with a generic error message. The stderr output can be retrieved
by accessing the `stderr` property of the exception.
"""
ffprobe_cmd: str | None = FfprobeStatus.which()
@@ -36,10 +36,12 @@ def probe(filename: Path | str, timeout: int | None = None, **kwargs: ...):
# PATCHED
p = silent_popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=False)
- communicate_kwargs = {}
- if timeout is not None:
- communicate_kwargs["timeout"] = timeout
- out, err = p.communicate(**communicate_kwargs)
+ try:
+ out, err = p.communicate(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ logger.warning("[Probe] ffprobe timed out", filename=filename, timeout=timeout)
+ p.kill()
+ out, err = p.communicate()
if p.returncode != 0:
raise ffmpeg.Error("ffprobe", out, err)
return json.loads(out.decode("utf-8"))
diff --git a/src/tagstudio/qt/controllers/banner.py b/src/tagstudio/qt/controllers/banner.py
index 8b1bb9f07..b28a65258 100644
--- a/src/tagstudio/qt/controllers/banner.py
+++ b/src/tagstudio/qt/controllers/banner.py
@@ -9,7 +9,6 @@
from PySide6.QtCore import (
QEasingCurve,
- QPropertyAnimation,
QRectF,
Qt,
QTimer,
@@ -115,10 +114,10 @@ def __init__(self, parent: QWidget | None = None):
self._connect_callbacks()
self._set_mode("notice")
- self._height_anim = QPropertyAnimation(self, b"maximumHeight", self)
+ self._height_anim = QVariantAnimation(self)
self._height_anim.setDuration(self.ANIMATION_MS)
self._height_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
- self._height_anim.valueChanged.connect(self.setMinimumHeight)
+ self._height_anim.valueChanged.connect(lambda height: self.setFixedHeight(round(height)))
self._height_anim.finished.connect(self._on_height_anim_finished)
self._shown_at: float | None = None
@@ -191,7 +190,7 @@ def _enter_stage(self, stage: Enum) -> None:
def _is_fully_open(self) -> bool:
return (
self.maximumHeight() == self.HEIGHT
- and self._height_anim.state() != QPropertyAnimation.State.Running
+ and self._height_anim.state() != QVariantAnimation.State.Running
)
def _connect_callbacks(self) -> None:
@@ -211,14 +210,38 @@ def _on_height_anim_finished(self) -> None:
self._shown_at = time.monotonic()
def _start_height_animation(self, target_height: int) -> None:
- if self.maximumHeight() == target_height and self._height_anim.state() != (
- QPropertyAnimation.State.Running
- ):
+ is_running = self._height_anim.state() == QVariantAnimation.State.Running
+ if is_running and self._height_anim.endValue() == target_height:
+ return
+ if self.maximumHeight() == target_height and not is_running:
return
+ is_opening = self.maximumHeight() == 0 and target_height > 0
self._height_anim.stop()
- self._height_anim.setStartValue(self.maximumHeight())
- self._height_anim.setEndValue(target_height)
+ self._height_anim.setStartValue(float(self.maximumHeight()))
+ self._height_anim.setEndValue(float(target_height))
self._height_anim.start()
+ if is_opening and (closing := self._closing_sibling()) is not None:
+ # Open in step with the closing banner so their heights add up to one banner.
+ self._height_anim.setCurrentTime(closing._height_anim.currentTime())
+
+ def _closing_sibling(self) -> Banner | None:
+ """Another banner in the same stack that's animating out, if any.
+
+ Used to sync up another banner's opening animation so they keep a constant height together.
+ """
+ parent = self.parentWidget()
+ if parent is None:
+ return None
+ for sibling in parent.findChildren(
+ Banner, options=Qt.FindChildOption.FindDirectChildrenOnly
+ ):
+ if (
+ sibling is not self
+ and sibling._height_anim.state() == QVariantAnimation.State.Running
+ and sibling._height_anim.endValue() == 0
+ ):
+ return sibling
+ return None
def _animate_to(self, target_height: int, force: bool = False) -> None:
self._hide_timer.stop()
diff --git a/src/tagstudio/qt/controllers/banner_stack.py b/src/tagstudio/qt/controllers/banner_stack.py
index e0afda3f3..71786412a 100644
--- a/src/tagstudio/qt/controllers/banner_stack.py
+++ b/src/tagstudio/qt/controllers/banner_stack.py
@@ -43,6 +43,14 @@ def push(self, banner: Banner, key: Hashable | None = None) -> None:
if key is not None:
self._by_key[key] = banner
+ def clear(self) -> None:
+ """Remove all banners without animating them out."""
+ self._by_key.clear()
+ while (item := self.layout().takeAt(0)) is not None:
+ if (banner := item.widget()) is not None:
+ banner.hide()
+ banner.deleteLater()
+
def _on_banner_closed(self, banner: Banner) -> None:
if banner.stack_key is not None and self._by_key.get(banner.stack_key) is banner:
del self._by_key[banner.stack_key]
diff --git a/src/tagstudio/qt/controllers/item_thumb.py b/src/tagstudio/qt/controllers/item_thumb.py
index 8b77e3a76..157d7df47 100644
--- a/src/tagstudio/qt/controllers/item_thumb.py
+++ b/src/tagstudio/qt/controllers/item_thumb.py
@@ -2,11 +2,23 @@
# SPDX-License-Identifier: GPL-3.0-only
+from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, override
import structlog
-from PySide6.QtCore import QEvent, QMimeData, QPoint, QRect, QRectF, QSignalBlocker, Qt, QUrl
+from PySide6.QtCore import (
+ QAbstractAnimation,
+ QEvent,
+ QMimeData,
+ QPoint,
+ QRect,
+ QRectF,
+ QSignalBlocker,
+ Qt,
+ QUrl,
+ QVariantAnimation,
+)
from PySide6.QtGui import (
QBrush,
QColor,
@@ -49,10 +61,28 @@
logger = structlog.get_logger(__name__)
+_DARK_SHADOW = QColor(0, 0, 0, 255)
+_LIGHT_SHADOW = QColor(0, 0, 0, 115)
+
+
+@lru_cache(maxsize=16)
+def _rounded_path(width: float, height: float, inset: float, edge_width: float = 0) -> QPainterPath:
+ """A thumbnail's rounded rect shrunk by `inset`, or only its `edge_width` edge."""
+ rect = QRectF(0, 0, width, height).adjusted(inset, inset, -inset, -inset)
+ path = QPainterPath()
+ path.addRoundedRect(rect, RADIUS - inset, RADIUS - inset)
+ if edge_width:
+ inner = rect.adjusted(edge_width, edge_width, -edge_width, -edge_width)
+ inner_radius = RADIUS - inset - edge_width
+ path.addRoundedRect(inner, inner_radius, inner_radius)
+ return path
+
class ItemThumb(QWidget):
"""The thumbnail widget for a library item (Entry, Entry Group, etc.)."""
+ _FADE_IN_MS = 75 # Fade in time for thumbnails from the loading image
+
def __init__(
self,
mode: ItemType | None,
@@ -70,8 +100,13 @@ def __init__(
self._select_color = Palette.accent_alt()
self._select_color_faded = item_thumb_faded_select_color()
+ self._fade = QVariantAnimation(self)
+ self._fade.setDuration(self._FADE_IN_MS)
+ self._fade.setStartValue(0.0)
+ self._fade.setEndValue(1.0)
+ self._fade_from_brush: QBrush | None = None
self._image_hovered: bool = False
- self._image: QPixmap | None = None
+ self._image_brush: QBrush | None = None
self._item_path: Path | None = None
self._mode: ItemType | None = mode
self._press_pos: QPoint | None = None
@@ -99,6 +134,7 @@ def _connect_callbacks(self) -> None:
view.open_file_action.triggered.connect(self._opener.open_file)
view.open_explorer_action.triggered.connect(self._opener.open_explorer)
view.delete_action.triggered.connect(self._on_delete)
+ self._fade.valueChanged.connect(lambda _: self.update())
for tag_id, badge in view.badges.items():
badge.stateChanged.connect(lambda _, t=tag_id: self._on_badge_check(t))
@@ -206,8 +242,18 @@ def set_duration(self, duration: float | None) -> None:
def set_filename_text(self, filename: Path):
self.layout().file_label.setText(str(filename.name))
+ self._apply_filename_style()
self._apply_label_sizes()
+ def _apply_filename_style(self) -> None:
+ """Style the filename label as a blank placeholder while it's loading."""
+ file_label = self.layout().file_label
+ is_placeholder = not file_label.text() and self.rendered_path == Path()
+ style = item_thumb_filename_style(is_selected=self._selected, is_placeholder=is_placeholder)
+ if style != file_label.styleSheet():
+ file_label.setStyleSheet(style)
+ file_label.setMinimumWidth(self._thumb_size[0] if is_placeholder else 0)
+
def _apply_label_sizes(self) -> None:
self.layout().activate()
@@ -234,18 +280,45 @@ def set_filename_visibility(self, set_visible: bool):
def set_selected(self, value: bool) -> None:
if value != self._selected:
self._selected = value
- self.layout().file_label.setStyleSheet(item_thumb_filename_style(is_selected=value))
+ self._apply_filename_style()
self.update()
def set_thumb_size(self, size: int) -> None:
self._thumb_size = (size, size)
self.setFixedSize(size, size)
+ if self._image_brush is not None:
+ self._image_brush = self._scaled_image_brush(self._image_brush.texture())
+ self._apply_filename_style()
self.set_filename_visibility(self._show_filename_label)
self.update()
def set_image(self, image: QPixmap, file_path: Path) -> None:
- self._image = image
+ if self.rendered_path == Path() and file_path != Path():
+ self._fade_from_brush = self._image_brush
+ self._fade.start()
+ else:
+ self._fade.stop()
+ self._image_brush = self._scaled_image_brush(image)
self.rendered_path = file_path
+ self._apply_filename_style()
+ self.update()
+
+ def _scaled_image_brush(self, image: QPixmap) -> QBrush:
+ """A brush that paints an image scaled to the thumbnail size."""
+ image_size = image.deviceIndependentSize()
+ brush = QBrush(image)
+ brush.setTransform(
+ QTransform.fromScale(
+ self._thumb_size[0] / image_size.width(), self._thumb_size[1] / image_size.height()
+ )
+ )
+ return brush
+
+ def clear_image(self) -> None:
+ self._fade.stop()
+ self._image_brush = None
+ self.rendered_path = None
+ self._apply_filename_style()
self.update()
def set_item(self, entry: Entry):
@@ -301,19 +374,19 @@ def paintEvent(self, event: QPaintEvent) -> None:
return
painter = QPainter(self)
- rect = self._image_rect()
- if self._image is not None:
- self._paint_decorated_image(painter, QRectF(rect))
+ if self._image_brush is not None:
+ if (
+ self._fade_from_brush is not None
+ and self._fade.state() == QAbstractAnimation.State.Running
+ ):
+ self._paint_decorated_image(painter, self._fade_from_brush)
+ painter.setOpacity(float(self._fade.currentValue()))
+ self._paint_decorated_image(painter, self._image_brush)
+ painter.setOpacity(1)
if self._image_hovered or self._selected:
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
- inset = OUTLINE_WIDTH / 2
- outline = QPainterPath()
- outline.addRoundedRect(
- QRectF(rect).adjusted(inset, inset, -inset, -inset),
- RADIUS - inset,
- RADIUS - inset,
- )
+ outline = _rounded_path(*self._thumb_size, OUTLINE_WIDTH / 2)
color: QColor = self._hover_color
if self._selected:
@@ -330,37 +403,26 @@ def paintEvent(self, event: QPaintEvent) -> None:
painter.end()
- def _paint_decorated_image(self, painter: QPainter, rect: QRectF) -> None:
- """Paint the image with rounded corners and a raised edge decoration."""
- image = unwrap(self._image)
- image_size = image.deviceIndependentSize()
- brush = QBrush(image)
- brush.setTransform(
- QTransform.fromScale(
- rect.width() / image_size.width(), rect.height() / image_size.height()
- )
- )
+ def _paint_decorated_image(self, painter: QPainter, brush: QBrush) -> None:
+ """Paint an image with rounded corners and edge decorations."""
+ opacity = painter.opacity()
+ width, height = self._thumb_size
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(brush)
- painter.drawRoundedRect(rect.adjusted(1, 1, -1, -1), RADIUS - 1, RADIUS - 1)
+ painter.drawPath(_rounded_path(width, height, 1))
- highlight = QPainterPath()
- highlight.addRoundedRect(rect.adjusted(1, 1, -1, -1), RADIUS - 1, RADIUS - 1)
- highlight.addRoundedRect(rect.adjusted(2, 2, -2, -2), RADIUS - 2, RADIUS - 2)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Screen)
- painter.setOpacity(0.875)
- painter.fillPath(highlight, brush)
- painter.setOpacity(1)
+ painter.setOpacity(opacity * 0.875)
+ painter.fillPath(_rounded_path(width, height, 1, edge_width=1), brush)
+ painter.setOpacity(opacity)
- shadow = QPainterPath()
- shadow.addRoundedRect(rect, RADIUS, RADIUS)
- shadow.addRoundedRect(rect.adjusted(1, 1, -1, -1), RADIUS - 1, RADIUS - 1)
+ shadow = _rounded_path(width, height, 0, edge_width=1)
is_dark = QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
- painter.fillPath(shadow, QColor(0, 0, 0, 255 if is_dark else 115))
+ painter.fillPath(shadow, _DARK_SHADOW if is_dark else _LIGHT_SHADOW)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Multiply)
painter.fillPath(shadow, brush)
painter.restore()
diff --git a/src/tagstudio/qt/controllers/thumb_grid.py b/src/tagstudio/qt/controllers/thumb_grid.py
index 5ae22a335..b70d72004 100644
--- a/src/tagstudio/qt/controllers/thumb_grid.py
+++ b/src/tagstudio/qt/controllers/thumb_grid.py
@@ -7,8 +7,8 @@
from typing import TYPE_CHECKING, override
import structlog
-from PySide6.QtCore import QThreadPool, QTimer, Signal
-from PySide6.QtGui import QColor, QPixmap
+from PySide6.QtCore import Qt, QThreadPool, QTimer, Signal
+from PySide6.QtGui import QColor, QMouseEvent, QPixmap
from PySide6.QtWidgets import QScrollArea, QWidget
from tagstudio.core.library.alchemy.enums import ItemType
@@ -28,12 +28,13 @@
class ThumbGrid(QWidget):
"""A scrollable grid of item thumbnails."""
- DURATION_DELAY_MS = 200 # Allows for fast scrolling without getting lagged by duration labels
- RESIZE_RENDER_DELAY_MS = 200 # Allows for more responsive rapid resizing
+ DURATION_DELAY_MS = 200 # Allows fast scrolling without getting lagged by duration labels
+ RESIZE_RENDER_DELAY_MS = 200 # Allows more responsive rapid resizing
+ SHOW_THUMBS_DELAY_MS = 200 # Allows quick renders to appear together without a loading flash
- visible_changed = Signal(int) # ID of the first visible entry
- _entries_fetched = Signal(int, object, object)
_duration_loaded = Signal(Path, object)
+ _entries_fetched = Signal(int, object, object)
+ visible_changed = Signal(int) # ID of the first visible entry
def __init__(self, driver: QtDriver, scroll_area: QScrollArea) -> None:
super().__init__()
@@ -44,6 +45,14 @@ def __init__(self, driver: QtDriver, scroll_area: QScrollArea) -> None:
self._duration_timer = QTimer(self)
self._duration_timer.setInterval(self.DURATION_DELAY_MS)
self._duration_timer.setSingleShot(True)
+ self._resize_render_timer = QTimer(self)
+ self._resize_render_timer.setInterval(self.RESIZE_RENDER_DELAY_MS)
+ self._resize_render_timer.setSingleShot(True)
+ self._show_thumbs_timer = QTimer(self)
+ self._show_thumbs_timer.setInterval(self.SHOW_THUMBS_DELAY_MS)
+ self._show_thumbs_timer.setSingleShot(True)
+
+ self._changed_while_fetching: set[int] = set()
self._durations: dict[Path, float | None] = {}
self._entries: dict[int, Entry] = {}
self._entry_ids: list[int] = []
@@ -51,16 +60,14 @@ def __init__(self, driver: QtDriver, scroll_area: QScrollArea) -> None:
self._entry_paths: dict[Path, int] = {}
self._fetch_pool: QThreadPool | None = None
self._fetching: set[int] = set()
- self._changed_while_fetching: set[int] = set()
self._generation = 0
+ self._held: set[ItemThumb] = set()
+ self._last_entries_time = 0.0
self._loading_image: QPixmap | None = None
self._pending_durations: set[Path] = set()
self._previous_size_renders: dict[Path, QPixmap] = {}
self._render_cutoff = 0.0
self._renders: dict[Path, QPixmap | None] = {}
- self._resize_render_timer = QTimer(self)
- self._resize_render_timer.setInterval(self.RESIZE_RENDER_DELAY_MS)
- self._resize_render_timer.setSingleShot(True)
self._scroll_to: int | None = None
self.setLayout(ThumbGridLayout(scroll_area, self._create_thumb))
@@ -70,14 +77,28 @@ def __init__(self, driver: QtDriver, scroll_area: QScrollArea) -> None:
def layout(self) -> ThumbGridLayout[ItemThumb]:
return super().layout() # pyright: ignore[reportReturnType]
+ @override
+ def mousePressEvent(self, event: QMouseEvent) -> None:
+ # Clear selected entries when clicking the background
+ if (
+ event.button() == Qt.MouseButton.LeftButton
+ and event.modifiers() == Qt.KeyboardModifier.NoModifier
+ and isinstance(self.childAt(event.position().toPoint()), ItemThumb | None)
+ and self._driver.selected
+ ):
+ self._driver.clear_select_action_callback()
+ super().mousePressEvent(event)
+
def _connect_callbacks(self, scroll_area: QScrollArea) -> None:
scroll_area.verticalScrollBar().valueChanged.connect(lambda _: self.layout().update())
self.layout().items_assigned.connect(self._on_items_assigned)
+ self.layout().items_unassigned.connect(self._on_items_unassigned)
self.layout().first_visible_changed.connect(
lambda index: self.visible_changed.emit(self._entry_ids[index])
)
self._renderer.updated.connect(self._on_rendered)
self._resize_render_timer.timeout.connect(self._request_renders)
+ self._show_thumbs_timer.timeout.connect(self._show_held_entries)
self._duration_timer.timeout.connect(self._queue_durations)
self._entries_fetched.connect(self._on_entries_fetched)
self._duration_loaded.connect(self._on_duration_loaded)
@@ -95,6 +116,18 @@ def set_entries(self, entry_ids: list[int]) -> None:
self._driver.entry_state.unload_badges()
self._previous_size_renders.clear()
self._reset_renders()
+ if self._fetch_pool is not None:
+ self._fetch_pool.clear()
+ now = time.monotonic()
+ if now - self._last_entries_time < self.SHOW_THUMBS_DELAY_MS / 1000:
+ # Show entries immediately if the results are changing quickly.
+ # Traversing pages or searching quickly feels much more responsive.
+ self._show_thumbs_timer.stop()
+ else:
+ # Use the delay to wait and show ready thumbnails all at once.
+ # Prevents a weird "popcorn"/"waterfall" effect and overall looks much nicer.
+ self._show_thumbs_timer.start()
+ self._last_entries_time = now
self.layout().set_item_count(len(entry_ids))
index = None if self._scroll_to is None else self._entry_indices.get(self._scroll_to)
@@ -110,6 +143,7 @@ def set_thumb_size(self, size: int) -> None:
(path, image) for path, image in self._renders.items() if image is not None
)
self._reset_renders()
+ self._show_loading_images()
self._resize_render_timer.start()
first_visible = self.layout().first_visible_index()
for thumb in self.layout().widgets():
@@ -125,7 +159,8 @@ def set_filenames_visible(self, visible: bool) -> None:
def update_selected(self) -> None:
for _, thumb in self.layout().assigned_items():
- thumb.set_selected(thumb.item_id in self._driver._selected) # pyright: ignore[reportPrivateUsage]
+ if thumb not in self._held:
+ thumb.set_selected(thumb.item_id in self._driver._selected) # pyright: ignore[reportPrivateUsage]
def _create_thumb(self) -> ItemThumb:
size = self._driver.main_window.thumb_size
@@ -149,25 +184,53 @@ def _on_items_assigned(self, items: list[tuple[int, ItemThumb]]) -> None:
for index, thumb in items:
entry_id = self._entry_ids[index]
thumb.set_item_id(entry_id)
- thumb.set_selected(entry_id in self._driver._selected) # pyright: ignore[reportPrivateUsage]
- entry = self._entries.get(entry_id)
- if entry is not None:
- self._assign_entry(thumb, entry)
- continue
-
- thumb.set_filename_text(Path())
- thumb.set_extension(Path())
- for tag_id in self._driver.entry_state.badge_tag_ids:
- thumb.assign_badge(tag_id, value=False)
- self._show_loading_image(thumb)
- if entry_id not in self._fetching:
+ if entry_id not in self._entries and entry_id not in self._fetching:
missing.append(entry_id)
+ if self._show_thumbs_timer.isActive() and thumb.rendered_path is not None:
+ self._held.add(thumb)
+ else:
+ self._show_entry(thumb)
if missing:
self._fetch_entries(missing)
self._trim_render_queue()
self._request_renders()
+ def _show_entry(self, thumb: ItemThumb) -> None:
+ """Show a thumbnail's entry or placeholders while the entry is fetched."""
+ self._held.discard(thumb)
+ entry = self._entries.get(thumb.item_id)
+ if entry is None:
+ self._clear_thumb(thumb)
+ self._show_loading_image(thumb)
+ else:
+ self._assign_entry(thumb, entry)
+ thumb.set_selected(thumb.item_id in self._driver._selected) # pyright: ignore[reportPrivateUsage]
+
+ def _clear_thumb(self, thumb: ItemThumb) -> None:
+ """Remove the entry details and image a thumbnail is showing."""
+ thumb.set_selected(False)
+ thumb.set_filename_text(Path())
+ thumb.set_extension(Path())
+ for tag_id in self._driver.entry_state.badge_tag_ids:
+ thumb.assign_badge(tag_id, value=False)
+ thumb.clear_image()
+
+ def _show_held_entries(self) -> None:
+ self._show_thumbs_timer.stop()
+ for thumb in list(self._held):
+ self._show_entry(thumb)
+
+ def _held_renders_ready(self) -> bool:
+ """Whether every held thumbnail on screen has its render."""
+ for thumb in self._held:
+ entry = self._entries.get(thumb.item_id)
+ if thumb.visibleRegion().isEmpty():
+ continue
+ if entry is None or self._renders.get(self._path(entry)) is None:
+ return False
+ return True
+
def _assign_entry(self, thumb: ItemThumb, entry: Entry) -> None:
path = self._path(entry)
thumb.set_item(entry)
@@ -182,6 +245,11 @@ def _assign_entry(self, thumb: ItemThumb, entry: Entry) -> None:
elif thumb.rendered_path != path:
self._show_loading_image(thumb)
+ def _on_items_unassigned(self, thumbs: list[ItemThumb]) -> None:
+ for thumb in thumbs:
+ self._held.discard(thumb)
+ thumb.clear_image()
+
def _show_loading_image(self, thumb: ItemThumb) -> None:
thumb.set_image(unwrap(self._loading_image), Path())
@@ -192,7 +260,11 @@ def _assign_badges(self, thumb: ItemThumb, entry_id: int) -> None:
def _on_tags_changed(self, entry_ids: set[int]) -> None:
self._changed_while_fetching.update(self._fetching.intersection(entry_ids))
for _, thumb in self.layout().assigned_items():
- if thumb.item_id in entry_ids and thumb.item_id in self._entries:
+ if (
+ thumb.item_id in entry_ids
+ and thumb.item_id in self._entries
+ and thumb not in self._held
+ ):
self._assign_badges(thumb, thumb.item_id)
def _fetch_entries(self, entry_ids: list[int]) -> None:
@@ -230,7 +302,7 @@ def _on_entries_fetched(
for entry in entries:
self._entries[entry.id] = entry
self._entry_paths[self._path(entry)] = entry.id
- if (thumb := self._thumb_for(entry.id)) is not None:
+ if (thumb := self._thumb_for(entry.id)) is not None and thumb not in self._held:
self._assign_entry(thumb, entry)
self._request_renders()
@@ -241,10 +313,13 @@ def _reset_renders(self) -> None:
self._loading_image = self._renderer.loading_image((size, size), self.devicePixelRatio())
self._driver.thumb_job_queue.queue.clear()
self._pending_durations.clear()
+
+ def _show_loading_images(self) -> None:
+ """Show the loading image on assigned thumbnails that don't have their render yet."""
for _, thumb in self.layout().assigned_items():
entry = self._entries.get(thumb.item_id)
if entry is None or thumb.rendered_path != self._path(entry):
- thumb.set_image(self._loading_image, Path())
+ self._show_loading_image(thumb)
def _request_renders(self) -> None:
"""Queue renders for thumbnails, starting at the first visible one."""
@@ -300,7 +375,11 @@ def _on_rendered(
self._renders[file_path] = image
self._previous_size_renders.pop(file_path, None)
- if (thumb := self._thumb_for(self._entry_paths.get(file_path))) is not None:
+ thumb = self._thumb_for(self._entry_paths.get(file_path))
+ if thumb in self._held:
+ if self._held_renders_ready():
+ self._show_held_entries()
+ elif thumb is not None:
thumb.set_image(image, file_path)
def _queue_durations(self) -> None:
@@ -321,5 +400,6 @@ def _load_duration(self, file_path: Path) -> None:
def _on_duration_loaded(self, file_path: Path, duration: float | None) -> None:
self._pending_durations.discard(file_path)
self._durations[file_path] = duration
- if (thumb := self._thumb_for(self._entry_paths.get(file_path))) is not None:
+ thumb = self._thumb_for(self._entry_paths.get(file_path))
+ if thumb is not None and thumb not in self._held:
thumb.set_duration(duration)
diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py
index 34b81c51f..4b6702729 100644
--- a/src/tagstudio/qt/qt_driver.py
+++ b/src/tagstudio/qt/qt_driver.py
@@ -27,7 +27,16 @@
import semver
import structlog
from humanfriendly import format_size, format_timespan # pyright: ignore[reportUnknownVariableType]
-from PySide6.QtCore import QObject, QSettings, Qt, QThread, QThreadPool, QTimer, Signal
+from PySide6.QtCore import (
+ QDeadlineTimer,
+ QObject,
+ QSettings,
+ Qt,
+ QThread,
+ QThreadPool,
+ QTimer,
+ Signal,
+)
from PySide6.QtGui import (
QColor,
QDragEnterEvent,
@@ -196,6 +205,7 @@ def __init__(self, args: Namespace):
# Thumbnail Grid
self._selected: OrderedDict[int, None] = OrderedDict()
self.frame_content: list[int] = [] # List of Entry IDs for the current query
+ self._results_state: BrowsingState | None = None
self.scrollbar_pos = 0
self.spacing = None
self.thumb_job_queue: Queue = Queue()
@@ -673,6 +683,7 @@ def _update_browsing_state():
sync_banner = self._sync_banner
if sync_banner is not None and sync_banner.is_awaiting_refresh:
sync_banner.hide_banner()
+ self._results_state = None # Submitting a search always searches again
try:
self.update_browsing_state(
BrowsingState.from_search_query(self.main_window.search_field.text())
@@ -760,23 +771,28 @@ def handle_sigterm(self):
def shutdown(self):
"""Save Library on Application Exit."""
+ logger.info("[QtDriver] Shutting down application...")
self.close_library(is_shutdown=True)
- logger.info("[SHUTDOWN] Ending Thumbnail Threads...")
+
+ logger.info("[QtDriver] Ending thumbnail threads...")
for _ in self.thumb_threads:
self.thumb_job_queue.put(Consumer.MARKER_QUIT)
- # wait for threads to quit
+ deadline = QDeadlineTimer(1000)
for thread in self.thumb_threads:
thread.quit()
- thread.wait()
+ thread.wait(deadline)
+ if any(thread.isRunning() for thread in self.thumb_threads):
+ logger.warning("[QtDriver]Forcing exit of remaining thumbnail threads...")
+ os._exit(0)
QApplication.quit()
def close_library(self, is_shutdown: bool = False):
if not self.lib.library_dir:
- logger.info("No Library to Close")
+ logger.info("[QtDriver] No library to close")
return
- logger.info("Closing Library...")
+ logger.info("[QtDriver] Closing library...")
# Invalidate any sync still active for the old library,
# and wait for the sync to stop before releasing the lock.
@@ -784,15 +800,13 @@ def close_library(self, is_shutdown: bool = False):
self._new_sync_session()
self._wait_for_sync_stop()
self.file_scan_lock = False
- if (sync_banner := self._sync_banner) is not None:
- sync_banner.hide_banner(force=True)
+ self.main_window.banners.clear()
if not is_shutdown:
self.cached_values.remove(AppCacheItems.LAST_LIBRARY)
self.cached_values.sync()
# Reset library state
- self.main_window.inspector.set_selection(self.selected)
self.main_window.search_field.setText("")
self.main_window.results_label.setText("")
scrollbar: QScrollArea = self.main_window.entry_scroll_area
@@ -809,6 +823,7 @@ def close_library(self, is_shutdown: bool = False):
self.main_window.setWindowTitle(self.base_title)
self.frame_content.clear()
+ self._results_state = None
self._selected.clear()
if self.color_manager_panel:
self.color_manager_panel.reset()
@@ -849,7 +864,7 @@ def close_library(self, is_shutdown: bool = False):
self.main_window.menu_bar.add_tag_to_selected_action.setEnabled(False)
def backup_library(self):
- logger.info("Backing Up Library...")
+ logger.info("[QtDriver] Backing up library...")
Library.save_library_backup_to_disk(unwrap(self.lib.library_dir))
def add_tag_action_callback(self):
@@ -1284,16 +1299,14 @@ def mouse_navigation(self, event: QMouseEvent):
elif event.button() == Qt.MouseButton.BackButton:
self.navigation_callback(-1)
- def page_move(self, value: int, absolute=False) -> None:
- logger.info("page_move", value=value, absolute=absolute)
-
+ def page_move(self, value: int, absolute: bool = False) -> None:
current = self.browsing_history.current
index = clamp(value if absolute else current.page_index + value, 0, self.pages_count - 1)
if index != current.page_index:
self.update_browsing_state(current.with_page_index(index))
def navigation_callback(self, delta: int) -> None:
- """Callback for the Forwads and Backwards Navigation Buttons next to the search bar."""
+ """Callback for the Forwards and Backwards Navigation Buttons next to the search bar."""
logger.info(
"navigation_callback",
delta=delta,
@@ -1477,27 +1490,37 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None:
self.main_window.search_field.setText(current.query or "")
- # search the library
- start_time = time.time()
- Ignore.get_patterns(self.lib.library_dir, include_global=True)
- results = self.lib.search_library(current, page_size=0)
- logger.info("items to render", count=len(results))
- end_time = time.time()
-
- # inform user about completed search
- time_span = f"({format_timespan(end_time - start_time).title()})"
- count = f"{results.total_count:,}"
- self.main_window.results_label.setText(
- Translations.format(
- # Show "X Results" on query, "X Items" on none
- "status.results_found" if current.query else "status.items",
- count=count,
- time_span=time_span,
- )
+ shown = self._results_state
+ only_page_changed = (
+ current is not shown # Refreshing the shown state searches again
+ and shown is not None
+ and current.with_page_index(0) == shown.with_page_index(0)
)
+ if not only_page_changed:
+ # search the library
+ start_time = time.time()
+ Ignore.get_patterns(self.lib.library_dir, include_global=True)
+ results = self.lib.search_library(current, page_size=0)
+ logger.info("items to render", count=len(results))
+ end_time = time.time()
+
+ # inform user about completed search
+ time_span = (
+ f"({format_timespan(end_time - start_time).title()})"
+ )
+ count = f"{results.total_count:,}"
+ self.main_window.results_label.setText(
+ Translations.format(
+ # Show "X Results" on query, "X Items" on none
+ "status.results_found" if current.query else "status.items",
+ count=count,
+ time_span=time_span,
+ )
+ )
+ self.frame_content = results.ids
+ self._results_state = current
# update page content
- self.frame_content = results.ids
same_page = current.query == previous.query and current.page_index == previous.page_index
if state is None:
entry_id = current.first_visible_entry_id
@@ -1513,7 +1536,7 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None:
# update pagination
page_size = 0 if self.settings.infinite_scroll else self.settings.page_size
if page_size > 0:
- self.pages_count = math.ceil(results.total_count / page_size)
+ self.pages_count = math.ceil(len(self.frame_content) / page_size)
else:
self.pages_count = 1
self.main_window.pagination.update_buttons(
diff --git a/src/tagstudio/qt/qt_file_renderer.py b/src/tagstudio/qt/qt_file_renderer.py
index d510c5788..a006b83c8 100644
--- a/src/tagstudio/qt/qt_file_renderer.py
+++ b/src/tagstudio/qt/qt_file_renderer.py
@@ -146,7 +146,7 @@ def _placeholder(
if (
renderer is None
or not filepath.is_file()
- or FileRenderer.previously_failed(filepath, date_modified)
+ or FileRenderer.previously_failed(filepath, self.theme, date_modified)
):
return None
if renderer.has_small_variant:
@@ -154,7 +154,7 @@ def _placeholder(
blank = QImage(side, side, QImage.Format.Format_ARGB32_Premultiplied)
blank.fill(Qt.GlobalColor.transparent)
return blank
- cached = FileRenderer.cached_thumbnail(cache, filepath, date_modified)
+ cached = FileRenderer.cached_thumbnail(cache, filepath, self.theme, date_modified)
if cached is None:
return _icon("thumb_loading", self._theme_color, self.theme, side, pixel_ratio)
return ImageQt.ImageQt(cached)
@@ -193,7 +193,7 @@ def render(
is_missing = result.is_unlinked or not filepath.is_file()
cached = None
if is_missing and not is_thumb:
- cached = FileRenderer.cached_thumbnail(cache, filepath, date_modified)
+ cached = FileRenderer.cached_thumbnail(cache, filepath, self.theme, date_modified)
if cached is not None:
image = ImageQt.ImageQt(cached)
else:
diff --git a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
index 076ea7e70..14697b5df 100644
--- a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
+++ b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
@@ -20,6 +20,7 @@ class ThumbGridLayout[W: QWidget](QLayout):
first_visible_changed = Signal(int) # Index of the first visible item
items_assigned = Signal(object) # list[tuple[int, W]] of newly assigned (index, widget) pairs
+ items_unassigned = Signal(object) # list[W] of widgets that no longer have an index
def __init__(self, scroll_area: QScrollArea, create_widget: Callable[[], W]) -> None:
super().__init__(None)
@@ -34,14 +35,19 @@ def __init__(self, scroll_area: QScrollArea, create_widget: Callable[[], W]) ->
self._items: list[QLayoutItem] = []
# [start, end, per_row, item size]
self._last_layout: tuple[int, int, int, QSize] | None = None
+ # Widgets by [row from the first visible row, column] from before the items were replaced
+ self._previous_slots: dict[tuple[int, int], W] = {}
+ self._previous_offset = 0
self._scroll_area = scroll_area
self._scroll_to: int | None = None
def set_item_count(self, count: int) -> None:
- """Replace all items with `count` new ones, unassigning every widget."""
+ """Replace all items with `count` new ones, keeping widgets in place if nothing scrolls."""
self._item_count = count
- self._free.extend(self._assigned.values())
- self._assigned.clear()
+ if self._assigned:
+ self._previous_slots = self._screen_slots()
+ self._previous_offset = self._scroll_area.verticalScrollBar().value()
+ self._assigned.clear()
self._first_visible = None
self._last_layout = None
self.update()
@@ -60,7 +66,16 @@ def assigned_items(self) -> list[tuple[int, W]]:
return sorted(self._assigned.items(), key=lambda item: item[0])
def widgets(self) -> list[W]:
- return [*self._assigned.values(), *self._free]
+ return [*self._assigned.values(), *self._previous_slots.values(), *self._free]
+
+ def _screen_slots(self) -> dict[tuple[int, int], W]:
+ """Assigned widgets by their row from the first visible row and their column."""
+ per_row = unwrap(self._last_layout)[2]
+ first_row = unwrap(self._first_visible) // per_row
+ return {
+ (index // per_row - first_row, index % per_row): widget
+ for index, widget in self._assigned.items()
+ }
def _new_widget(self) -> W:
widget = self._create_widget()
@@ -84,10 +99,14 @@ def _grid_metrics(self, width: int) -> tuple[int, QSize]:
def setGeometry(self, arg__1: QRect) -> None:
super().setGeometry(arg__1)
if self._item_count == 0:
- self._free.extend(self._assigned.values())
+ unassigned = [*self._assigned.values(), *self._previous_slots.values()]
self._assigned.clear()
+ self._previous_slots.clear()
+ self._free.extend(unassigned)
for widget in self._free:
widget.setGeometry(32_000, 32_000, 0, 0)
+ if unassigned:
+ self.items_unassigned.emit(unassigned)
return
per_row, cell = self._grid_metrics(arg__1.width())
@@ -100,6 +119,11 @@ def setGeometry(self, arg__1: QRect) -> None:
self._scroll_to = None
offset = scroll_bar.value()
+ unassigned: list[W] = []
+ if offset != self._previous_offset:
+ unassigned.extend(self._previous_slots.values())
+ self._free.extend(self._previous_slots.values())
+ self._previous_slots.clear()
last_row = (self._item_count - 1) // per_row
first_row = min(offset // cell.height(), last_row)
visible_rows = math.ceil(
@@ -118,22 +142,32 @@ def setGeometry(self, arg__1: QRect) -> None:
return
self._last_layout = (start, end, per_row, item_size)
- for index in [index for index in self._assigned if not start <= index < end]:
- self._free.append(self._assigned.pop(index))
+ moved_out = [
+ self._assigned.pop(index) for index in list(self._assigned) if not start <= index < end
+ ]
+ self._free.extend(moved_out)
+ unassigned.extend(moved_out)
newly_assigned: list[tuple[int, W]] = []
for index in range(start, end):
widget = self._assigned.get(index)
+ row, column = divmod(index, per_row)
if widget is None:
- widget = self._free.pop() if self._free else self._new_widget()
+ widget = self._previous_slots.pop((row - first_row, column), None)
+ if widget is None:
+ widget = self._free.pop() if self._free else self._new_widget()
self._assigned[index] = widget
newly_assigned.append((index, widget))
- row, column = divmod(index, per_row)
widget.setGeometry(QRect(QPoint(column * cell.width(), row * cell.height()), item_size))
+ unassigned.extend(self._previous_slots.values())
+ self._free.extend(self._previous_slots.values())
+ self._previous_slots.clear()
for widget in self._free:
widget.setGeometry(32_000, 32_000, 0, 0)
+ if unassigned:
+ self.items_unassigned.emit(unassigned)
if newly_assigned:
self.items_assigned.emit(newly_assigned)
@@ -159,6 +193,7 @@ def takeAt(self, index: int) -> QLayoutItem:
widget = item.widget()
self._free = [w for w in self._free if w is not widget]
self._assigned = {i: w for i, w in self._assigned.items() if w is not widget}
+ self._previous_slots = {k: w for k, w in self._previous_slots.items() if w is not widget}
return item
@override
diff --git a/src/tagstudio/qt/views/pagination_view.py b/src/tagstudio/qt/views/pagination_view.py
index 186aae6d8..4bf4f3aa1 100644
--- a/src/tagstudio/qt/views/pagination_view.py
+++ b/src/tagstudio/qt/views/pagination_view.py
@@ -48,7 +48,9 @@ def __init__(self) -> None:
self.start_button.setMaximumSize(self.BUTTON_SIZE)
# ------ ... ---------------------------
- self.start_ellipses = QLabel(". . .")
+ self.start_ellipses = QLabel(". . .")
+ self.start_ellipses.setStyleSheet("padding-bottom: 1px;")
+ self.start_ellipses.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignBottom)
self.start_ellipses.setMinimumSize(self.BUTTON_SIZE)
self.start_ellipses.setMaximumSize(self.BUTTON_SIZE)
@@ -73,7 +75,9 @@ def __init__(self) -> None:
self.end_buffer_layout.setSpacing(3)
# -------------------------- ... -------
- self.end_ellipses = QLabel(". . .")
+ self.end_ellipses = QLabel(". . .")
+ self.end_ellipses.setStyleSheet("padding-bottom: 1px;")
+ self.end_ellipses.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom)
self.end_ellipses.setMinimumSize(self.BUTTON_SIZE)
self.end_ellipses.setMaximumSize(self.BUTTON_SIZE)
diff --git a/src/tagstudio/qt/views/styles/stylesheets.py b/src/tagstudio/qt/views/styles/stylesheets.py
index 290244fc5..ebd973fd5 100644
--- a/src/tagstudio/qt/views/styles/stylesheets.py
+++ b/src/tagstudio/qt/views/styles/stylesheets.py
@@ -774,7 +774,7 @@ def item_thumb_check_badge_style(size: int, icon: Path, checked_icon: Path) -> s
)
-def item_thumb_filename_style(is_selected: bool = False) -> str:
+def item_thumb_filename_style(is_selected: bool = False, is_placeholder: bool = False) -> str:
"""Style used for filename labels under item thumbnails, highlighted when selected."""
if is_selected:
return (
@@ -785,6 +785,15 @@ def item_thumb_filename_style(is_selected: bool = False) -> str:
"padding-left: 1px;"
f"border-radius: {HALF_PAD}px;"
)
+ if is_placeholder:
+ background = "rgba(255, 255, 255, 13)" if _is_dark_theme() else "rgba(0, 0, 0, 34)"
+ return (
+ f"background-color: {background};"
+ "font-size: 10px;"
+ "padding-bottom: 1px;"
+ "padding-left: 1px;"
+ f"border-radius: {HALF_PAD}px;"
+ )
return "font-size: 10px; padding-left: 1px; padding-bottom: 1px;"
diff --git a/tests/qt/test_entry_state.py b/tests/qt/test_entry_state.py
index 82cec48f9..bf58383b5 100644
--- a/tests/qt/test_entry_state.py
+++ b/tests/qt/test_entry_state.py
@@ -5,6 +5,7 @@
from PySide6.QtCore import QRect
+from pytestqt.qtbot import QtBot
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.library.alchemy.library import Library
@@ -47,10 +48,11 @@ def test_unload_badges_rereads_from_library(library: Library):
assert entry_state.has_badge(1, TAG_ARCHIVED)
-def test_badge_click_updates_whole_selection(qt_driver: QtDriver):
+def test_badge_click_updates_whole_selection(qt_driver: QtDriver, qtbot: QtBot):
grid = qt_driver.main_window.thumb_grid
grid.set_entries([1, 2])
grid.layout().setGeometry(QRect(0, 0, 1000, 1000))
+ qtbot.waitUntil(lambda: not grid._held)
thumbs = [unwrap(grid.layout().widget_at(index)) for index in (0, 1)]
qt_driver.toggle_item_selection(1, append=False, bridge=False)