"
From dad5972034b5aff8b3c0a10242f407d2293a6a25 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Mon, 14 Sep 2026 15:53:15 -0700
Subject: [PATCH 07/14] fix: wacky loop in color_data.py
---
src/tagstudio/qt/controllers/tiles/color_data.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/tagstudio/qt/controllers/tiles/color_data.py b/src/tagstudio/qt/controllers/tiles/color_data.py
index b0170ff71d..b232adf226 100644
--- a/src/tagstudio/qt/controllers/tiles/color_data.py
+++ b/src/tagstudio/qt/controllers/tiles/color_data.py
@@ -59,8 +59,10 @@ def set_colors(self, colors: Iterable[TagColorGroup]):
max_width = 60
capsules: list[Capsule] = []
- while (item := self.layout().itemAt(0)) and (widget := item.widget()):
- widget.deleteLater()
+ layout = self.layout()
+ while item := layout.takeAt(0):
+ if widget := item.widget():
+ widget.deleteLater()
for color in colors_:
capsule = Capsule(has_edit=is_mutable, has_remove=is_mutable)
From df9451b8191f533375ded1e068144c73510c1f7b Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Mon, 14 Sep 2026 16:24:28 -0700
Subject: [PATCH 08/14] fix: clean up tile_list.py, remove redundant disconnect
step
---
.../qt/controllers/tiles/tile_list.py | 30 +++++++------------
1 file changed, 11 insertions(+), 19 deletions(-)
diff --git a/src/tagstudio/qt/controllers/tiles/tile_list.py b/src/tagstudio/qt/controllers/tiles/tile_list.py
index e9ee0c9fa1..ff3b792026 100644
--- a/src/tagstudio/qt/controllers/tiles/tile_list.py
+++ b/src/tagstudio/qt/controllers/tiles/tile_list.py
@@ -7,7 +7,6 @@
from datetime import datetime as dt
from functools import partial
from typing import override
-from warnings import catch_warnings
import structlog
from PySide6.QtCore import Signal
@@ -47,13 +46,11 @@ def __init__(self, library: Library, driver: QtDriver) -> None:
self.lib = library
self.driver: QtDriver = driver
- self.initialized = False
- self.is_open: bool = False
- self.common_fields: list = [] # TODO: Reimplement
- self.mixed_fields: list = [] # TODO: Reimplement
self.cached_entries: list[Entry] = []
self._tiles: list[Tile] = []
+ # TODO: Reimplement mixed entry editing
+
self.setLayout(TileListView())
@override
@@ -374,22 +371,17 @@ def write_tag_tile(
if not is_mixed:
inner_widget = container.get_inner_widget()
- if isinstance(inner_widget, TagData):
- with catch_warnings(record=True):
- inner_widget.on_update.disconnect()
-
- else:
+ if not isinstance(inner_widget, TagData):
inner_widget = TagData(Translations["entries.tags"], self.driver)
container.set_inner_widget(inner_widget)
+ inner_widget.on_update.connect(
+ lambda: (
+ self.update_from_entry(self.cached_entries[0].id, update_badges=True),
+ self.on_tags_update.emit(),
+ )
+ )
inner_widget.set_entries([e.id for e in self.cached_entries])
inner_widget.set_tags(tags)
-
- inner_widget.on_update.connect(
- lambda: (
- self.update_from_entry(self.cached_entries[0].id, update_badges=True),
- self.on_tags_update.emit(),
- )
- )
else:
text = f"{Translations['field.mixed_data']}"
inner_widget = TextData("Mixed Tags", text) # NOTE: Unlocalized but unused
@@ -431,9 +423,9 @@ def remove_message_box(self, prompt: str, callback: Callable[..., None]) -> None
remove_mb.setWindowTitle(Translations["Remove Field"])
remove_mb.setIcon(QMessageBox.Icon.Warning)
cancel_button = remove_mb.addButton(
- Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole
+ Translations["generic.cancel_alt"], QMessageBox.ButtonRole.RejectRole
)
- remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole)
+ remove_mb.addButton("&Remove", QMessageBox.ButtonRole.DestructiveRole)
remove_mb.setEscapeButton(cancel_button)
result = remove_mb.exec_()
if result == QMessageBox.ButtonRole.ActionRole.value:
From c007f7ee4a93e3debcba97177748a726fef5c3dc Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Mon, 14 Sep 2026 16:57:10 -0700
Subject: [PATCH 09/14] refactor: move leftover controller logic on TagDataView
to TagData
---
.../qt/controllers/tiles/tag_data.py | 37 ++++++++++++-------
src/tagstudio/qt/views/tiles/tag_data_view.py | 37 +------------------
2 files changed, 25 insertions(+), 49 deletions(-)
diff --git a/src/tagstudio/qt/controllers/tiles/tag_data.py b/src/tagstudio/qt/controllers/tiles/tag_data.py
index 176e698a16..295697e5c9 100644
--- a/src/tagstudio/qt/controllers/tiles/tag_data.py
+++ b/src/tagstudio/qt/controllers/tiles/tag_data.py
@@ -14,6 +14,7 @@
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
+from tagstudio.qt.controllers.capsule import Capsule
from tagstudio.qt.controllers.edit_tag_panel import EditTagPanel
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.tiles.tile_data import TileData
@@ -32,25 +33,38 @@ class TagData(TileData):
def __init__(self, title: str, driver: QtDriver):
self._driver = driver
+ self._lib = driver.lib
self._entries: list[int] = []
- super().__init__(title, TagDataView(driver.lib))
- self._connect_callbacks()
+ super().__init__(title, TagDataView())
+ self.setObjectName("tag_data")
@override
def layout(self) -> TagDataView:
return super().layout() # pyright: ignore[reportReturnType]
- def _connect_callbacks(self) -> None:
- self.layout().tag_clicked.connect(self._on_click)
- self.layout().tag_removed.connect(self._on_remove)
- self.layout().tag_edited.connect(self._on_edit)
- self.layout().tag_searched.connect(self._on_search)
-
def set_entries(self, entries: list[int]) -> None:
self._entries = entries
def set_tags(self, tags: Iterable[Tag]) -> None:
- self.layout().set_tags(tags)
+ tags_ = sorted(list(tags), key=lambda tag: self._lib.tag_display_name(tag))
+ logger.info("[TagData] Tags:", tags=tags)
+
+ layout = self.layout()
+ while item := layout.takeAt(0):
+ if widget := item.widget():
+ widget.deleteLater()
+
+ for tag in tags_:
+ capsule = Capsule(
+ has_edit=True, has_remove=True, search_label=Translations["tag.search_for_tag"]
+ )
+ capsule.set_text(self._lib.tag_display_name(tag))
+ capsule.set_color_group(tag.color)
+ capsule.on_click.connect(lambda t=tag: self._on_click(t))
+ capsule.on_remove.connect(lambda t=tag: self._on_remove(t))
+ capsule.on_edit.connect(lambda t=tag: self._on_edit(t))
+ capsule.on_search.connect(lambda t=tag: self._on_search(t))
+ layout.addWidget(capsule)
def _on_click(self, tag: Tag) -> None:
match self._driver.settings.tag_click_action:
@@ -76,10 +90,7 @@ def _on_click(self, tag: Tag) -> None:
)
def _on_remove(self, tag: Tag) -> None:
- logger.info(
- "[TagData] remove_tag",
- selected=self._entries,
- )
+ logger.info("[TagData] remove_tag", selected=self._entries)
for entry_id in self._entries:
self._driver.lib.remove_tags_from_entries(entry_id, tag.id)
diff --git a/src/tagstudio/qt/views/tiles/tag_data_view.py b/src/tagstudio/qt/views/tiles/tag_data_view.py
index adc0ccd1b9..b51791700f 100644
--- a/src/tagstudio/qt/views/tiles/tag_data_view.py
+++ b/src/tagstudio/qt/views/tiles/tag_data_view.py
@@ -2,48 +2,13 @@
# SPDX-License-Identifier: GPL-3.0-only
-from collections.abc import Iterable
-
-import structlog
-from PySide6.QtCore import Signal
-
-from tagstudio.core.library.alchemy.library import Library
-from tagstudio.core.library.alchemy.models import Tag
-from tagstudio.i18n.translations import Translations
-from tagstudio.qt.controllers.capsule import Capsule
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
-logger = structlog.get_logger(__name__)
-
class TagDataView(FlowLayout):
"""The layout used for a TagData widget."""
- tag_clicked = Signal(Tag)
- tag_removed = Signal(Tag)
- tag_edited = Signal(Tag)
- tag_searched = Signal(Tag)
-
- def __init__(self, library: Library) -> None:
+ def __init__(self) -> None:
super().__init__()
- self._lib = library
self.enable_grid_optimizations(value=False)
self.setContentsMargins(0, 0, 0, 0)
-
- def set_tags(self, tags: Iterable[Tag]) -> None:
- tags_ = sorted(list(tags), key=lambda tag: self._lib.tag_display_name(tag))
- logger.info("[TagData] Tags:", tags=tags)
- while self.itemAt(0):
- self.takeAt(0).widget().deleteLater() # pyright: ignore[reportOptionalMemberAccess]
-
- for tag in tags_:
- capsule = Capsule(
- has_edit=True, has_remove=True, search_label=Translations["tag.search_for_tag"]
- )
- capsule.set_text(self._lib.tag_display_name(tag))
- capsule.set_color_group(tag.color)
- capsule.on_click.connect(lambda t=tag: self.tag_clicked.emit(t))
- capsule.on_remove.connect(lambda t=tag: self.tag_removed.emit(t))
- capsule.on_edit.connect(lambda t=tag: self.tag_edited.emit(t))
- capsule.on_search.connect(lambda t=tag: self.tag_searched.emit(t))
- self.addWidget(capsule)
From 5368d034f7b4c52ecc36ac8a287ce3a06679ac7c Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:50:56 -0700
Subject: [PATCH 10/14] refactor: more tweaks in pagination.py
---
src/tagstudio/qt/controllers/pagination.py | 18 ++++++++----------
src/tagstudio/qt/controllers/tiles/tile.py | 15 ++++++++-------
2 files changed, 16 insertions(+), 17 deletions(-)
diff --git a/src/tagstudio/qt/controllers/pagination.py b/src/tagstudio/qt/controllers/pagination.py
index e6a97f233b..7dd133b8a3 100644
--- a/src/tagstudio/qt/controllers/pagination.py
+++ b/src/tagstudio/qt/controllers/pagination.py
@@ -5,12 +5,15 @@
from typing import override
from warnings import catch_warnings
+import structlog
from PySide6.QtCore import QEvent, QObject, Qt, Signal
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QWidget
from tagstudio.qt.views.pagination_view import PaginationView
from tagstudio.qt.views.styles.stylesheets import pagination_style
+logger = structlog.get_logger(__name__)
+
class Pagination(QWidget):
HEIGHT = 36
@@ -21,9 +24,6 @@ class Pagination(QWidget):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
- self.page_count: int = 0
- self.current_page_index: int = 0
-
self.setHidden(True)
self.setObjectName("pagination")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)
@@ -139,10 +139,10 @@ def update_buttons(self, page_count: int, index: int, emit: bool = True):
button.setHidden(True)
view.current_page_field.setText(str(i + 1))
- start_offset = max(0, (index - 4) - 4)
- end_offset = min(page_count - 1, (index + 4) - 4)
+ start_offset = max(0, index - view.BUFFER_PAGE_COUNT * 2)
+ end_offset = min(page_count - 1, index)
if i < index:
- if (i != 0) and i >= index - 4:
+ if (i != 0) and i >= index - view.BUFFER_PAGE_COUNT:
if button := self._get_button_at(
view.start_buffer_layout, i - start_offset
):
@@ -155,7 +155,7 @@ def update_buttons(self, page_count: int, index: int, emit: bool = True):
if button := self._get_button_at(view.end_buffer_layout, i):
button.setHidden(True)
elif i > index:
- if i != page_count - 1 and i <= index + 4:
+ if i != page_count - 1 and i <= index + view.BUFFER_PAGE_COUNT:
if button := self._get_button_at(view.end_buffer_layout, i - end_offset):
button.setHidden(False)
button.setText(str(i + 1))
@@ -179,11 +179,9 @@ def update_buttons(self, page_count: int, index: int, emit: bool = True):
view.validator.setTop(page_count)
if emit:
self.index.emit(index)
- self.current_page_index = index
- self.page_count = page_count
def _goto_page(self, index: int):
- self.update_buttons(self.page_count, index)
+ self.update_buttons(self.layout().validator.top(), index)
def _assign_click(self, button: QPushButton, index: int):
with catch_warnings(record=True):
diff --git a/src/tagstudio/qt/controllers/tiles/tile.py b/src/tagstudio/qt/controllers/tiles/tile.py
index ef2746ca96..080d067e57 100644
--- a/src/tagstudio/qt/controllers/tiles/tile.py
+++ b/src/tagstudio/qt/controllers/tiles/tile.py
@@ -22,7 +22,6 @@ def __init__(self, title: str = "TILE") -> None:
super().__init__()
self.setObjectName("tile")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)
- self.title: str = title
self.copy_callback: Callable[[], None] | None = None
self.edit_callback: Callable[[], None] | None = None
self.remove_callback: Callable[[], None] | None = None
@@ -79,22 +78,24 @@ def set_title(self, title: str) -> None:
@override
def enterEvent(self, event: QEnterEvent) -> None:
# NOTE: You could pass the hover event to the inner widget if needed.
+ layout = self.layout()
if self.copy_callback:
- self.layout().copy_button.setHidden(False)
+ layout.copy_button.setHidden(False)
if self.edit_callback:
- self.layout().edit_button.setHidden(False)
+ layout.edit_button.setHidden(False)
if self.remove_callback:
- self.layout().remove_button.setHidden(False)
+ layout.remove_button.setHidden(False)
return super().enterEvent(event)
@override
def leaveEvent(self, event: QEvent) -> None:
+ layout = self.layout()
if self.copy_callback:
- self.layout().copy_button.setHidden(True)
+ layout.copy_button.setHidden(True)
if self.edit_callback:
- self.layout().edit_button.setHidden(True)
+ layout.edit_button.setHidden(True)
if self.remove_callback:
- self.layout().remove_button.setHidden(True)
+ layout.remove_button.setHidden(True)
return super().leaveEvent(event)
@override
From 1aa0b7d416f96ea18a4935ee65f98f1641bbb16f Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Wed, 16 Sep 2026 15:18:18 -0700
Subject: [PATCH 11/14] refactor(ui): MVC split PreviewThumb + PreviewThumbView
---
src/tagstudio/qt/controllers/preview_thumb.py | 344 +++++++++++++++--
src/tagstudio/qt/views/inspector_view.py | 2 +-
src/tagstudio/qt/views/preview_thumb_view.py | 365 +++---------------
3 files changed, 346 insertions(+), 365 deletions(-)
diff --git a/src/tagstudio/qt/controllers/preview_thumb.py b/src/tagstudio/qt/controllers/preview_thumb.py
index 25b5b29a86..123a881c54 100644
--- a/src/tagstudio/qt/controllers/preview_thumb.py
+++ b/src/tagstudio/qt/controllers/preview_thumb.py
@@ -3,6 +3,9 @@
import io
+import math
+import time
+from enum import Enum, auto
from pathlib import Path
from typing import TYPE_CHECKING, override
@@ -11,16 +14,20 @@
import structlog
from PIL import Image, UnidentifiedImageError
from PIL.Image import DecompressionBombError
-from PySide6.QtCore import QSize
+from PySide6.QtCore import QBuffer, QByteArray, QSize, Signal
+from PySide6.QtGui import QMovie, QPixmap, QResizeEvent
+from PySide6.QtWidgets import QWidget
from rawpy import LibRawFileUnsupportedError, LibRawIOError # pyright: ignore
-from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.query_lang.file_groups import SEARCH
from tagstudio.previews.video_tester import is_readable_video
from tagstudio.qt.mixed.file_attributes import FileAttributeData
+from tagstudio.qt.mixed.media_player import MediaPlayer
+from tagstudio.qt.qt_file_renderer import QtFileRenderer
from tagstudio.qt.utils.file_opener import open_file
from tagstudio.qt.views.preview_thumb_view import PreviewThumbView
+from tagstudio.qt.views.styles.rounded_pixmap_style import RoundedPixmapStyle
if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
@@ -28,15 +35,296 @@
logger = structlog.get_logger(__name__)
Image.MAX_IMAGE_PIXELS = None
+_DEFAULT_PREVIEW_SIZE = (272, 272)
+_THUMB_SIZE_FACTOR = 2
-# TODO: Use newer MVC style guidelines
-class PreviewThumb(PreviewThumbView):
- def __init__(self, library: Library, driver: QtDriver):
- super().__init__(library, driver)
- self.__driver: QtDriver = driver
+class _PreviewType(Enum):
+ """Enum for which of the Inspector's stacked pages should be shown for a file."""
- def __get_image_stats(self, filepath: Path) -> FileAttributeData:
+ ANIMATED = auto()
+ AUDIO = auto()
+ IMAGE = auto()
+ TEXT = auto()
+ VIDEO = auto()
+
+
+class PreviewThumb(QWidget):
+ """The file preview thumbnail widget."""
+
+ check_ffmpeg = Signal(bool)
+ stats_updated = Signal(Path, FileAttributeData)
+
+ def __init__(self, driver: QtDriver):
+ super().__init__()
+ self._driver = driver
+ self._thumb_renderer = QtFileRenderer(driver.lib, driver.settings)
+
+ self._current_file: Path | None = None
+ self._gif_buffer: QBuffer = QBuffer()
+ self._image_ratio: float = 1.0
+ self._preview_size: tuple[int, int] = _DEFAULT_PREVIEW_SIZE
+ self._rendered_res: tuple[int, int] = (0, 0)
+ self._should_render_on_resize: bool = False
+
+ self.setMinimumSize(*self._preview_size)
+ self.setLayout(PreviewThumbView(driver))
+ self._connect_callbacks()
+
+ self.hide_preview()
+
+ @override
+ def layout(self) -> PreviewThumbView:
+ return super().layout() # pyright: ignore[reportReturnType]
+
+ def _connect_callbacks(self) -> None:
+ view = self.layout()
+ view.open_file_action.triggered.connect(self._open_file_action_callback)
+ view.open_explorer_action.triggered.connect(self._open_explorer_action_callback)
+ view.delete_action.triggered.connect(self._delete_action_callback)
+ view.button_wrapper.clicked.connect(self._button_wrapper_callback)
+
+ # QMediaPlayer loads duration asynchronously after setSource().
+ view.media_player.player.durationChanged.connect(
+ self._media_player_duration_changed_callback
+ )
+ # Need to watch for this to resize the player appropriately.
+ view.media_player.player.hasVideoChanged.connect(self._media_player_video_changed_callback)
+
+ self._thumb_renderer.updated.connect(self._thumb_renderer_updated_callback)
+ self._thumb_renderer.updated_ratio.connect(self._thumb_renderer_updated_ratio_callback)
+
+ def _open_file_action_callback(self) -> None:
+ if self._current_file:
+ open_file(
+ self._current_file,
+ windows_start_command=self._driver.settings.windows_start_command,
+ )
+
+ def _open_explorer_action_callback(self) -> None:
+ if self._current_file:
+ open_file(self._current_file, file_manager=True)
+
+ def _delete_action_callback(self) -> None:
+ if self._current_file:
+ self._driver.delete_files_callback(self._current_file)
+
+ def _button_wrapper_callback(self) -> None:
+ if self._current_file:
+ open_file(
+ self._current_file,
+ windows_start_command=self._driver.settings.windows_start_command,
+ )
+
+ def _media_player_video_changed_callback(self) -> None:
+ self._update_image_size((self.size().width(), self.size().height()))
+
+ def _media_player_duration_changed_callback(self, duration_ms: int) -> None:
+ filepath = self.layout().media_player.filepath
+ if filepath is None or duration_ms <= 0:
+ return
+
+ self.stats_updated.emit(
+ filepath,
+ FileAttributeData(duration=duration_ms // 1000),
+ )
+
+ def _thumb_renderer_updated_callback(
+ self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path
+ ) -> None:
+ self.layout().button_wrapper.setIcon(img)
+
+ def _thumb_renderer_updated_ratio_callback(self, ratio: float) -> None:
+ self._image_ratio = ratio
+ self._update_image_size((self.size().width(), self.size().height()))
+
+ def _update_image_size(self, size: tuple[int, int]) -> None:
+ view = self.layout()
+ adj_width: float = size[0]
+ adj_height: float = size[1]
+ # Landscape
+ if self._image_ratio > 1:
+ adj_height = size[0] * (1 / self._image_ratio)
+ # Portrait
+ elif self._image_ratio <= 1:
+ adj_width = size[1] * self._image_ratio
+
+ if adj_width > size[0]:
+ adj_height = adj_height * (size[0] / adj_width)
+ adj_width = size[0]
+ elif adj_height > size[1]:
+ adj_width = adj_width * (size[1] / adj_height)
+ adj_height = size[1]
+
+ adj_size = QSize(int(adj_width), int(adj_height))
+
+ self._preview_size = (int(adj_width), int(adj_height))
+ view.button_wrapper.setMaximumSize(adj_size)
+ view.button_wrapper.setIconSize(adj_size)
+ view.preview_gif.setMaximumSize(adj_size)
+ view.preview_gif.setMinimumSize(adj_size)
+
+ view.media_player.setMaximumSize(adj_size)
+ view.media_player.setMinimumSize(adj_size)
+
+ proxy_style = RoundedPixmapStyle(radius=8)
+ view.preview_gif.setStyle(proxy_style)
+ view.media_player.setStyle(proxy_style)
+ m = view.preview_gif.movie()
+ if m:
+ m.setScaledSize(adj_size)
+
+ def _switch_preview(self, preview: _PreviewType | None) -> None:
+ view = self.layout()
+ if preview in [_PreviewType.AUDIO, _PreviewType.VIDEO]:
+ view.media_player.show()
+ view.setCurrentWidget(view.media_player_page)
+ view.media_player_page.raise_()
+ self.check_ffmpeg.emit(True) # noqa: FBT003
+ else:
+ view.media_player.stop()
+ view.media_player.hide()
+ self.check_ffmpeg.emit(False) # noqa: FBT003
+
+ if preview in [_PreviewType.IMAGE, _PreviewType.AUDIO]:
+ view.button_wrapper.show()
+ current_page = (
+ view.preview_img_page if preview == _PreviewType.IMAGE else view.media_player_page
+ )
+ view.setCurrentWidget(current_page)
+ current_page.raise_()
+ else:
+ view.button_wrapper.hide()
+
+ if preview == _PreviewType.ANIMATED:
+ view.preview_gif.show()
+ view.setCurrentWidget(view.preview_gif_page)
+ view.preview_gif_page.raise_()
+ else:
+ if view.preview_gif.movie():
+ view.preview_gif.movie().stop()
+ self._gif_buffer.close()
+ view.preview_gif.hide()
+
+ def _render_thumb(self, filepath: Path) -> None:
+ self._should_render_on_resize = True
+
+ self._rendered_res = (
+ math.ceil(self._preview_size[0] * _THUMB_SIZE_FACTOR),
+ math.ceil(self._preview_size[1] * _THUMB_SIZE_FACTOR),
+ )
+
+ # TODO: Make driver update the cache manager reference here instead of passing the driver.
+ self._thumb_renderer.render(
+ self._driver.cache_manager,
+ time.time(),
+ filepath,
+ self._rendered_res,
+ self.devicePixelRatio(),
+ )
+
+ def _update_media_player(self, filepath: Path) -> None:
+ """Display either audio or video."""
+ self.layout().media_player.play(filepath)
+
+ def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData:
+ self._should_render_on_resize = False
+
+ self._switch_preview(_PreviewType.VIDEO)
+ self._update_media_player(filepath)
+ stats = FileAttributeData()
+
+ if size is not None:
+ stats.width = size.width()
+ stats.height = size.height()
+
+ self._image_ratio = stats.width / stats.height
+ self.resizeEvent(
+ QResizeEvent(
+ QSize(stats.width, stats.height),
+ QSize(stats.width, stats.height),
+ )
+ )
+
+ return stats
+
+ def _display_audio(self, filepath: Path) -> FileAttributeData:
+ self._switch_preview(_PreviewType.AUDIO)
+ self._render_thumb(filepath)
+ self._update_media_player(filepath)
+ return FileAttributeData()
+
+ def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None:
+ """Update the animated image preview from a filepath."""
+ self._should_render_on_resize = False
+
+ view = self.layout()
+ stats = FileAttributeData()
+
+ # Ensure that any movie and buffer from previous animations are cleared.
+ if view.preview_gif.movie():
+ view.preview_gif.movie().stop()
+ self._gif_buffer.close()
+
+ stats.width = size[0]
+ stats.height = size[1]
+
+ self._image_ratio = stats.width / stats.height
+
+ self._gif_buffer.setData(gif_data)
+ movie = QMovie(self._gif_buffer, QByteArray())
+ view.preview_gif.setMovie(movie)
+
+ # If the animation only has 1 frame, it isn't animated and shouldn't be treated as such
+ if movie.frameCount() <= 1:
+ return None
+
+ # The animation has more than 1 frame, continue displaying it as an animation
+ self._switch_preview(_PreviewType.ANIMATED)
+ self.resizeEvent(
+ QResizeEvent(
+ QSize(stats.width, stats.height),
+ QSize(stats.width, stats.height),
+ )
+ )
+ movie.start()
+ stats.duration = movie.frameCount() // 60
+
+ return stats
+
+ def _display_image(self, filepath: Path):
+ """Renders the given file as an image, no matter its media type."""
+ self._switch_preview(_PreviewType.IMAGE)
+ self._render_thumb(filepath)
+
+ def hide_preview(self) -> None:
+ """Completely hide the file preview."""
+ self._switch_preview(None)
+ self._current_file = None
+ self._should_render_on_resize = False
+
+ @override
+ def resizeEvent(self, event: QResizeEvent) -> None:
+ self._update_image_size((self.size().width(), self.size().height()))
+
+ if (
+ self._current_file is not None
+ and self._should_render_on_resize
+ and self._rendered_res < self._preview_size
+ ):
+ self._render_thumb(self._current_file)
+
+ return super().resizeEvent(event)
+
+ @property
+ def media_player(self) -> MediaPlayer:
+ return self.layout().media_player
+
+ @property
+ def current_file(self) -> Path | None:
+ return self._current_file
+
+ def _get_image_stats(self, filepath: Path) -> FileAttributeData:
"""Get width and height of an image as dict."""
stats = FileAttributeData()
ext = filepath.suffix.lower()
@@ -73,13 +361,12 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData:
return stats
- def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None:
+ def _get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None:
"""Loads an animated image and returns gif data and size, if successful."""
ext = filepath.suffix.lower()
try:
image: Image.Image = Image.open(filepath)
-
if ext == ".apng":
image_bytes_io = io.BytesIO()
image.save(
@@ -102,7 +389,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None
logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e)
return None
- def __get_video_res(self, filepath: str) -> tuple[bool, QSize]:
+ def _get_video_res(self, filepath: str) -> tuple[bool, QSize]:
video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG)
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
@@ -112,14 +399,13 @@ def __get_video_res(self, filepath: str) -> tuple[bool, QSize]:
def display_file(self, filepath: Path) -> FileAttributeData:
"""Render a single file preview."""
self._current_file = filepath
-
ext = filepath.suffix.lower()
# Video
if MediaTypes.contains("video", ext, SEARCH) and is_readable_video(filepath):
size: QSize | None = None
try:
- success, size = self.__get_video_res(str(filepath))
+ success, size = self._get_video_res(str(filepath))
if not success:
size = None
except cv2.error as e:
@@ -131,40 +417,14 @@ def display_file(self, filepath: Path) -> FileAttributeData:
return self._display_audio(filepath)
# Animated Images
elif MediaTypes.contains("image.animated", ext, SEARCH):
- if (ret := self.__get_gif_data(filepath)) and (
+ if (ret := self._get_gif_data(filepath)) and (
stats := self._display_gif(ret[0], ret[1])
) is not None:
return stats
else:
self._display_image(filepath)
- return self.__get_image_stats(filepath)
+ return self._get_image_stats(filepath)
# Other Types (Including Images)
else:
self._display_image(filepath)
- return self.__get_image_stats(filepath)
-
- @override
- def _open_file_action_callback(self):
- if self._current_file:
- open_file(
- self._current_file,
- windows_start_command=self.__driver.settings.windows_start_command,
- )
-
- @override
- def _open_explorer_action_callback(self):
- if self._current_file:
- open_file(self._current_file, file_manager=True)
-
- @override
- def _delete_action_callback(self):
- if self._current_file:
- self.__driver.delete_files_callback(self._current_file)
-
- @override
- def _button_wrapper_callback(self):
- if self._current_file:
- open_file(
- self._current_file,
- windows_start_command=self.__driver.settings.windows_start_command,
- )
+ return self._get_image_stats(filepath)
diff --git a/src/tagstudio/qt/views/inspector_view.py b/src/tagstudio/qt/views/inspector_view.py
index 5d7609e9df..91bb95d43d 100644
--- a/src/tagstudio/qt/views/inspector_view.py
+++ b/src/tagstudio/qt/views/inspector_view.py
@@ -51,7 +51,7 @@ def ph_text(key: str) -> str:
driver.lib, driver.settings, ph_text("home.search_or_create_tags")
)
- self.preview_thumb = PreviewThumb(driver.lib, driver)
+ self.preview_thumb = PreviewThumb(driver)
self.file_attrs = FileAttributes(driver.lib, driver)
self.containers = TileList(driver.lib, driver)
diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py
index e250d59f8a..12427d6279 100644
--- a/src/tagstudio/qt/views/preview_thumb_view.py
+++ b/src/tagstudio/qt/views/preview_thumb_view.py
@@ -2,356 +2,77 @@
# SPDX-License-Identifier: GPL-3.0-only
-import math
-import time
-from enum import Enum, auto
-from pathlib import Path
-from typing import TYPE_CHECKING, override
+from typing import TYPE_CHECKING
-import structlog
-from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal
-from PySide6.QtGui import QAction, QMovie, QPixmap, QResizeEvent
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QAction
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStackedLayout, QWidget
-from tagstudio.core.library.alchemy.library import Library
from tagstudio.i18n.platform_strings import open_file_str, trash_term
from tagstudio.i18n.translations import Translations
-from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.qt.mixed.media_player import MediaPlayer
-from tagstudio.qt.qt_file_renderer import QtFileRenderer
-from tagstudio.qt.views.styles.rounded_pixmap_style import RoundedPixmapStyle
if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
-logger = structlog.get_logger(__name__)
+_DEFAULT_PREVIEW_SIZE = (272, 272)
-THUMB_SIZE_FACTOR = 2
+class PreviewThumbView(QStackedLayout):
+ """The layout for the file preview thumbnail widget."""
-
-class PreviewType(Enum):
- """Enum for which of the Inspector's stacked pages should be shown for a file."""
-
- ANIMATED = auto()
- AUDIO = auto()
- IMAGE = auto()
- TEXT = auto()
- VIDEO = auto()
-
-
-# TODO: Use newer MVC style guidelines
-class PreviewThumbView(QWidget):
- """The file preview thumbnail widget."""
-
- check_ffmpeg = Signal(bool)
- stats_updated = Signal(Path, FileAttributeData)
-
- __image_ratio: float
-
- _current_file: Path | None
- __should_render_on_resize: bool
- __rendered_res: tuple[int, int]
-
- def __init__(self, library: Library, driver: QtDriver) -> None:
+ def __init__(self, driver: QtDriver) -> None:
super().__init__()
- self._driver = driver
-
- self._preview_size: tuple[int, int] = (272, 272)
- self.__image_ratio = 1.0
-
- self.__should_render_on_resize = False
-
- self.__image_layout = QStackedLayout(self)
- self.__image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self.__image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
- self.__image_layout.setContentsMargins(0, 0, 0, 0)
-
- open_file_action = QAction(Translations["file.open_file"], self)
- open_file_action.triggered.connect(self._open_file_action_callback)
- open_explorer_action = QAction(open_file_str(), self)
- open_explorer_action.triggered.connect(self._open_explorer_action_callback)
- delete_action = QAction(
- Translations.format("trash.context.singular", trash_term=trash_term()),
- self,
+ self.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.setStackingMode(QStackedLayout.StackingMode.StackAll)
+ self.setContentsMargins(0, 0, 0, 0)
+
+ self.open_file_action = QAction(Translations["file.open_file"], self)
+ self.open_explorer_action = QAction(open_file_str(), self)
+ self.delete_action = QAction(
+ Translations.format("trash.context.singular", trash_term=trash_term()), self
)
- delete_action.triggered.connect(self._delete_action_callback)
- self.__button_wrapper = QPushButton()
- self.__button_wrapper.setMinimumSize(*self._preview_size)
- self.__button_wrapper.setFlat(True)
- self.__button_wrapper.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
- self.__button_wrapper.addAction(open_file_action)
- self.__button_wrapper.addAction(open_explorer_action)
- self.__button_wrapper.addAction(delete_action)
- self.__button_wrapper.clicked.connect(self._button_wrapper_callback)
+ self.button_wrapper = QPushButton()
+ self.button_wrapper.setMinimumSize(*_DEFAULT_PREVIEW_SIZE)
+ self.button_wrapper.setFlat(True)
+ self.button_wrapper.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
+ self.button_wrapper.addAction(self.open_file_action)
+ self.button_wrapper.addAction(self.open_explorer_action)
+ self.button_wrapper.addAction(self.delete_action)
# In testing, it didn't seem possible to center the widgets directly
# on the QStackedLayout. Adding sublayouts allows us to center the widgets.
- self.__preview_img_page = QWidget()
- self.__stacked_page_setup(self.__preview_img_page, self.__button_wrapper)
-
- self.__preview_gif = QLabel()
- self.__preview_gif.setMinimumSize(*self._preview_size)
- self.__preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
- self.__preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
- self.__preview_gif.addAction(open_file_action)
- self.__preview_gif.addAction(open_explorer_action)
- self.__preview_gif.addAction(delete_action)
- self.__gif_buffer: QBuffer = QBuffer()
-
- self.__preview_gif_page = QWidget()
- self.__stacked_page_setup(self.__preview_gif_page, self.__preview_gif)
-
- self.__media_player = MediaPlayer(driver)
- self.__media_player.addAction(open_file_action)
- self.__media_player.addAction(open_explorer_action)
- self.__media_player.addAction(delete_action)
- # QMediaPlayer loads duration asynchronously after setSource().
- self.__media_player.player.durationChanged.connect(
- self.__media_player_duration_changed_callback
- )
-
- # Need to watch for this to resize the player appropriately.
- self.__media_player.player.hasVideoChanged.connect(
- self.__media_player_video_changed_callback
- )
-
- self.__media_player_page = QWidget()
- self.__stacked_page_setup(self.__media_player_page, self.__media_player)
-
- self.__thumb_renderer = QtFileRenderer(driver.lib, driver.settings)
- self.__thumb_renderer.updated.connect(self.__thumb_renderer_updated_callback)
- self.__thumb_renderer.updated_ratio.connect(self.__thumb_renderer_updated_ratio_callback)
-
- self.__image_layout.addWidget(self.__preview_img_page)
- self.__image_layout.addWidget(self.__preview_gif_page)
- self.__image_layout.addWidget(self.__media_player_page)
-
- self.setMinimumSize(*self._preview_size)
+ self.preview_img_page = QWidget()
+ self._stacked_page_setup(self.preview_img_page, self.button_wrapper)
- self.hide_preview()
+ self.preview_gif = QLabel()
+ self.preview_gif.setMinimumSize(*_DEFAULT_PREVIEW_SIZE)
+ self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
+ self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
+ self.preview_gif.addAction(self.open_file_action)
+ self.preview_gif.addAction(self.open_explorer_action)
+ self.preview_gif.addAction(self.delete_action)
- def _open_file_action_callback(self):
- raise NotImplementedError
+ self.preview_gif_page = QWidget()
+ self._stacked_page_setup(self.preview_gif_page, self.preview_gif)
- def _open_explorer_action_callback(self):
- raise NotImplementedError
+ self.media_player = MediaPlayer(driver)
+ self.media_player.addAction(self.open_file_action)
+ self.media_player.addAction(self.open_explorer_action)
+ self.media_player.addAction(self.delete_action)
- def _delete_action_callback(self):
- raise NotImplementedError
+ self.media_player_page = QWidget()
+ self._stacked_page_setup(self.media_player_page, self.media_player)
- def _button_wrapper_callback(self):
- raise NotImplementedError
-
- def __media_player_video_changed_callback(self, video: bool) -> None:
- self.__update_image_size((self.size().width(), self.size().height()))
-
- def __media_player_duration_changed_callback(self, duration_ms: int) -> None:
- filepath = self.__media_player.filepath
- if filepath is None or duration_ms <= 0:
- return
-
- self.stats_updated.emit(
- filepath,
- FileAttributeData(duration=duration_ms // 1000),
- )
+ self.addWidget(self.preview_img_page)
+ self.addWidget(self.preview_gif_page)
+ self.addWidget(self.media_player_page)
- def __thumb_renderer_updated_callback(
- self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path
- ) -> None:
- self.__button_wrapper.setIcon(img)
-
- def __thumb_renderer_updated_ratio_callback(self, ratio: float) -> None:
- self.__image_ratio = ratio
- self.__update_image_size((self.size().width(), self.size().height()))
-
- def __stacked_page_setup(self, page: QWidget, widget: QWidget) -> None:
+ def _stacked_page_setup(self, page: QWidget, widget: QWidget) -> None:
layout = QHBoxLayout(page)
layout.addWidget(widget)
layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter)
layout.setContentsMargins(0, 0, 0, 0)
widget.setCursor(Qt.CursorShape.PointingHandCursor)
page.setLayout(layout)
-
- def __update_image_size(self, size: tuple[int, int]) -> None:
- adj_width: float = size[0]
- adj_height: float = size[1]
- # Landscape
- if self.__image_ratio > 1:
- adj_height = size[0] * (1 / self.__image_ratio)
- # Portrait
- elif self.__image_ratio <= 1:
- adj_width = size[1] * self.__image_ratio
-
- if adj_width > size[0]:
- adj_height = adj_height * (size[0] / adj_width)
- adj_width = size[0]
- elif adj_height > size[1]:
- adj_width = adj_width * (size[1] / adj_height)
- adj_height = size[1]
-
- adj_size = QSize(int(adj_width), int(adj_height))
-
- self._preview_size = (int(adj_width), int(adj_height))
- self.__button_wrapper.setMaximumSize(adj_size)
- self.__button_wrapper.setIconSize(adj_size)
- self.__preview_gif.setMaximumSize(adj_size)
- self.__preview_gif.setMinimumSize(adj_size)
-
- self.__media_player.setMaximumSize(adj_size)
- self.__media_player.setMinimumSize(adj_size)
-
- proxy_style = RoundedPixmapStyle(radius=8)
- self.__preview_gif.setStyle(proxy_style)
- self.__media_player.setStyle(proxy_style)
- m = self.__preview_gif.movie()
- if m:
- m.setScaledSize(adj_size)
-
- def __switch_preview(self, preview: PreviewType | None) -> None:
- if preview in [PreviewType.AUDIO, PreviewType.VIDEO]:
- self.__media_player.show()
- self.__image_layout.setCurrentWidget(self.__media_player_page)
- self.check_ffmpeg.emit(True) # noqa: FBT003
- else:
- self.__media_player.stop()
- self.__media_player.hide()
- self.check_ffmpeg.emit(False) # noqa: FBT003
-
- if preview in [PreviewType.IMAGE, PreviewType.AUDIO]:
- self.__button_wrapper.show()
- self.__image_layout.setCurrentWidget(
- self.__preview_img_page
- if preview == PreviewType.IMAGE
- else self.__media_player_page
- )
- else:
- self.__button_wrapper.hide()
-
- if preview == PreviewType.ANIMATED:
- self.__preview_gif.show()
- self.__image_layout.setCurrentWidget(self.__preview_gif_page)
- else:
- if self.__preview_gif.movie():
- self.__preview_gif.movie().stop()
- self.__gif_buffer.close()
- self.__preview_gif.hide()
-
- def __render_thumb(self, filepath: Path) -> None:
- self.__should_render_on_resize = True
-
- self.__rendered_res = (
- math.ceil(self._preview_size[0] * THUMB_SIZE_FACTOR),
- math.ceil(self._preview_size[1] * THUMB_SIZE_FACTOR),
- )
-
- # TODO: Make driver update the cache manager reference here instead of passing the driver.
- self.__thumb_renderer.render(
- self._driver.cache_manager,
- time.time(),
- filepath,
- self.__rendered_res,
- self.devicePixelRatio(),
- )
-
- def __update_media_player(self, filepath: Path) -> None:
- """Display either audio or video."""
- self.__media_player.play(filepath)
-
- def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData:
- self.__should_render_on_resize = False
-
- self.__switch_preview(PreviewType.VIDEO)
- self.__update_media_player(filepath)
- stats = FileAttributeData()
-
- if size is not None:
- stats.width = size.width()
- stats.height = size.height()
-
- self.__image_ratio = stats.width / stats.height
- self.resizeEvent(
- QResizeEvent(
- QSize(stats.width, stats.height),
- QSize(stats.width, stats.height),
- )
- )
-
- return stats
-
- def _display_audio(self, filepath: Path) -> FileAttributeData:
- self.__switch_preview(PreviewType.AUDIO)
- self.__render_thumb(filepath)
- self.__update_media_player(filepath)
- return FileAttributeData()
-
- def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None:
- """Update the animated image preview from a filepath."""
- self.__should_render_on_resize = False
-
- stats = FileAttributeData()
-
- # Ensure that any movie and buffer from previous animations are cleared.
- if self.__preview_gif.movie():
- self.__preview_gif.movie().stop()
- self.__gif_buffer.close()
-
- stats.width = size[0]
- stats.height = size[1]
-
- self.__image_ratio = stats.width / stats.height
-
- self.__gif_buffer.setData(gif_data)
- movie = QMovie(self.__gif_buffer, QByteArray())
- self.__preview_gif.setMovie(movie)
-
- # If the animation only has 1 frame, it isn't animated and shouldn't be treated as such
- if movie.frameCount() <= 1:
- return None
-
- # The animation has more than 1 frame, continue displaying it as an animation
- self.__switch_preview(PreviewType.ANIMATED)
- self.resizeEvent(
- QResizeEvent(
- QSize(stats.width, stats.height),
- QSize(stats.width, stats.height),
- )
- )
- movie.start()
-
- stats.duration = movie.frameCount() // 60
-
- return stats
-
- def _display_image(self, filepath: Path):
- """Renders the given file as an image, no matter its media type."""
- self.__switch_preview(PreviewType.IMAGE)
- self.__render_thumb(filepath)
-
- def hide_preview(self) -> None:
- """Completely hide the file preview."""
- self.__switch_preview(None)
- self._current_file = None
- self.__should_render_on_resize = False
-
- @override
- def resizeEvent(self, event: QResizeEvent) -> None:
- self.__update_image_size((self.size().width(), self.size().height()))
-
- if (
- self._current_file is not None
- and self.__should_render_on_resize
- and self.__rendered_res < self._preview_size
- ):
- self.__render_thumb(self._current_file)
-
- return super().resizeEvent(event)
-
- @property
- def media_player(self) -> MediaPlayer:
- return self.__media_player
-
- @property
- def current_file(self) -> Path | None:
- return self._current_file
From 36af12ba6e7ff4ae55a4cafbf21a2a653874de5b Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Thu, 24 Sep 2026 11:58:09 -0700
Subject: [PATCH 12/14] fix(ui): use consistent corner radius for preview thumb
---
LICENSES/CC-BY-SA-4.0.txt | 428 ------------------
src/tagstudio/previews/file_renderer.py | 37 +-
src/tagstudio/qt/controllers/preview_thumb.py | 75 +--
src/tagstudio/qt/mixed/media_player.py | 15 +-
src/tagstudio/qt/views/preview_thumb_view.py | 3 +
.../qt/views/styles/corner_radius_effect.py | 38 ++
.../qt/views/styles/rounded_pixmap_style.py | 32 --
7 files changed, 96 insertions(+), 532 deletions(-)
delete mode 100644 LICENSES/CC-BY-SA-4.0.txt
create mode 100644 src/tagstudio/qt/views/styles/corner_radius_effect.py
delete mode 100644 src/tagstudio/qt/views/styles/rounded_pixmap_style.py
diff --git a/LICENSES/CC-BY-SA-4.0.txt b/LICENSES/CC-BY-SA-4.0.txt
deleted file mode 100644
index 2d58298e6e..0000000000
--- a/LICENSES/CC-BY-SA-4.0.txt
+++ /dev/null
@@ -1,428 +0,0 @@
-Attribution-ShareAlike 4.0 International
-
-=======================================================================
-
-Creative Commons Corporation ("Creative Commons") is not a law firm and
-does not provide legal services or legal advice. Distribution of
-Creative Commons public licenses does not create a lawyer-client or
-other relationship. Creative Commons makes its licenses and related
-information available on an "as-is" basis. Creative Commons gives no
-warranties regarding its licenses, any material licensed under their
-terms and conditions, or any related information. Creative Commons
-disclaims all liability for damages resulting from their use to the
-fullest extent possible.
-
-Using Creative Commons Public Licenses
-
-Creative Commons public licenses provide a standard set of terms and
-conditions that creators and other rights holders may use to share
-original works of authorship and other material subject to copyright
-and certain other rights specified in the public license below. The
-following considerations are for informational purposes only, are not
-exhaustive, and do not form part of our licenses.
-
- Considerations for licensors: Our public licenses are
- intended for use by those authorized to give the public
- permission to use material in ways otherwise restricted by
- copyright and certain other rights. Our licenses are
- irrevocable. Licensors should read and understand the terms
- and conditions of the license they choose before applying it.
- Licensors should also secure all rights necessary before
- applying our licenses so that the public can reuse the
- material as expected. Licensors should clearly mark any
- material not subject to the license. This includes other CC-
- licensed material, or material used under an exception or
- limitation to copyright. More considerations for licensors:
- wiki.creativecommons.org/Considerations_for_licensors
-
- Considerations for the public: By using one of our public
- licenses, a licensor grants the public permission to use the
- licensed material under specified terms and conditions. If
- the licensor's permission is not necessary for any reason--for
- example, because of any applicable exception or limitation to
- copyright--then that use is not regulated by the license. Our
- licenses grant only permissions under copyright and certain
- other rights that a licensor has authority to grant. Use of
- the licensed material may still be restricted for other
- reasons, including because others have copyright or other
- rights in the material. A licensor may make special requests,
- such as asking that all changes be marked or described.
- Although not required by our licenses, you are encouraged to
- respect those requests where reasonable. More considerations
- for the public:
- wiki.creativecommons.org/Considerations_for_licensees
-
-=======================================================================
-
-Creative Commons Attribution-ShareAlike 4.0 International Public
-License
-
-By exercising the Licensed Rights (defined below), You accept and agree
-to be bound by the terms and conditions of this Creative Commons
-Attribution-ShareAlike 4.0 International Public License ("Public
-License"). To the extent this Public License may be interpreted as a
-contract, You are granted the Licensed Rights in consideration of Your
-acceptance of these terms and conditions, and the Licensor grants You
-such rights in consideration of benefits the Licensor receives from
-making the Licensed Material available under these terms and
-conditions.
-
-
-Section 1 -- Definitions.
-
- a. Adapted Material means material subject to Copyright and Similar
- Rights that is derived from or based upon the Licensed Material
- and in which the Licensed Material is translated, altered,
- arranged, transformed, or otherwise modified in a manner requiring
- permission under the Copyright and Similar Rights held by the
- Licensor. For purposes of this Public License, where the Licensed
- Material is a musical work, performance, or sound recording,
- Adapted Material is always produced where the Licensed Material is
- synched in timed relation with a moving image.
-
- b. Adapter's License means the license You apply to Your Copyright
- and Similar Rights in Your contributions to Adapted Material in
- accordance with the terms and conditions of this Public License.
-
- c. BY-SA Compatible License means a license listed at
- creativecommons.org/compatiblelicenses, approved by Creative
- Commons as essentially the equivalent of this Public License.
-
- d. Copyright and Similar Rights means copyright and/or similar rights
- closely related to copyright including, without limitation,
- performance, broadcast, sound recording, and Sui Generis Database
- Rights, without regard to how the rights are labeled or
- categorized. For purposes of this Public License, the rights
- specified in Section 2(b)(1)-(2) are not Copyright and Similar
- Rights.
-
- e. Effective Technological Measures means those measures that, in the
- absence of proper authority, may not be circumvented under laws
- fulfilling obligations under Article 11 of the WIPO Copyright
- Treaty adopted on December 20, 1996, and/or similar international
- agreements.
-
- f. Exceptions and Limitations means fair use, fair dealing, and/or
- any other exception or limitation to Copyright and Similar Rights
- that applies to Your use of the Licensed Material.
-
- g. License Elements means the license attributes listed in the name
- of a Creative Commons Public License. The License Elements of this
- Public License are Attribution and ShareAlike.
-
- h. Licensed Material means the artistic or literary work, database,
- or other material to which the Licensor applied this Public
- License.
-
- i. Licensed Rights means the rights granted to You subject to the
- terms and conditions of this Public License, which are limited to
- all Copyright and Similar Rights that apply to Your use of the
- Licensed Material and that the Licensor has authority to license.
-
- j. Licensor means the individual(s) or entity(ies) granting rights
- under this Public License.
-
- k. Share means to provide material to the public by any means or
- process that requires permission under the Licensed Rights, such
- as reproduction, public display, public performance, distribution,
- dissemination, communication, or importation, and to make material
- available to the public including in ways that members of the
- public may access the material from a place and at a time
- individually chosen by them.
-
- l. Sui Generis Database Rights means rights other than copyright
- resulting from Directive 96/9/EC of the European Parliament and of
- the Council of 11 March 1996 on the legal protection of databases,
- as amended and/or succeeded, as well as other essentially
- equivalent rights anywhere in the world.
-
- m. You means the individual or entity exercising the Licensed Rights
- under this Public License. Your has a corresponding meaning.
-
-
-Section 2 -- Scope.
-
- a. License grant.
-
- 1. Subject to the terms and conditions of this Public License,
- the Licensor hereby grants You a worldwide, royalty-free,
- non-sublicensable, non-exclusive, irrevocable license to
- exercise the Licensed Rights in the Licensed Material to:
-
- a. reproduce and Share the Licensed Material, in whole or
- in part; and
-
- b. produce, reproduce, and Share Adapted Material.
-
- 2. Exceptions and Limitations. For the avoidance of doubt, where
- Exceptions and Limitations apply to Your use, this Public
- License does not apply, and You do not need to comply with
- its terms and conditions.
-
- 3. Term. The term of this Public License is specified in Section
- 6(a).
-
- 4. Media and formats; technical modifications allowed. The
- Licensor authorizes You to exercise the Licensed Rights in
- all media and formats whether now known or hereafter created,
- and to make technical modifications necessary to do so. The
- Licensor waives and/or agrees not to assert any right or
- authority to forbid You from making technical modifications
- necessary to exercise the Licensed Rights, including
- technical modifications necessary to circumvent Effective
- Technological Measures. For purposes of this Public License,
- simply making modifications authorized by this Section 2(a)
- (4) never produces Adapted Material.
-
- 5. Downstream recipients.
-
- a. Offer from the Licensor -- Licensed Material. Every
- recipient of the Licensed Material automatically
- receives an offer from the Licensor to exercise the
- Licensed Rights under the terms and conditions of this
- Public License.
-
- b. Additional offer from the Licensor -- Adapted Material.
- Every recipient of Adapted Material from You
- automatically receives an offer from the Licensor to
- exercise the Licensed Rights in the Adapted Material
- under the conditions of the Adapter's License You apply.
-
- c. No downstream restrictions. You may not offer or impose
- any additional or different terms or conditions on, or
- apply any Effective Technological Measures to, the
- Licensed Material if doing so restricts exercise of the
- Licensed Rights by any recipient of the Licensed
- Material.
-
- 6. No endorsement. Nothing in this Public License constitutes or
- may be construed as permission to assert or imply that You
- are, or that Your use of the Licensed Material is, connected
- with, or sponsored, endorsed, or granted official status by,
- the Licensor or others designated to receive attribution as
- provided in Section 3(a)(1)(A)(i).
-
- b. Other rights.
-
- 1. Moral rights, such as the right of integrity, are not
- licensed under this Public License, nor are publicity,
- privacy, and/or other similar personality rights; however, to
- the extent possible, the Licensor waives and/or agrees not to
- assert any such rights held by the Licensor to the limited
- extent necessary to allow You to exercise the Licensed
- Rights, but not otherwise.
-
- 2. Patent and trademark rights are not licensed under this
- Public License.
-
- 3. To the extent possible, the Licensor waives any right to
- collect royalties from You for the exercise of the Licensed
- Rights, whether directly or through a collecting society
- under any voluntary or waivable statutory or compulsory
- licensing scheme. In all other cases the Licensor expressly
- reserves any right to collect such royalties.
-
-
-Section 3 -- License Conditions.
-
-Your exercise of the Licensed Rights is expressly made subject to the
-following conditions.
-
- a. Attribution.
-
- 1. If You Share the Licensed Material (including in modified
- form), You must:
-
- a. retain the following if it is supplied by the Licensor
- with the Licensed Material:
-
- i. identification of the creator(s) of the Licensed
- Material and any others designated to receive
- attribution, in any reasonable manner requested by
- the Licensor (including by pseudonym if
- designated);
-
- ii. a copyright notice;
-
- iii. a notice that refers to this Public License;
-
- iv. a notice that refers to the disclaimer of
- warranties;
-
- v. a URI or hyperlink to the Licensed Material to the
- extent reasonably practicable;
-
- b. indicate if You modified the Licensed Material and
- retain an indication of any previous modifications; and
-
- c. indicate the Licensed Material is licensed under this
- Public License, and include the text of, or the URI or
- hyperlink to, this Public License.
-
- 2. You may satisfy the conditions in Section 3(a)(1) in any
- reasonable manner based on the medium, means, and context in
- which You Share the Licensed Material. For example, it may be
- reasonable to satisfy the conditions by providing a URI or
- hyperlink to a resource that includes the required
- information.
-
- 3. If requested by the Licensor, You must remove any of the
- information required by Section 3(a)(1)(A) to the extent
- reasonably practicable.
-
- b. ShareAlike.
-
- In addition to the conditions in Section 3(a), if You Share
- Adapted Material You produce, the following conditions also apply.
-
- 1. The Adapter's License You apply must be a Creative Commons
- license with the same License Elements, this version or
- later, or a BY-SA Compatible License.
-
- 2. You must include the text of, or the URI or hyperlink to, the
- Adapter's License You apply. You may satisfy this condition
- in any reasonable manner based on the medium, means, and
- context in which You Share Adapted Material.
-
- 3. You may not offer or impose any additional or different terms
- or conditions on, or apply any Effective Technological
- Measures to, Adapted Material that restrict exercise of the
- rights granted under the Adapter's License You apply.
-
-
-Section 4 -- Sui Generis Database Rights.
-
-Where the Licensed Rights include Sui Generis Database Rights that
-apply to Your use of the Licensed Material:
-
- a. for the avoidance of doubt, Section 2(a)(1) grants You the right
- to extract, reuse, reproduce, and Share all or a substantial
- portion of the contents of the database;
-
- b. if You include all or a substantial portion of the database
- contents in a database in which You have Sui Generis Database
- Rights, then the database in which You have Sui Generis Database
- Rights (but not its individual contents) is Adapted Material,
- including for purposes of Section 3(b); and
-
- c. You must comply with the conditions in Section 3(a) if You Share
- all or a substantial portion of the contents of the database.
-
-For the avoidance of doubt, this Section 4 supplements and does not
-replace Your obligations under this Public License where the Licensed
-Rights include other Copyright and Similar Rights.
-
-
-Section 5 -- Disclaimer of Warranties and Limitation of Liability.
-
- a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
- EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
- AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
- ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
- IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
- WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
- PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
- ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
- KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
- ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
-
- b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
- TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
- NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
- INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
- COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
- USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
- ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
- DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
- IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
-
- c. The disclaimer of warranties and limitation of liability provided
- above shall be interpreted in a manner that, to the extent
- possible, most closely approximates an absolute disclaimer and
- waiver of all liability.
-
-
-Section 6 -- Term and Termination.
-
- a. This Public License applies for the term of the Copyright and
- Similar Rights licensed here. However, if You fail to comply with
- this Public License, then Your rights under this Public License
- terminate automatically.
-
- b. Where Your right to use the Licensed Material has terminated under
- Section 6(a), it reinstates:
-
- 1. automatically as of the date the violation is cured, provided
- it is cured within 30 days of Your discovery of the
- violation; or
-
- 2. upon express reinstatement by the Licensor.
-
- For the avoidance of doubt, this Section 6(b) does not affect any
- right the Licensor may have to seek remedies for Your violations
- of this Public License.
-
- c. For the avoidance of doubt, the Licensor may also offer the
- Licensed Material under separate terms or conditions or stop
- distributing the Licensed Material at any time; however, doing so
- will not terminate this Public License.
-
- d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
- License.
-
-
-Section 7 -- Other Terms and Conditions.
-
- a. The Licensor shall not be bound by any additional or different
- terms or conditions communicated by You unless expressly agreed.
-
- b. Any arrangements, understandings, or agreements regarding the
- Licensed Material not stated herein are separate from and
- independent of the terms and conditions of this Public License.
-
-
-Section 8 -- Interpretation.
-
- a. For the avoidance of doubt, this Public License does not, and
- shall not be interpreted to, reduce, limit, restrict, or impose
- conditions on any use of the Licensed Material that could lawfully
- be made without permission under this Public License.
-
- b. To the extent possible, if any provision of this Public License is
- deemed unenforceable, it shall be automatically reformed to the
- minimum extent necessary to make it enforceable. If the provision
- cannot be reformed, it shall be severed from this Public License
- without affecting the enforceability of the remaining terms and
- conditions.
-
- c. No term or condition of this Public License will be waived and no
- failure to comply consented to unless expressly agreed to by the
- Licensor.
-
- d. Nothing in this Public License constitutes or may be interpreted
- as a limitation upon, or waiver of, any privileges and immunities
- that apply to the Licensor or You, including from the legal
- processes of any jurisdiction or authority.
-
-
-=======================================================================
-
-Creative Commons is not a party to its public
-licenses. Notwithstanding, Creative Commons may elect to apply one of
-its public licenses to material it publishes and in those instances
-will be considered the “Licensor.” The text of the Creative Commons
-public licenses is dedicated to the public domain under the CC0 Public
-Domain Dedication. Except for the limited purpose of indicating that
-material is shared under a Creative Commons public license or as
-otherwise permitted by the Creative Commons policies published at
-creativecommons.org/policies, Creative Commons does not authorize the
-use of the trademark "Creative Commons" or any other trademark or logo
-of Creative Commons without its prior written consent including,
-without limitation, in connection with any unauthorized modifications
-to any of its public licenses or any other arrangements,
-understandings, or agreements concerning use of licensed material. For
-the avoidance of doubt, this paragraph does not form part of the
-public licenses.
-
-Creative Commons may be contacted at creativecommons.org.
-
diff --git a/src/tagstudio/previews/file_renderer.py b/src/tagstudio/previews/file_renderer.py
index d8a017be05..04916c2bda 100644
--- a/src/tagstudio/previews/file_renderer.py
+++ b/src/tagstudio/previews/file_renderer.py
@@ -96,9 +96,9 @@ def __init__(self, library: Library, settings: AppSettings) -> None:
self.settings = settings
# Cached thumbnail elements.
- # Key: Size + Pixel Ratio Tuple + Radius Scale
- # (Ex. (512, 512, 1.25, 4))
- self.thumb_masks: dict[tuple[int, int, float, float], Image.Image] = {}
+ # Key: Size + Pixel Ratio Tuple
+ # (Ex. (512, 512, 1.25))
+ self.thumb_masks: dict[tuple[int, int, float], Image.Image] = {}
self.raised_edges: dict[tuple[int, int, float], tuple[Image.Image, Image.Image]] = {}
# Key: ("name", UiColor, 512, 512, 1.25)
@@ -129,27 +129,19 @@ def _get_resource_id(self, url: Path) -> str:
return "file_generic"
# NOTE: This method will be replaced with frontend specific decorations (Qt painting)
- def _get_mask(
- self, size: tuple[int, int], pixel_ratio: float, scale_radius: bool = False
- ) -> Image.Image:
- """Return a thumbnail mask given a size, pixel ratio, and radius scaling option.
+ def _get_mask(self, size: tuple[int, int], pixel_ratio: float) -> Image.Image:
+ """Return a thumbnail mask given a size and pixel ratio.
If one is not already cached, a new one will be rendered.
Args:
size (tuple[int, int]): The size of the graphic.
pixel_ratio (float): The screen pixel ratio.
- scale_radius (bool): Option to scale the radius up (Used by the Inspector).
"""
- thumb_scale: int = 512
- radius_scale: float = 1
- if scale_radius:
- radius_scale = max(size[0], size[1]) / thumb_scale
-
- item: Image.Image | None = self.thumb_masks.get((*size, pixel_ratio, radius_scale))
+ item: Image.Image | None = self.thumb_masks.get((*size, pixel_ratio))
if not item:
- item = self._render_mask(size, pixel_ratio, radius_scale)
- self.thumb_masks[(*size, pixel_ratio, radius_scale)] = item
+ item = self._render_mask(size, pixel_ratio)
+ self.thumb_masks[(*size, pixel_ratio)] = item
return item
# NOTE: This method will be replaced with frontend specific decorations (Qt painting)
@@ -215,15 +207,12 @@ def _get_icon(
return item
# NOTE: This method will be replaced with frontend specific decorations (Qt painting)
- def _render_mask(
- self, size: tuple[int, int], pixel_ratio: float, radius_scale: float = 1
- ) -> Image.Image:
+ def _render_mask(self, size: tuple[int, int], pixel_ratio: float) -> Image.Image:
"""Render a thumbnail mask graphic.
Args:
size (tuple[int,int]): The size of the graphic.
pixel_ratio (float): The screen pixel ratio.
- radius_scale (float): The scale factor of the border radius (Used by the Inspector).
"""
smooth_factor: int = 2
radius_factor: int = 8
@@ -236,7 +225,7 @@ def _render_mask(
draw = ImageDraw.Draw(im)
draw.rounded_rectangle(
(0, 0) + tuple([d - 1 for d in im.size]),
- radius=math.ceil(radius_factor * smooth_factor * pixel_ratio * radius_scale),
+ radius=math.ceil(radius_factor * smooth_factor * pixel_ratio),
fill="white",
)
im = im.resize(
@@ -686,11 +675,7 @@ def fetch_cached_image(file_name: Path):
if not filepath.exists() or filepath.is_dir()
else render_default((512, 512), 2)
)
- render_mask_and_edge = False
- mask = self._get_mask(image.size, dpi_scale, scale_radius=True)
- bg = Image.new("RGBA", image.size, (0, 0, 0, 0))
- bg.paste(image, mask=mask.getchannel(0))
- image = bg
+ image = image.convert("RGBA")
# If the image couldn't be rendered, use a default media image.
if not image:
diff --git a/src/tagstudio/qt/controllers/preview_thumb.py b/src/tagstudio/qt/controllers/preview_thumb.py
index 123a881c54..4b6bce7ba6 100644
--- a/src/tagstudio/qt/controllers/preview_thumb.py
+++ b/src/tagstudio/qt/controllers/preview_thumb.py
@@ -14,7 +14,7 @@
import structlog
from PIL import Image, UnidentifiedImageError
from PIL.Image import DecompressionBombError
-from PySide6.QtCore import QBuffer, QByteArray, QSize, Signal
+from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal
from PySide6.QtGui import QMovie, QPixmap, QResizeEvent
from PySide6.QtWidgets import QWidget
from rawpy import LibRawFileUnsupportedError, LibRawIOError # pyright: ignore
@@ -27,7 +27,6 @@
from tagstudio.qt.qt_file_renderer import QtFileRenderer
from tagstudio.qt.utils.file_opener import open_file
from tagstudio.qt.views.preview_thumb_view import PreviewThumbView
-from tagstudio.qt.views.styles.rounded_pixmap_style import RoundedPixmapStyle
if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
@@ -66,6 +65,7 @@ def __init__(self, driver: QtDriver):
self._preview_size: tuple[int, int] = _DEFAULT_PREVIEW_SIZE
self._rendered_res: tuple[int, int] = (0, 0)
self._should_render_on_resize: bool = False
+ self._source_pixmap: QPixmap = QPixmap()
self.setMinimumSize(*self._preview_size)
self.setLayout(PreviewThumbView(driver))
@@ -132,7 +132,19 @@ def _media_player_duration_changed_callback(self, duration_ms: int) -> None:
def _thumb_renderer_updated_callback(
self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path
) -> None:
- self.layout().button_wrapper.setIcon(img)
+ self._source_pixmap = img
+ self._update_icon()
+
+ def _update_icon(self) -> None:
+ button = self.layout().button_wrapper
+ ratio = self.devicePixelRatio()
+ pixmap = self._source_pixmap.scaled(
+ button.iconSize() * ratio,
+ Qt.AspectRatioMode.IgnoreAspectRatio,
+ Qt.TransformationMode.SmoothTransformation,
+ )
+ pixmap.setDevicePixelRatio(ratio)
+ button.setIcon(pixmap)
def _thumb_renderer_updated_ratio_callback(self, ratio: float) -> None:
self._image_ratio = ratio
@@ -140,39 +152,38 @@ def _thumb_renderer_updated_ratio_callback(self, ratio: float) -> None:
def _update_image_size(self, size: tuple[int, int]) -> None:
view = self.layout()
- adj_width: float = size[0]
- adj_height: float = size[1]
+ scaled_width: float = size[0]
+ scaled_height: float = size[1]
# Landscape
if self._image_ratio > 1:
- adj_height = size[0] * (1 / self._image_ratio)
+ scaled_height = size[0] * (1 / self._image_ratio)
# Portrait
elif self._image_ratio <= 1:
- adj_width = size[1] * self._image_ratio
-
- if adj_width > size[0]:
- adj_height = adj_height * (size[0] / adj_width)
- adj_width = size[0]
- elif adj_height > size[1]:
- adj_width = adj_width * (size[1] / adj_height)
- adj_height = size[1]
-
- adj_size = QSize(int(adj_width), int(adj_height))
-
- self._preview_size = (int(adj_width), int(adj_height))
- view.button_wrapper.setMaximumSize(adj_size)
- view.button_wrapper.setIconSize(adj_size)
- view.preview_gif.setMaximumSize(adj_size)
- view.preview_gif.setMinimumSize(adj_size)
-
- view.media_player.setMaximumSize(adj_size)
- view.media_player.setMinimumSize(adj_size)
-
- proxy_style = RoundedPixmapStyle(radius=8)
- view.preview_gif.setStyle(proxy_style)
- view.media_player.setStyle(proxy_style)
- m = view.preview_gif.movie()
- if m:
- m.setScaledSize(adj_size)
+ scaled_width = size[1] * self._image_ratio
+
+ if scaled_width > size[0]:
+ scaled_height = scaled_height * (size[0] / scaled_width)
+ scaled_width = size[0]
+ elif scaled_height > size[1]:
+ scaled_width = scaled_width * (size[1] / scaled_height)
+ scaled_height = size[1]
+
+ scaled_size = QSize(int(scaled_width), int(scaled_height))
+
+ self._preview_size = (int(scaled_width), int(scaled_height))
+ view.button_wrapper.setMaximumSize(scaled_size)
+ view.button_wrapper.setMinimumSize(scaled_size)
+ view.button_wrapper.setIconSize(scaled_size)
+ self._update_icon()
+ view.preview_gif.setMaximumSize(scaled_size)
+ view.preview_gif.setMinimumSize(scaled_size)
+
+ view.media_player.setMaximumSize(scaled_size)
+ view.media_player.setMinimumSize(scaled_size)
+
+ movie = view.preview_gif.movie()
+ if movie:
+ movie.setScaledSize(scaled_size)
def _switch_preview(self, preview: _PreviewType | None) -> None:
view = self.layout()
diff --git a/src/tagstudio/qt/mixed/media_player.py b/src/tagstudio/qt/mixed/media_player.py
index a8029a311d..1439fb6594 100644
--- a/src/tagstudio/qt/mixed/media_player.py
+++ b/src/tagstudio/qt/mixed/media_player.py
@@ -8,18 +8,16 @@
from typing import override
import structlog
-from PIL import Image, ImageDraw, ImageQt
+from PIL import ImageQt
from PySide6.QtCore import QEvent, QObject, QRectF, QSize, Qt, QUrl, QVariantAnimation
from PySide6.QtGui import (
QAction,
- QBitmap,
QBrush,
QColor,
QLinearGradient,
QMouseEvent,
QPen,
QPixmap,
- QRegion,
QResizeEvent,
)
from PySide6.QtMultimedia import QAudioOutput, QMediaDevices, QMediaPlayer
@@ -264,16 +262,6 @@ def toggle_loop(self) -> None:
self.driver.settings.save()
self.player.setLoops(-1 if self.driver.settings.loop else 1)
- def apply_rounded_corners(self) -> None:
- """Apply a rounded corner effect to the video player."""
- width: int = int(max(self.contentsRect().size().width(), 0))
- height: int = int(max(self.contentsRect().size().height(), 0))
- mask = Image.new("RGBA", (width, height), (0, 0, 0, 255))
- draw = ImageDraw.Draw(mask)
- draw.rounded_rectangle((0, 0) + (width, height), radius=8, fill=(0, 0, 0, 0))
- final_mask: QPixmap = mask.getchannel("A").toqpixmap() # pyright: ignore[reportUnknownVariableType]
- self.setMask(QRegion(QBitmap(final_mask)))
-
def set_tint_opacity(self, opacity: int) -> None:
"""Set the opacity of the video player's tint.
@@ -474,7 +462,6 @@ def _update_controls(self, size: QSize) -> None:
self.centerOn(self.video_preview)
self.tint.setRect(0, 0, self.size().width(), self.size().height())
- self.apply_rounded_corners()
@override
def resizeEvent(self, event: QResizeEvent) -> None:
diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py
index 12427d6279..e8cf4b0068 100644
--- a/src/tagstudio/qt/views/preview_thumb_view.py
+++ b/src/tagstudio/qt/views/preview_thumb_view.py
@@ -11,6 +11,8 @@
from tagstudio.i18n.platform_strings import open_file_str, trash_term
from tagstudio.i18n.translations import Translations
from tagstudio.qt.mixed.media_player import MediaPlayer
+from tagstudio.qt.views.styles.corner_radius_effect import CornerRadiusEffect
+from tagstudio.qt.views.styles.stylesheets import RADIUS
if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
@@ -75,4 +77,5 @@ def _stacked_page_setup(self, page: QWidget, widget: QWidget) -> None:
layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter)
layout.setContentsMargins(0, 0, 0, 0)
widget.setCursor(Qt.CursorShape.PointingHandCursor)
+ widget.setGraphicsEffect(CornerRadiusEffect(widget, RADIUS))
page.setLayout(layout)
diff --git a/src/tagstudio/qt/views/styles/corner_radius_effect.py b/src/tagstudio/qt/views/styles/corner_radius_effect.py
new file mode 100644
index 0000000000..7a6f8c5208
--- /dev/null
+++ b/src/tagstudio/qt/views/styles/corner_radius_effect.py
@@ -0,0 +1,38 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from typing import override
+
+from PySide6.QtCore import QPoint, QRectF, Qt
+from PySide6.QtGui import QBrush, QPainter, QTransform
+from PySide6.QtWidgets import QGraphicsEffect, QWidget
+
+
+class CornerRadiusEffect(QGraphicsEffect):
+ """Add an anti-aliased corner radius effect to a widget."""
+
+ def __init__(self, parent: QWidget, radius: float) -> None:
+ super().__init__(parent)
+ self._radius = radius
+
+ @override
+ def draw(self, painter: QPainter) -> None:
+ offset = QPoint()
+ pixmap = self.sourcePixmap(
+ Qt.CoordinateSystem.LogicalCoordinates, offset, QGraphicsEffect.PixmapPadMode.NoPad
+ )
+ if pixmap.isNull():
+ return
+
+ brush = QBrush(pixmap)
+ brush.setTransform(QTransform.fromTranslate(offset.x(), offset.y()))
+
+ painter.save()
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(brush)
+ painter.drawRoundedRect(
+ QRectF(offset, pixmap.deviceIndependentSize()), self._radius, self._radius
+ )
+ painter.restore()
diff --git a/src/tagstudio/qt/views/styles/rounded_pixmap_style.py b/src/tagstudio/qt/views/styles/rounded_pixmap_style.py
deleted file mode 100644
index 33395a5a77..0000000000
--- a/src/tagstudio/qt/views/styles/rounded_pixmap_style.py
+++ /dev/null
@@ -1,32 +0,0 @@
-# SPDX-FileCopyrightText: (c) 2019 Edwin Yllanes
-# SPDX-License-Identifier: CC-BY-SA-4.0
-# See: https://stackoverflow.com/questions/54230005/qmovie-with-border-radius/54231484#54231484
-
-
-from typing import override
-
-from PySide6.QtCore import QRect
-from PySide6.QtGui import QBrush, QColor, QImage, QPainter, QPixmap
-from PySide6.QtWidgets import QProxyStyle
-
-
-class RoundedPixmapStyle(QProxyStyle):
- def __init__(self, radius: int = 8):
- super().__init__()
- self._radius = radius
-
- @override
- def drawItemPixmap(
- self, painter: QPainter, rect: QRect, alignment: int, pixmap: QPixmap | QImage
- ):
- painter.save()
- pix = QPixmap(pixmap.size())
- pix.fill(QColor("transparent"))
- p = QPainter(pix)
- p.setBrush(QBrush(pixmap))
- p.setPen(QColor("transparent"))
- p.setRenderHint(QPainter.RenderHint.Antialiasing)
- p.drawRoundedRect(pixmap.rect(), self._radius, self._radius)
- p.end()
- super().drawItemPixmap(painter, rect, alignment, pix)
- painter.restore()
From 5077ecab25fc489fb22438dc58d4c442647174ac Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Thu, 1 Oct 2026 12:56:14 -0700
Subject: [PATCH 13/14] fix(ui): fix scaling and updating issues with previews
---
src/tagstudio/qt/controllers/preview_thumb.py | 41 +++++++++++++++----
src/tagstudio/qt/views/preview_thumb_view.py | 5 ++-
2 files changed, 36 insertions(+), 10 deletions(-)
diff --git a/src/tagstudio/qt/controllers/preview_thumb.py b/src/tagstudio/qt/controllers/preview_thumb.py
index 4b6bce7ba6..0f4fc9a5ed 100644
--- a/src/tagstudio/qt/controllers/preview_thumb.py
+++ b/src/tagstudio/qt/controllers/preview_thumb.py
@@ -14,19 +14,21 @@
import structlog
from PIL import Image, UnidentifiedImageError
from PIL.Image import DecompressionBombError
-from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal
-from PySide6.QtGui import QMovie, QPixmap, QResizeEvent
+from PySide6.QtCore import QBuffer, QByteArray, QRectF, QSize, Qt, Signal
+from PySide6.QtGui import QBrush, QMovie, QPainter, QPixmap, QResizeEvent
from PySide6.QtWidgets import QWidget
from rawpy import LibRawFileUnsupportedError, LibRawIOError # pyright: ignore
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.query_lang.file_groups import SEARCH
+from tagstudio.core.utils.types import unwrap
from tagstudio.previews.video_tester import is_readable_video
from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.qt.mixed.media_player import MediaPlayer
from tagstudio.qt.qt_file_renderer import QtFileRenderer
from tagstudio.qt.utils.file_opener import open_file
from tagstudio.qt.views.preview_thumb_view import PreviewThumbView
+from tagstudio.qt.views.styles.stylesheets import RADIUS
if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
@@ -61,6 +63,7 @@ def __init__(self, driver: QtDriver):
self._current_file: Path | None = None
self._gif_buffer: QBuffer = QBuffer()
+ self._gif_size: QSize = QSize()
self._image_ratio: float = 1.0
self._preview_size: tuple[int, int] = _DEFAULT_PREVIEW_SIZE
self._rendered_res: tuple[int, int] = (0, 0)
@@ -133,24 +136,36 @@ def _thumb_renderer_updated_callback(
self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path
) -> None:
self._source_pixmap = img
- self._update_icon()
+ self._update_image_size((self.size().width(), self.size().height()), refresh_icon=True)
def _update_icon(self) -> None:
button = self.layout().button_wrapper
+ if self._source_pixmap.isNull():
+ button.setIcon(self._source_pixmap)
+ return
+
ratio = self.devicePixelRatio()
- pixmap = self._source_pixmap.scaled(
+ scaled = self._source_pixmap.scaled(
button.iconSize() * ratio,
Qt.AspectRatioMode.IgnoreAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
+ scaled.setDevicePixelRatio(1)
+ pixmap = QPixmap(scaled.size())
+ pixmap.fill(Qt.GlobalColor.transparent)
+ painter = QPainter(pixmap)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QBrush(scaled))
+ painter.drawRoundedRect(QRectF(pixmap.rect()), RADIUS * ratio, RADIUS * ratio)
+ painter.end()
pixmap.setDevicePixelRatio(ratio)
button.setIcon(pixmap)
def _thumb_renderer_updated_ratio_callback(self, ratio: float) -> None:
self._image_ratio = ratio
- self._update_image_size((self.size().width(), self.size().height()))
- def _update_image_size(self, size: tuple[int, int]) -> None:
+ def _update_image_size(self, size: tuple[int, int], refresh_icon: bool = False) -> None:
view = self.layout()
scaled_width: float = size[0]
scaled_height: float = size[1]
@@ -173,17 +188,24 @@ def _update_image_size(self, size: tuple[int, int]) -> None:
self._preview_size = (int(scaled_width), int(scaled_height))
view.button_wrapper.setMaximumSize(scaled_size)
view.button_wrapper.setMinimumSize(scaled_size)
- view.button_wrapper.setIconSize(scaled_size)
- self._update_icon()
+ if refresh_icon or view.button_wrapper.iconSize() != scaled_size:
+ view.button_wrapper.setIconSize(scaled_size)
+ self._update_icon()
view.preview_gif.setMaximumSize(scaled_size)
view.preview_gif.setMinimumSize(scaled_size)
view.media_player.setMaximumSize(scaled_size)
view.media_player.setMinimumSize(scaled_size)
+ for page in (view.preview_img_page, view.preview_gif_page, view.media_player_page):
+ unwrap(page.layout()).activate()
+
movie = view.preview_gif.movie()
if movie:
- movie.setScaledSize(scaled_size)
+ gif_max = max(self._gif_size.width(), self._gif_size.height())
+ display_max = max(scaled_size.width(), scaled_size.height()) * self.devicePixelRatio()
+ is_upscaling = gif_max < display_max
+ movie.setScaledSize(QSize() if is_upscaling else scaled_size)
def _switch_preview(self, preview: _PreviewType | None) -> None:
view = self.layout()
@@ -281,6 +303,7 @@ def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeD
stats.height = size[1]
self._image_ratio = stats.width / stats.height
+ self._gif_size = QSize(*size)
self._gif_buffer.setData(gif_data)
movie = QMovie(self._gif_buffer, QByteArray())
diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py
index e8cf4b0068..b5842bff7c 100644
--- a/src/tagstudio/qt/views/preview_thumb_view.py
+++ b/src/tagstudio/qt/views/preview_thumb_view.py
@@ -50,12 +50,15 @@ def __init__(self, driver: QtDriver) -> None:
self.preview_gif = QLabel()
self.preview_gif.setMinimumSize(*_DEFAULT_PREVIEW_SIZE)
+ self.preview_gif.setScaledContents(True)
self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
self.preview_gif.addAction(self.open_file_action)
self.preview_gif.addAction(self.open_explorer_action)
self.preview_gif.addAction(self.delete_action)
+ self.preview_gif.setGraphicsEffect(CornerRadiusEffect(self.preview_gif, RADIUS))
+
self.preview_gif_page = QWidget()
self._stacked_page_setup(self.preview_gif_page, self.preview_gif)
@@ -63,6 +66,7 @@ def __init__(self, driver: QtDriver) -> None:
self.media_player.addAction(self.open_file_action)
self.media_player.addAction(self.open_explorer_action)
self.media_player.addAction(self.delete_action)
+ self.media_player.setGraphicsEffect(CornerRadiusEffect(self.media_player, RADIUS))
self.media_player_page = QWidget()
self._stacked_page_setup(self.media_player_page, self.media_player)
@@ -77,5 +81,4 @@ def _stacked_page_setup(self, page: QWidget, widget: QWidget) -> None:
layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter)
layout.setContentsMargins(0, 0, 0, 0)
widget.setCursor(Qt.CursorShape.PointingHandCursor)
- widget.setGraphicsEffect(CornerRadiusEffect(widget, RADIUS))
page.setLayout(layout)
From c61bc87b357833319e65247e868e40b21410feb7 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sat, 3 Oct 2026 23:52:52 -0700
Subject: [PATCH 14/14] chore: update stale class names in test comments
---
tests/qt/test_tile_list.py | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/tests/qt/test_tile_list.py b/tests/qt/test_tile_list.py
index 634e3d9b7e..bb33701fc2 100644
--- a/tests/qt/test_tile_list.py
+++ b/tests/qt/test_tile_list.py
@@ -20,7 +20,7 @@ def test_update_selection_empty(qt_driver: QtDriver):
qt_driver.toggle_item_selection(1, append=True, bridge=False)
panel.set_selection(qt_driver.selected)
- # FieldContainer should hide all containers
+ # TileList should hide all tiles
for container in panel.containers._tiles:
assert container.isHidden()
@@ -32,7 +32,7 @@ def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
- # FieldContainer should show all applicable tags and field containers
+ # TileList should show all applicable tag and field tiles
for container in panel.containers._tiles:
assert not container.isHidden()
@@ -47,7 +47,7 @@ def test_update_selection_multiple(qt_driver: QtDriver):
qt_driver.toggle_item_selection(2, append=True, bridge=False)
panel.set_selection(qt_driver.selected)
- # FieldContainer should show mixed field editing
+ # TileList should show mixed field editing
for container in panel.containers._tiles:
assert container.isHidden()
@@ -136,7 +136,7 @@ def test_meta_tag_category(qt_driver: QtDriver, entry_full: Entry):
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
- # FieldContainer should hide all containers
+ # TileList should hide all tiles
assert len(panel.containers._tiles) == 3
for i, container in enumerate(panel.containers._tiles):
match i:
@@ -169,7 +169,7 @@ def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry):
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
- # FieldContainer should hide all containers
+ # TileList should hide all tiles
assert len(panel.containers._tiles) == 3
for i, container in enumerate(panel.containers._tiles):
match i: