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)