From 1fb1a80d53bc910ba9ea7311e98d1c847f2f399f Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 29 Nov 2024 12:35:18 -0800 Subject: [PATCH] fix: ui/ux parity fixes for thumbnails and files (#608) * fix(ui): display loading icon before rendered thumb * fix: skip out of range thumbs * fix: optimize library refreshing * fix(ui): tag colors show correct names * fix(ui): ensure inner field containers are deleted * fix(ui): don't show default preview label text * fix: catch all missing file thumbs; clean up logs --- tagstudio/src/core/exceptions.py | 6 ++ tagstudio/src/core/library/alchemy/library.py | 2 + tagstudio/src/core/utils/refresh_dir.py | 51 ++++++++--- tagstudio/src/qt/helpers/file_opener.py | 21 +++-- tagstudio/src/qt/modals/build_tag.py | 2 +- tagstudio/src/qt/ts_qt.py | 31 +++++-- tagstudio/src/qt/widgets/fields.py | 5 +- tagstudio/src/qt/widgets/preview_panel.py | 14 +-- tagstudio/src/qt/widgets/thumb_renderer.py | 87 ++++++++++--------- tagstudio/tests/macros/test_refresh_dir.py | 3 +- 10 files changed, 145 insertions(+), 77 deletions(-) create mode 100644 tagstudio/src/core/exceptions.py diff --git a/tagstudio/src/core/exceptions.py b/tagstudio/src/core/exceptions.py new file mode 100644 index 00000000..10bec533 --- /dev/null +++ b/tagstudio/src/core/exceptions.py @@ -0,0 +1,6 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +class NoRendererError(Exception): ... diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index f9ba256a..82f79334 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -131,6 +131,7 @@ class Library: storage_path: Path | str | None engine: Engine | None folder: Folder | None + included_files: set[Path] = set() FILENAME: str = "ts_library.sqlite" @@ -140,6 +141,7 @@ class Library: self.library_dir = None self.storage_path = None self.folder = None + self.included_files = set() def open_library(self, library_dir: Path, storage_path: str | None = None) -> LibraryStatus: if storage_path == ":memory:": diff --git a/tagstudio/src/core/utils/refresh_dir.py b/tagstudio/src/core/utils/refresh_dir.py index 87b734ea..11980329 100644 --- a/tagstudio/src/core/utils/refresh_dir.py +++ b/tagstudio/src/core/utils/refresh_dir.py @@ -9,6 +9,19 @@ from src.core.library import Entry, Library logger = structlog.get_logger(__name__) +GLOBAL_IGNORE_SET: set[str] = set( + [ + TS_FOLDER_NAME, + "$RECYCLE.BIN", + ".Trashes", + ".Trash", + "tagstudio_thumbs", + ".fseventsd", + ".Spotlight-V100", + "System Volume Information", + ] +) + @dataclass class RefreshDirTracker: @@ -49,29 +62,45 @@ class RefreshDirTracker: self.files_not_in_library = [] dir_file_count = 0 - for path in lib_path.glob("**/*"): - str_path = str(path) - if path.is_dir(): + for f in lib_path.glob("**/*"): + end_time_loop = time() + # Yield output every 1/30 of a second + if (end_time_loop - start_time_loop) > 0.034: + yield dir_file_count + start_time_loop = time() + + # Skip if the file/path is already mapped in the Library + if f in self.library.included_files: + dir_file_count += 1 continue - if "$RECYCLE.BIN" in str_path or TS_FOLDER_NAME in str_path: + # Ignore if the file is a directory + if f.is_dir(): + continue + + # Ensure new file isn't in a globally ignored folder + skip: bool = False + for part in f.parts: + if part in GLOBAL_IGNORE_SET: + skip = True + break + if skip: continue dir_file_count += 1 - relative_path = path.relative_to(lib_path) + self.library.included_files.add(f) + + relative_path = f.relative_to(lib_path) # TODO - load these in batch somehow if not self.library.has_path_entry(relative_path): self.files_not_in_library.append(relative_path) - # Yield output every 1/30 of a second - if (time() - start_time_loop) > 0.034: - yield dir_file_count - start_time_loop = time() - end_time_total = time() + yield dir_file_count logger.info( "Directory scan time", path=lib_path, duration=(end_time_total - start_time_total), - new_files_count=dir_file_count, + files_not_in_lib=self.files_not_in_library, + files_scanned=dir_file_count, ) diff --git a/tagstudio/src/qt/helpers/file_opener.py b/tagstudio/src/qt/helpers/file_opener.py index d402e1de..f696d416 100644 --- a/tagstudio/src/qt/helpers/file_opener.py +++ b/tagstudio/src/qt/helpers/file_opener.py @@ -19,9 +19,9 @@ def open_file(path: str | Path, file_manager: bool = False): """Open a file in the default application or file explorer. Args: - path (str): The path to the file to open. - file_manager (bool, optional): Whether to open the file in the file manager - (e.g. Finder on macOS). Defaults to False. + path (str): The path to the file to open. + file_manager (bool, optional): Whether to open the file in the file manager + (e.g. Finder on macOS). Defaults to False. """ path = Path(path) logger.info("Opening file", path=path) @@ -93,7 +93,7 @@ class FileOpenerHelper: """Initialize the FileOpenerHelper. Args: - filepath (str): The path to the file to open. + filepath (str): The path to the file to open. """ self.filepath = str(filepath) @@ -101,7 +101,7 @@ class FileOpenerHelper: """Set the filepath to open. Args: - filepath (str): The path to the file to open. + filepath (str): The path to the file to open. """ self.filepath = str(filepath) @@ -115,20 +115,19 @@ class FileOpenerHelper: class FileOpenerLabel(QLabel): - def __init__(self, text, parent=None): + def __init__(self, parent=None): """Initialize the FileOpenerLabel. Args: - text (str): The text to display. - parent (QWidget, optional): The parent widget. Defaults to None. + parent (QWidget, optional): The parent widget. Defaults to None. """ - super().__init__(text, parent) + super().__init__(parent) def set_file_path(self, filepath): """Set the filepath to open. Args: - filepath (str): The path to the file to open. + filepath (str): The path to the file to open. """ self.filepath = filepath @@ -139,7 +138,7 @@ class FileOpenerLabel(QLabel): On a right click, show a context menu. Args: - event (QMouseEvent): The mouse press event. + event (QMouseEvent): The mouse press event. """ super().mousePressEvent(event) diff --git a/tagstudio/src/qt/modals/build_tag.py b/tagstudio/src/qt/modals/build_tag.py index 1ed8821d..fcb786ae 100644 --- a/tagstudio/src/qt/modals/build_tag.py +++ b/tagstudio/src/qt/modals/build_tag.py @@ -203,7 +203,7 @@ class BuildTagPanel(PanelWidget): self.color_field.setMaxVisibleItems(10) self.color_field.setStyleSheet("combobox-popup:0;") for color in TagColor: - self.color_field.addItem(color.name, userData=color.value) + self.color_field.addItem(color.name.replace("_", " ").title(), userData=color.value) # self.color_field.setProperty("appearance", "flat") self.color_field.currentIndexChanged.connect( lambda c: ( diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index f934892a..6a869cd8 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -1031,25 +1031,41 @@ class QtDriver(DriverMixin, QObject): self.flow_container.layout().update() self.main_window.update() - for idx, (entry, item_thumb) in enumerate( - zip_longest(self.frame_content, self.item_thumbs) - ): + is_grid_thumb = True + # Show loading placeholder icons + for entry, item_thumb in zip_longest(self.frame_content, self.item_thumbs): if not entry: item_thumb.hide() continue - filepath = self.lib.library_dir / entry.path - item_thumb = self.item_thumbs[idx] item_thumb.set_mode(ItemType.ENTRY) item_thumb.set_item_id(entry) # TODO - show after item is rendered item_thumb.show() + is_loading = True self.thumb_job_queue.put( ( item_thumb.renderer.render, - (sys.float_info.max, "", base_size, ratio, True, True), + (sys.float_info.max, "", base_size, ratio, is_loading, is_grid_thumb), + ) + ) + + # Show rendered thumbnails + for idx, (entry, item_thumb) in enumerate( + zip_longest(self.frame_content, self.item_thumbs) + ): + if not entry: + continue + + filepath = self.lib.library_dir / entry.path + is_loading = False + + self.thumb_job_queue.put( + ( + item_thumb.renderer.render, + (time.time(), filepath, base_size, ratio, is_loading, is_grid_thumb), ) ) @@ -1188,7 +1204,8 @@ class QtDriver(DriverMixin, QObject): self.filter.page_size = self.lib.prefs(LibraryPrefs.PAGE_SIZE) # TODO - make this call optional - self.add_new_files_callback() + if self.lib.entries_count < 10000: + self.add_new_files_callback() self.update_libs_list(path) title_text = f"{self.base_title} - Library '{self.lib.library_dir}'" diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index 2fb8d7fb..2c35f86f 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.py @@ -135,7 +135,10 @@ class FieldContainer(QWidget): def set_inner_widget(self, widget: "FieldWidget"): if self.field_layout.itemAt(0): - self.field_layout.itemAt(0).widget().deleteLater() + old: QWidget = self.field_layout.itemAt(0).widget() + self.field_layout.removeWidget(old) + old.deleteLater() + self.field_layout.addWidget(widget) def get_inner_widget(self): diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index e74d2dc7..107c14e8 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -162,23 +162,27 @@ class PreviewPanel(QWidget): image_layout.addWidget(self.preview_vid) image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter) self.image_container.setMinimumSize(*self.img_button_size) - self.file_label = FileOpenerLabel("filename") + self.file_label = FileOpenerLabel() + self.file_label.setObjectName("filenameLabel") self.file_label.setTextFormat(Qt.TextFormat.RichText) self.file_label.setWordWrap(True) self.file_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.file_label.setStyleSheet(file_label_style) - self.date_created_label = QLabel("dateCreatedLabel") + self.date_created_label = QLabel() + self.date_created_label.setObjectName("dateCreatedLabel") self.date_created_label.setAlignment(Qt.AlignmentFlag.AlignLeft) self.date_created_label.setTextFormat(Qt.TextFormat.RichText) self.date_created_label.setStyleSheet(date_style) - self.date_modified_label = QLabel("dateModifiedLabel") + self.date_modified_label = QLabel() + self.date_modified_label.setObjectName("dateModifiedLabel") self.date_modified_label.setAlignment(Qt.AlignmentFlag.AlignLeft) self.date_modified_label.setTextFormat(Qt.TextFormat.RichText) self.date_modified_label.setStyleSheet(date_style) - self.dimensions_label = QLabel("dimensionsLabel") + self.dimensions_label = QLabel() + self.dimensions_label.setObjectName("dimensionsLabel") self.dimensions_label.setWordWrap(True) self.dimensions_label.setStyleSheet(properties_style) @@ -480,7 +484,7 @@ class PreviewPanel(QWidget): if filepath and filepath.is_file(): created: dt = None if platform.system() == "Windows" or platform.system() == "Darwin": - created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined] + created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined, unused-ignore] else: created = dt.fromtimestamp(filepath.stat().st_ctime) modified: dt = dt.fromtimestamp(filepath.stat().st_mtime) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index be5d8899..f78724ae 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -45,6 +45,7 @@ from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions from PySide6.QtSvg import QSvgRenderer from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT +from src.core.exceptions import NoRendererError from src.core.media_types import MediaCategories, MediaType from src.core.palette import ColorType, UiColor, get_ui_color from src.core.utils.encoding import detect_char_encoding @@ -470,7 +471,7 @@ class ThumbRenderer(QObject): id3.ID3NoHeaderError, MutagenError, ) as e: - logger.error("Couldn't read album artwork", path=filepath, error=e) + logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__) return image def _audio_waveform_thumb( @@ -555,7 +556,7 @@ class ThumbRenderer(QObject): im.resize((size, size), Image.Resampling.BILINEAR) except exceptions.CouldntDecodeError as e: - logger.error("Couldn't render waveform", path=filepath.name, error=e) + logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__) return im @@ -581,7 +582,6 @@ class ThumbRenderer(QObject): except ( AttributeError, UnidentifiedImageError, - FileNotFoundError, TypeError, ) as e: if str(e) == "expected string or buffer": @@ -591,7 +591,7 @@ class ThumbRenderer(QObject): ) else: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im def _source_engine(self, filepath: Path) -> Image.Image: @@ -607,15 +607,14 @@ class ThumbRenderer(QObject): except ( AttributeError, UnidentifiedImageError, - FileNotFoundError, TypeError, struct.error, ) as e: if str(e) == "expected string or buffer": - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) else: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im @classmethod @@ -661,7 +660,7 @@ class ThumbRenderer(QObject): image_data = zip_file.read(file_name) im = Image.open(BytesIO(image_data)) except Exception as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im @@ -722,7 +721,7 @@ class ThumbRenderer(QObject): ) im = self._apply_overlay_color(bg, UiColor.PURPLE) except OSError as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im def _font_long_thumb(self, filepath: Path, size: int) -> Image.Image: @@ -753,7 +752,7 @@ class ThumbRenderer(QObject): )[-1] im = theme_fg_overlay(bg, use_alpha=False) except OSError as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im def _image_raw_thumb(self, filepath: Path) -> Image.Image: @@ -772,13 +771,12 @@ class ThumbRenderer(QObject): rgb, decoder_name="raw", ) - except DecompressionBombError as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) except ( + DecompressionBombError, rawpy._rawpy.LibRawIOError, rawpy._rawpy.LibRawFileUnsupportedError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im def _image_thumb(self, filepath: Path) -> Image.Image: @@ -796,13 +794,12 @@ class ThumbRenderer(QObject): new_bg = Image.new("RGB", im.size, color="#1e1e1e") new_bg.paste(im, mask=im.getchannel(3)) im = new_bg - im = ImageOps.exif_transpose(im) except ( UnidentifiedImageError, DecompressionBombError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im @classmethod @@ -955,7 +952,7 @@ class ThumbRenderer(QObject): UnicodeDecodeError, OSError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im def _video_thumb(self, filepath: Path) -> Image.Image: @@ -996,7 +993,7 @@ class ThumbRenderer(QObject): DecompressionBombError, OSError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im def render( @@ -1040,6 +1037,28 @@ class ThumbRenderer(QObject): "thumb_loading", theme_color, (adj_size, adj_size), pixel_ratio ) + def render_default() -> Image.Image: + if update_on_ratio_change: + self.updated_ratio.emit(1) + im = self._get_icon( + name=self._get_resource_id(_filepath), + color=theme_color, + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, + ) + return im + + def render_unlinked() -> Image.Image: + if update_on_ratio_change: + self.updated_ratio.emit(1) + im = self._get_icon( + name="broken_link_icon", + color=UiColor.RED, + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, + ) + return im + if is_loading: final = loading_thumb.resize((adj_size, adj_size), resample=Image.Resampling.BILINEAR) qim = ImageQt.ImageQt(final) @@ -1049,6 +1068,9 @@ class ThumbRenderer(QObject): self.updated_ratio.emit(1) elif _filepath: try: + # Missing Files ================================================ + if not _filepath.exists(): + raise FileNotFoundError ext: str = _filepath.suffix.lower() # Images ======================================================= if MediaCategories.is_ext_in_category( @@ -1122,10 +1144,8 @@ class ThumbRenderer(QObject): ): image = self._source_engine(_filepath) # No Rendered Thumbnail ======================================== - if not _filepath.exists(): - raise FileNotFoundError - elif not image: - raise UnidentifiedImageError + if not image: + raise NoRendererError orig_x, orig_y = image.size new_x, new_y = (adj_size, adj_size) @@ -1161,32 +1181,19 @@ class ThumbRenderer(QObject): final = Image.new("RGBA", image.size, (0, 0, 0, 0)) final.paste(image, mask=mask.getchannel(0)) - except FileNotFoundError as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - if update_on_ratio_change: - self.updated_ratio.emit(1) - final = self._get_icon( - name="broken_link_icon", - color=UiColor.RED, - size=(adj_size, adj_size), - pixel_ratio=pixel_ratio, - ) + except FileNotFoundError: + final = render_unlinked() except ( UnidentifiedImageError, DecompressionBombError, ValueError, ChildProcessError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + final = render_default() + except NoRendererError: + final = render_default() - if update_on_ratio_change: - self.updated_ratio.emit(1) - final = self._get_icon( - name=self._get_resource_id(_filepath), - color=theme_color, - size=(adj_size, adj_size), - pixel_ratio=pixel_ratio, - ) qim = ImageQt.ImageQt(final) if image: image.close() diff --git a/tagstudio/tests/macros/test_refresh_dir.py b/tagstudio/tests/macros/test_refresh_dir.py index 4655d399..a4d3e808 100644 --- a/tagstudio/tests/macros/test_refresh_dir.py +++ b/tagstudio/tests/macros/test_refresh_dir.py @@ -15,10 +15,11 @@ def test_refresh_new_files(library, exclude_mode): library.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, exclude_mode) library.set_prefs(LibraryPrefs.EXTENSION_LIST, [".md"]) registry = RefreshDirTracker(library=library) + library.included_files.clear() (library.library_dir / "FOO.MD").touch() # When - assert not list(registry.refresh_dir(library.library_dir)) + assert len(list(registry.refresh_dir(library.library_dir))) == 1 # Then assert registry.files_not_in_library == [pathlib.Path("FOO.MD")]