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
This commit is contained in:
Travis Abendshien
2024-11-29 12:35:18 -08:00
committed by GitHub
parent d152cd75d8
commit 1fb1a80d53
10 changed files with 145 additions and 77 deletions

View File

@@ -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): ...

View File

@@ -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:":

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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: (

View File

@@ -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}'"

View File

@@ -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):

View File

@@ -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)

View File

@@ -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()

View File

@@ -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")]