From becd985e1f07a4fac4578517b92b00ca6ca31dc0 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Fri, 31 Oct 2025 18:14:54 -0400 Subject: [PATCH 01/21] Handle unknown file durations --- src/tagstudio/qt/mixed/file_attributes.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/tagstudio/qt/mixed/file_attributes.py b/src/tagstudio/qt/mixed/file_attributes.py index 5704e6e9e..256ffcd80 100644 --- a/src/tagstudio/qt/mixed/file_attributes.py +++ b/src/tagstudio/qt/mixed/file_attributes.py @@ -242,15 +242,24 @@ def add_newline(stats_label_text: str) -> str: if stats.duration is not None: stats_label_text = add_newline(stats_label_text) + logger.debug("[FileAttributes] Updating file duration", duration=stats.duration) + + unknown_duration: str = "-:--" + try: - dur_str = str(timedelta(seconds=float(stats.duration)))[:-7] - if dur_str.startswith("0:"): - dur_str = dur_str[2:] - if dur_str.startswith("0"): - dur_str = dur_str[1:] + formatted_duration = str(timedelta(seconds=float(stats.duration)))[:-7] + logger.debug("[FileAttributes]", formatted_duration=formatted_duration) + if formatted_duration.startswith("0:"): + formatted_duration = formatted_duration[2:] + if formatted_duration.startswith("0"): + formatted_duration = formatted_duration[1:] except OverflowError: - dur_str = "-:--" - stats_label_text += f"{dur_str}" + formatted_duration = unknown_duration + + if formatted_duration == "": + formatted_duration = unknown_duration + + stats_label_text += f"{formatted_duration}" if font_family: stats_label_text = add_newline(stats_label_text) From 72e295e0726dd84106f421b73b5102aa2c05e11c Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Sat, 1 Nov 2025 09:47:31 -0400 Subject: [PATCH 02/21] Add file property widgets --- .../attributes/dimension_property_widget.py | 16 ++++++++ .../attributes/duration_property_widget.py | 37 +++++++++++++++++++ .../attributes/file_property_widget.py | 11 ++++++ 3 files changed, 64 insertions(+) create mode 100644 src/tagstudio/qt/views/preview_panel/attributes/dimension_property_widget.py create mode 100644 src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py create mode 100644 src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py diff --git a/src/tagstudio/qt/views/preview_panel/attributes/dimension_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/dimension_property_widget.py new file mode 100644 index 000000000..e49844caa --- /dev/null +++ b/src/tagstudio/qt/views/preview_panel/attributes/dimension_property_widget.py @@ -0,0 +1,16 @@ +from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget + + +class DimensionPropertyWidget(FilePropertyWidget): + """A widget representing a file's dimensions.""" + + def __init__(self) -> None: + super().__init__() + + self.setObjectName("dimensions_property") + + def set_value(self, **kwargs) -> None: + width: int = kwargs.get('width', 0) + height: int = kwargs.get('height', 0) + + self.setText(f"{width} x {height} px") \ No newline at end of file diff --git a/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py new file mode 100644 index 000000000..3209413cd --- /dev/null +++ b/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py @@ -0,0 +1,37 @@ +from datetime import timedelta + +import structlog + +from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget + +logger = structlog.get_logger(__name__) + + +class DurationPropertyWidget(FilePropertyWidget): + """A widget representing a file's duration.""" + + def __init__(self) -> None: + super().__init__() + + self.setObjectName("duration_property") + + def set_value(self, **kwargs) -> None: + unknown_duration: str = "-:--" + duration: int = kwargs.get("duration", 0) + + logger.debug("[DurationPropertyWidget] Updating duration", duration=duration) + + try: + formatted_duration = str(timedelta(seconds=float(duration)))[:-7] + logger.debug("[DurationPropertyWidget]", formatted_duration=formatted_duration) + if formatted_duration.startswith("0:"): + formatted_duration = formatted_duration[2:] + if formatted_duration.startswith("0"): + formatted_duration = formatted_duration[1:] + except OverflowError: + formatted_duration = unknown_duration + + if formatted_duration == "": + formatted_duration = unknown_duration + + self.setText(formatted_duration) \ No newline at end of file diff --git a/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py new file mode 100644 index 000000000..99330ef53 --- /dev/null +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py @@ -0,0 +1,11 @@ +from PySide6.QtWidgets import QLabel + + +class FilePropertyWidget(QLabel): + """A widget representing a property of a file.""" + + def __init__(self) -> None: + super().__init__() + + def set_value(self, **kwargs) -> None: + raise NotImplementedError() \ No newline at end of file From 890ea65b0553b342a9f5644ada080d937debc9fa Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Sat, 1 Nov 2025 09:48:10 -0400 Subject: [PATCH 03/21] a small amount of changes --- .../attributes/file_attributes_controller.py | 14 + .../preview_panel_controller.py | 18 +- .../thumbnail}/preview_thumb_controller.py | 64 ++-- src/tagstudio/qt/mixed/file_attributes.py | 278 ----------------- .../thumbnail}/media_player.py | 10 +- .../attributes/file_attributes_model.py | 14 + src/tagstudio/qt/views/main_window.py | 2 +- .../attributes/file_attributes_view.py | 294 ++++++++++++++++++ .../{ => preview_panel}/preview_panel_view.py | 52 ++-- .../thumbnail}/preview_thumb_view.py | 180 +++++------ 10 files changed, 508 insertions(+), 418 deletions(-) create mode 100644 src/tagstudio/qt/controllers/attributes/file_attributes_controller.py rename src/tagstudio/qt/controllers/{ => preview_panel}/preview_panel_controller.py (68%) rename src/tagstudio/qt/controllers/{ => preview_panel/thumbnail}/preview_thumb_controller.py (73%) delete mode 100644 src/tagstudio/qt/mixed/file_attributes.py rename src/tagstudio/qt/mixed/{ => preview_panel/thumbnail}/media_player.py (99%) create mode 100644 src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py create mode 100644 src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py rename src/tagstudio/qt/views/{ => preview_panel}/preview_panel_view.py (80%) rename src/tagstudio/qt/views/{ => preview_panel/thumbnail}/preview_thumb_view.py (63%) diff --git a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py new file mode 100644 index 000000000..f4f161577 --- /dev/null +++ b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py @@ -0,0 +1,14 @@ +import typing + +from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.views.preview_panel.attributes.file_attributes_view import FileAttributesView + +if typing.TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + + +class FileAttributes(FileAttributesView): + """A widget displaying a list of a file's attributes.""" + + def __init__(self, library: Library, driver: "QtDriver"): + super().__init__(library, driver) \ No newline at end of file diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel/preview_panel_controller.py similarity index 68% rename from src/tagstudio/qt/controllers/preview_panel_controller.py rename to src/tagstudio/qt/controllers/preview_panel/preview_panel_controller.py index 0cf666198..6cef381ea 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel/preview_panel_controller.py @@ -9,7 +9,8 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.qt.mixed.add_field import AddFieldModal from tagstudio.qt.mixed.tag_search import TagSearchModal -from tagstudio.qt.views.preview_panel_view import PreviewPanelView +from tagstudio.qt.models.preview_panel.attributes.file_attributes_model import FilePropertyType +from tagstudio.qt.views.preview_panel.preview_panel_view import PreviewPanelView if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -22,6 +23,9 @@ def __init__(self, library: Library, driver: "QtDriver"): self.__add_field_modal = AddFieldModal(self.lib) self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True) + self._thumb.dimensions_changed.connect(self._file_dimensions_changed_callback) + self._thumb.duration_changed.connect(self._file_duration_changed_callback) + def _add_field_button_callback(self): self.__add_field_modal.show() @@ -36,6 +40,18 @@ def _set_selection_callback(self): self.__add_field_modal.done.connect(self._add_field_to_selected) self.__add_tag_modal.tsp.tag_chosen.connect(self._add_tag_to_selected) + def _file_dimensions_changed_callback(self, width: int, height: int) -> None: + self._file_attributes.update_file_property( + FilePropertyType.DIMENSIONS, + width=width, height=height + ) + + def _file_duration_changed_callback(self, duration: int) -> None: + self._file_attributes.update_file_property( + FilePropertyType.DURATION, + duration=duration + ) + def _add_field_to_selected(self, field_list: list[QListWidgetItem]): self._fields.add_field_to_selected(field_list) if len(self._selected) == 1: diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_panel/thumbnail/preview_thumb_controller.py similarity index 73% rename from src/tagstudio/qt/controllers/preview_thumb_controller.py rename to src/tagstudio/qt/controllers/preview_panel/thumbnail/preview_thumb_controller.py index ecc5d96a2..c7005b238 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel/thumbnail/preview_thumb_controller.py @@ -10,14 +10,13 @@ import structlog from PIL import Image, UnidentifiedImageError from PIL.Image import DecompressionBombError -from PySide6.QtCore import QSize +from PySide6.QtCore import QSize, Signal from tagstudio.core.library.alchemy.library import Library from tagstudio.core.media_types import MediaCategories from tagstudio.qt.helpers.file_tester import is_readable_video -from tagstudio.qt.mixed.file_attributes import FileAttributeData from tagstudio.qt.utils.file_opener import open_file -from tagstudio.qt.views.preview_thumb_view import PreviewThumbView +from tagstudio.qt.views.preview_panel.thumbnail.preview_thumb_view import PreviewThumbView if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -27,6 +26,9 @@ class PreviewThumb(PreviewThumbView): + dimensions_changed = Signal(int, int) + duration_changed = Signal(int) + __current_file: Path def __init__(self, library: Library, driver: "QtDriver"): @@ -34,9 +36,11 @@ def __init__(self, library: Library, driver: "QtDriver"): self.__driver: QtDriver = driver - def __get_image_stats(self, filepath: Path) -> FileAttributeData: + self._media_player.duration_changed.connect(self.duration_changed.emit) + + def __get_image_size(self, filepath: Path) -> QSize: """Get width and height of an image as dict.""" - stats = FileAttributeData() + size = QSize() ext = filepath.suffix.lower() if filepath.is_dir(): @@ -46,8 +50,7 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: with rawpy.imread(str(filepath)) as raw: rgb = raw.postprocess() image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black") - stats.width = image.width - stats.height = image.height + size = QSize(image.width, image.height) except ( rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] @@ -57,8 +60,7 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True): try: image = Image.open(str(filepath)) - stats.width = image.width - stats.height = image.height + size = QSize(image.width, image.height) except ( DecompressionBombError, FileNotFoundError, @@ -69,9 +71,9 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: elif MediaCategories.IMAGE_VECTOR_TYPES.contains(ext, mime_fallback=True): pass # TODO - return stats + return size - def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None: + def __get_gif_data(self, filepath: Path) -> tuple[bytes, QSize] | None: """Loads an animated image and returns gif data and size, if successful.""" ext = filepath.suffix.lower() @@ -90,11 +92,11 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None ) image.close() image_bytes_io.seek(0) - return (image_bytes_io.read(), (image.width, image.height)) + return image_bytes_io.read(), QSize(image.width, image.height) else: image.close() with open(filepath, "rb") as f: - return (f.read(), (image.width, image.height)) + return f.read(), QSize(image.width, image.height) except (UnidentifiedImageError, FileNotFoundError) as e: logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) @@ -105,9 +107,9 @@ def __get_video_res(self, filepath: str) -> tuple[bool, QSize]: success, frame = video.read() frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) image = Image.fromarray(frame) - return (success, QSize(image.width, image.height)) + return success, QSize(image.width, image.height) - def display_file(self, filepath: Path) -> FileAttributeData: + def display_file(self, filepath: Path) -> None: """Render a single file preview.""" self.__current_file = filepath @@ -117,31 +119,41 @@ def display_file(self, filepath: Path) -> FileAttributeData: if MediaCategories.VIDEO_TYPES.contains(ext, mime_fallback=True) and is_readable_video( filepath ): - size: QSize | None = None + video_size: QSize | None = None try: - success, size = self.__get_video_res(str(filepath)) + success, video_size = self.__get_video_res(str(filepath)) if not success: - size = None + video_size = None except cv2.error as e: logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) - return self._display_video(filepath, size) + if video_size: + self.dimensions_changed.emit(video_size.width(), video_size.height()) + else: + self.dimensions_changed.emit(0, 0) + # Audio elif MediaCategories.AUDIO_TYPES.contains(ext, mime_fallback=True): - return self._display_audio(filepath) + self._display_audio(filepath) + # Animated Images elif MediaCategories.IMAGE_ANIMATED_TYPES.contains(ext, mime_fallback=True): - if (ret := self.__get_gif_data(filepath)) and ( - stats := self._display_gif(ret[0], ret[1]) - ) is not None: - return stats + gif_data = self.__get_gif_data(filepath) + if gif_data: + self._display_gif(*gif_data) + gif_size = gif_data[1] else: self._display_image(filepath) - return self.__get_image_stats(filepath) + gif_size = self.__get_image_size(filepath) + + self.dimensions_changed.emit(gif_size.width(), gif_size.height()) + # Other Types (Including Images) else: self._display_image(filepath) - return self.__get_image_stats(filepath) + + image_size: QSize = self.__get_image_size(filepath) + self.dimensions_changed.emit(image_size.width(), image_size.height()) def _open_file_action_callback(self): open_file( diff --git a/src/tagstudio/qt/mixed/file_attributes.py b/src/tagstudio/qt/mixed/file_attributes.py deleted file mode 100644 index 256ffcd80..000000000 --- a/src/tagstudio/qt/mixed/file_attributes.py +++ /dev/null @@ -1,278 +0,0 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - - -import os -import platform -import typing -from dataclasses import dataclass -from datetime import datetime as dt -from datetime import timedelta -from pathlib import Path - -import structlog -from humanfriendly import format_size -from PIL import ImageFont -from PySide6.QtCore import Qt -from PySide6.QtGui import QGuiApplication -from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget - -from tagstudio.core.enums import ShowFilepathOption, Theme -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.ignore import Ignore -from tagstudio.core.media_types import MediaCategories -from tagstudio.core.utils.types import unwrap -from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color -from tagstudio.qt.translations import Translations -from tagstudio.qt.utils.file_opener import FileOpenerHelper, FileOpenerLabel - -if typing.TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - -logger = structlog.get_logger(__name__) - - -@dataclass -class FileAttributeData: - width: int | None = None - height: int | None = None - duration: int | None = None - - -class FileAttributes(QWidget): - """The Preview Panel Widget.""" - - def __init__(self, library: Library, driver: "QtDriver"): - super().__init__() - root_layout = QVBoxLayout(self) - root_layout.setContentsMargins(0, 0, 0, 0) - root_layout.setSpacing(0) - - label_bg_color = ( - Theme.COLOR_BG_DARK.value - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_DARK_LABEL.value - ) - - self.date_style = "font-size:12px;" - self.file_label_style = "font-size: 12px" - self.properties_style = ( - f"background-color:{label_bg_color};" - "color:#FFFFFF;" - "font-family:Oxanium;" - "font-weight:bold;" - "font-size:12px;" - "border-radius:3px;" - "padding-top: 4px;" - "padding-right: 1px;" - "padding-bottom: 1px;" - "padding-left: 1px;" - ) - - 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(self.file_label_style) - - 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(self.date_style) - self.date_created_label.setHidden(True) - - 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(self.date_style) - self.date_modified_label.setHidden(True) - - self.dimensions_label = QLabel() - self.dimensions_label.setObjectName("dimensionsLabel") - self.dimensions_label.setWordWrap(True) - self.dimensions_label.setStyleSheet(self.properties_style) - self.dimensions_label.setHidden(True) - - self.date_container = QWidget() - date_layout = QVBoxLayout(self.date_container) - date_layout.setContentsMargins(0, 2, 0, 0) - date_layout.setSpacing(0) - date_layout.addWidget(self.date_created_label) - date_layout.addWidget(self.date_modified_label) - - root_layout.addWidget(self.file_label) - root_layout.addWidget(self.date_container) - root_layout.addWidget(self.dimensions_label) - self.library = library - self.driver = driver - - def update_date_label(self, filepath: Path | None = None) -> None: - """Update the "Date Created" and "Date Modified" file property labels.""" - if filepath and filepath.is_file(): - created: dt - if platform.system() == "Windows" or platform.system() == "Darwin": - 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) - self.date_created_label.setText( - f"{Translations['file.date_created']}:" - + f" {self.driver.settings.format_datetime(created)}" - ) - self.date_modified_label.setText( - f"{Translations['file.date_modified']}: " - f"{self.driver.settings.format_datetime(modified)}" - ) - self.date_created_label.setHidden(False) - self.date_modified_label.setHidden(False) - elif filepath: - self.date_created_label.setText( - f"{Translations['file.date_created']}: N/A" - ) - self.date_modified_label.setText( - f"{Translations['file.date_modified']}: N/A" - ) - self.date_created_label.setHidden(False) - self.date_modified_label.setHidden(False) - else: - self.date_created_label.setHidden(True) - self.date_modified_label.setHidden(True) - - def update_stats(self, filepath: Path | None = None, stats: FileAttributeData | None = None): - """Render the panel widgets with the newest data from the Library.""" - if not stats: - stats = FileAttributeData() - - if not filepath: - self.layout().setSpacing(0) - self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.file_label.setText(f"{Translations['preview.no_selection']}") - self.file_label.set_file_path(Path()) - self.file_label.setCursor(Qt.CursorShape.ArrowCursor) - self.dimensions_label.setText("") - self.dimensions_label.setHidden(True) - else: - ext = filepath.suffix.lower() - display_path = filepath - if self.driver.settings.show_filepath == ShowFilepathOption.SHOW_FULL_PATHS: - display_path = filepath - elif self.driver.settings.show_filepath == ShowFilepathOption.SHOW_RELATIVE_PATHS: - display_path = Path(filepath).relative_to(unwrap(self.library.library_dir)) - elif self.driver.settings.show_filepath == ShowFilepathOption.SHOW_FILENAMES_ONLY: - display_path = Path(filepath.name) - - self.layout().setSpacing(6) - self.file_label.setAlignment(Qt.AlignmentFlag.AlignLeft) - self.file_label.set_file_path(filepath) - self.dimensions_label.setHidden(False) - - file_str: str = "" - separator: str = f"{os.path.sep}" # Gray - for i, part in enumerate(display_path.parts): - part_ = part.strip(os.path.sep) - if i != len(display_path.parts) - 1: - file_str += f"{'\u200b'.join(part_)}{separator}" - else: - if file_str != "": - file_str += "
" - file_str += f"{'\u200b'.join(part_)}" - self.file_label.setText(file_str) - self.file_label.setCursor(Qt.CursorShape.PointingHandCursor) - self.opener = FileOpenerHelper(filepath) - - # Initialize the possible stat variables - stats_label_text = "" - ext_display: str = "" - file_size: str = "" - font_family: str = "" - - # Attempt to populate the stat variables - ext_display = ext.upper()[1:] or filepath.stem.upper() - if filepath and filepath.is_file(): - try: - file_size = format_size(filepath.stat().st_size) - - if MediaCategories.is_ext_in_category( - ext, MediaCategories.FONT_TYPES, mime_fallback=True - ): - font = ImageFont.truetype(filepath) - font_family = f"{font.getname()[0]} ({font.getname()[1]}) " - except (FileNotFoundError, OSError) as e: - logger.error( - "[FileAttributes] Could not process file stats", filepath=filepath, error=e - ) - - # Format and display any stat variables - def add_newline(stats_label_text: str) -> str: - if stats_label_text and stats_label_text[-4:] != "
": - return stats_label_text + "
" - return stats_label_text - - if ext_display: - stats_label_text += ext_display - red = get_ui_color(ColorType.PRIMARY, UiColor.RED) - orange = get_ui_color(ColorType.PRIMARY, UiColor.ORANGE) - - if Ignore.compiled_patterns and Ignore.compiled_patterns.match( - filepath.relative_to(unwrap(self.library.library_dir)) - ): - stats_label_text = ( - f"{stats_label_text}" - f" • " - f"{Translations['preview.ignored'].upper()}" - ) - if not filepath.exists(): - stats_label_text = ( - f"{stats_label_text}" - f" • " - f"{Translations['preview.unlinked'].upper()}" - ) - if file_size: - stats_label_text += f" • {file_size}" - elif file_size: - stats_label_text += file_size - - if stats.width is not None and stats.height is not None: - stats_label_text = add_newline(stats_label_text) - stats_label_text += f"{stats.width} x {stats.height} px" - - if stats.duration is not None: - stats_label_text = add_newline(stats_label_text) - logger.debug("[FileAttributes] Updating file duration", duration=stats.duration) - - unknown_duration: str = "-:--" - - try: - formatted_duration = str(timedelta(seconds=float(stats.duration)))[:-7] - logger.debug("[FileAttributes]", formatted_duration=formatted_duration) - if formatted_duration.startswith("0:"): - formatted_duration = formatted_duration[2:] - if formatted_duration.startswith("0"): - formatted_duration = formatted_duration[1:] - except OverflowError: - formatted_duration = unknown_duration - - if formatted_duration == "": - formatted_duration = unknown_duration - - stats_label_text += f"{formatted_duration}" - - if font_family: - stats_label_text = add_newline(stats_label_text) - stats_label_text += f"{font_family}" - - self.dimensions_label.setText(stats_label_text) - - def update_multi_selection(self, count: int): - """Format attributes for multiple selected items.""" - self.layout().setSpacing(0) - self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.file_label.setText(Translations.format("preview.multiple_selection", count=count)) - self.file_label.setCursor(Qt.CursorShape.ArrowCursor) - self.file_label.set_file_path(Path()) - self.dimensions_label.setText("") - self.dimensions_label.setHidden(True) diff --git a/src/tagstudio/qt/mixed/media_player.py b/src/tagstudio/qt/mixed/preview_panel/thumbnail/media_player.py similarity index 99% rename from src/tagstudio/qt/mixed/media_player.py rename to src/tagstudio/qt/mixed/preview_panel/thumbnail/media_player.py index 065e73d40..1fb6be50c 100644 --- a/src/tagstudio/qt/mixed/media_player.py +++ b/src/tagstudio/qt/mixed/preview_panel/thumbnail/media_player.py @@ -9,7 +9,7 @@ import structlog from PIL import Image, ImageDraw -from PySide6.QtCore import QEvent, QObject, QRectF, QSize, Qt, QUrl, QVariantAnimation +from PySide6.QtCore import QEvent, QObject, QRectF, QSize, Qt, QUrl, QVariantAnimation, Signal from PySide6.QtGui import ( QAction, QBitmap, @@ -50,12 +50,15 @@ class MediaPlayer(QGraphicsView): Gives a basic control set to manage media playback. """ + duration_changed = Signal(int) + video_preview: "VideoPreview | None" = None def __init__(self, driver: "QtDriver") -> None: super().__init__() self.driver = driver + slider_style = """ QSlider { background: transparent; @@ -105,7 +108,7 @@ def __init__(self, driver: "QtDriver") -> None: border: none; } """) - self.setObjectName("mediaPlayer") + self.setObjectName("media_player") self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.video_preview = VideoPreview() @@ -132,6 +135,8 @@ def __init__(self, driver: "QtDriver") -> None: False # Q MediaPlayer.PlaybackState shows StoppedState when changing tracks ) + self.player.durationChanged.connect(self.duration_changed.emit) + self.player.positionChanged.connect(self.player_position_changed) self.player.mediaStatusChanged.connect(self.media_status_changed) self.player.playingChanged.connect(self.playing_changed) @@ -489,7 +494,6 @@ def resizeEvent(self, event: QResizeEvent) -> None: def volume_slider_changed(self, position: int) -> None: self.player.audioOutput().setVolume(position / 100) - class VideoPreview(QGraphicsVideoItem): @override def boundingRect(self): diff --git a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py new file mode 100644 index 000000000..6fd52e70d --- /dev/null +++ b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py @@ -0,0 +1,14 @@ +from enum import Enum + +from tagstudio.qt.views.preview_panel.attributes.dimension_property_widget import DimensionPropertyWidget +from tagstudio.qt.views.preview_panel.attributes.duration_property_widget import DurationPropertyWidget +from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget + + +class FilePropertyType(Enum): + DIMENSIONS = "dimensions", DimensionPropertyWidget + DURATION = "duration", DurationPropertyWidget + + def __init__(self, name: str, widget_class: type[FilePropertyWidget]): + self.__name = name + self.widget_class = widget_class \ No newline at end of file diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py index df675fbe6..92a6924d5 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -35,7 +35,7 @@ from tagstudio.core.enums import ShowFilepathOption from tagstudio.core.library.alchemy.enums import SortingModeEnum -from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel +from tagstudio.qt.controllers.preview_panel.preview_panel_controller import PreviewPanel from tagstudio.qt.helpers.color_overlay import theme_fg_overlay from tagstudio.qt.mixed.landing import LandingWidget from tagstudio.qt.mixed.pagination import Pagination diff --git a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py new file mode 100644 index 000000000..d3570f406 --- /dev/null +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py @@ -0,0 +1,294 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import os +import platform +import typing +from dataclasses import dataclass +from datetime import datetime as dt +from pathlib import Path + +import structlog +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication +from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget + +from tagstudio.core.enums import ShowFilepathOption, Theme +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.models.preview_panel.attributes.file_attributes_model import FilePropertyType +from tagstudio.qt.translations import Translations +from tagstudio.qt.utils.file_opener import FileOpenerLabel +from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget + +if typing.TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +@dataclass +class FileAttributeData: + width: int | None = None + height: int | None = None + duration: int | None = None + + +FILE_NAME_LABEL_STYLE = "font-size: 12px;" + +DATE_LABEL_STYLE = "font-size: 12px;" + +class FileAttributesView(QWidget): + """A widget displaying a list of a file's attributes.""" + + def __init__(self, library: Library, driver: "QtDriver"): + super().__init__() + self.library = library + self.driver = driver + + label_bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + properties_style = f""" + background-color: {label_bg_color}; + color: #FFFFFF; + font-family: Oxanium; + font-weight: bold; + font-size: 12px; + border-radius: 3px; + padding-top: 4px; + padding-right: 1px; + padding-bottom: 1px; + padding-left: 1px; + """ + + self.__root_layout = QVBoxLayout(self) + self.__root_layout.setContentsMargins(0, 0, 0, 0) + self.__root_layout.setSpacing(6) + + # File name + self.file_path_label = FileOpenerLabel() + self.file_path_label.setObjectName("file_path_label") + self.file_path_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.file_path_label.setWordWrap(True) + self.file_path_label.setTextFormat(Qt.TextFormat.RichText) + self.file_path_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.file_path_label.setCursor(Qt.CursorShape.PointingHandCursor) + self.file_path_label.setStyleSheet(FILE_NAME_LABEL_STYLE) + + self.__root_layout.addWidget(self.file_path_label) + + # Date container + self.date_properties = QWidget() + self.date_properties.setObjectName("date_properties") + + self.date_properties_layout = QVBoxLayout(self.date_properties) + self.date_properties_layout.setObjectName("date_properties_layout") + self.date_properties_layout.setContentsMargins(0, 2, 0, 0) + self.date_properties_layout.setSpacing(0) + + self.__root_layout.addWidget(self.date_properties) + + # Date created + self.date_created_label = QLabel() + self.date_created_label.setObjectName("date_created_label") + self.date_created_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.date_created_label.setTextFormat(Qt.TextFormat.RichText) + self.date_created_label.setStyleSheet(DATE_LABEL_STYLE) + self.date_created_label.setHidden(True) + + self.date_properties_layout.addWidget(self.date_created_label) + + # Date modified + self.date_modified_label = QLabel() + self.date_modified_label.setObjectName("date_modified_label") + self.date_modified_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.date_modified_label.setTextFormat(Qt.TextFormat.RichText) + self.date_modified_label.setStyleSheet(DATE_LABEL_STYLE) + self.date_modified_label.setHidden(True) + + self.date_properties_layout.addWidget(self.date_modified_label) + + # File properties + self.properties = QWidget() + self.properties.setObjectName("properties") + self.properties.setStyleSheet(properties_style) + + self.properties_layout = QVBoxLayout(self.properties) + self.properties_layout.setObjectName("properties_layout") + self.properties_layout.setContentsMargins(0, 0, 0, 0) + self.properties_layout.setSpacing(0) + + self.__root_layout.addWidget(self.properties) + + self.__property_widgets: dict[FilePropertyType, FilePropertyWidget] = {} + + def update_file_path(self, file_path: Path) -> None: + self.file_path_label.set_file_path(file_path) + + # Format the path according to the user's settings + display_path: Path = file_path + match self.driver.settings.show_filepath: + case ShowFilepathOption.SHOW_FULL_PATHS: + display_path = file_path + case ShowFilepathOption.SHOW_RELATIVE_PATHS: + display_path = Path(file_path).relative_to(unwrap(self.library.library_dir)) + case ShowFilepathOption.SHOW_FILENAMES_ONLY: + display_path = Path(file_path.name) + + # Stringify the path + path_string: str = "" + path_separator: str = f"{os.path.sep}" # Gray + for i, part in enumerate(display_path.parts): + directory_name = part.strip(os.path.sep) + if i < len(display_path.parts) - 1: + path_string += f"{'\u200b'.join(directory_name)}{path_separator}" + else: + if path_string != "": + path_string += "
" + path_string += f"{'\u200b'.join(directory_name)}" + + self.file_path_label.setText(path_string) + + + def update_date_label(self, filepath: Path | None = None) -> None: + """Update the "Date Created" and "Date Modified" file property labels.""" + if filepath and filepath.is_file(): + created: dt + if platform.system() == "Windows" or platform.system() == "Darwin": + 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) + self.date_created_label.setText( + f"{Translations['file.date_created']}:" + + f" {self.driver.settings.format_datetime(created)}" + ) + self.date_modified_label.setText( + f"{Translations['file.date_modified']}: " + f"{self.driver.settings.format_datetime(modified)}" + ) + self.date_created_label.setHidden(False) + self.date_modified_label.setHidden(False) + elif filepath: + self.date_created_label.setText( + f"{Translations['file.date_created']}: N/A" + ) + self.date_modified_label.setText( + f"{Translations['file.date_modified']}: N/A" + ) + self.date_created_label.setHidden(False) + self.date_modified_label.setHidden(False) + else: + self.date_created_label.setHidden(True) + self.date_modified_label.setHidden(True) + + def update_file_property(self, property_type: FilePropertyType, **kwargs) -> None: + """Update a property of the file.""" + logger.debug("[FileAttributes] Updating file property", type=property_type, **kwargs) + + if property_type not in self.__property_widgets: + property_widget: FilePropertyWidget = property_type.widget_class() + self.__property_widgets[property_type] = property_widget + self.properties_layout.addWidget(property_widget) + + self.__property_widgets[property_type].set_value(**kwargs) + + # def update_stats(self, filepath: Path | None = None, stats: FileAttributeData | None = None): + # """Render the panel widgets with the newest data from the Library.""" + # if not stats: + # stats = FileAttributeData() + # + # else: + # ext = filepath.suffix.lower() + # + # # Attempt to populate the stat variables + # ext_display = ext.upper()[1:] or filepath.stem.upper() + # if filepath and filepath.is_file(): + # try: + # file_size = format_size(filepath.stat().st_size) + # + # if MediaCategories.is_ext_in_category( + # ext, MediaCategories.FONT_TYPES, mime_fallback=True + # ): + # font = ImageFont.truetype(filepath) + # font_family = f"{font.getname()[0]} ({font.getname()[1]}) " + # except (FileNotFoundError, OSError) as e: + # logger.error( + # "[FileAttributes] Could not process file stats", filepath=filepath, error=e + # ) + # + # # Format and display any stat variables + # def add_newline(stats_label_text: str) -> str: + # if stats_label_text and stats_label_text[-4:] != "
": + # return stats_label_text + "
" + # return stats_label_text + # + # if ext_display: + # stats_label_text += ext_display + # red = get_ui_color(ColorType.PRIMARY, UiColor.RED) + # orange = get_ui_color(ColorType.PRIMARY, UiColor.ORANGE) + # + # if Ignore.compiled_patterns and Ignore.compiled_patterns.match( + # filepath.relative_to(unwrap(self.library.library_dir)) + # ): + # stats_label_text = ( + # f"{stats_label_text}" + # f" • " + # f"{Translations['preview.ignored'].upper()}" + # ) + # if not filepath.exists(): + # stats_label_text = ( + # f"{stats_label_text}" + # f" • " + # f"{Translations['preview.unlinked'].upper()}" + # ) + # if file_size: + # stats_label_text += f" • {file_size}" + # elif file_size: + # stats_label_text += file_size + # + # if font_family: + # stats_label_text = add_newline(stats_label_text) + # stats_label_text += f"{font_family}" + # + # self.dimensions_label.setText(stats_label_text) + + def set_selection_size(self, num_selected: int): + match num_selected: + case 0: + # File path label + self.file_path_label.setText(f"{Translations['preview.no_selection']}") + self.file_path_label.set_file_path(Path()) + self.file_path_label.setCursor(Qt.CursorShape.ArrowCursor) + self.file_path_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Properties + self.date_created_label.setHidden(True) + self.date_modified_label.setHidden(True) + self.properties.setHidden(True) + case 1: + # File path label + self.file_path_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.file_path_label.setCursor(Qt.CursorShape.PointingHandCursor) + + # Properties + self.date_created_label.setHidden(False) + self.date_modified_label.setHidden(False) + self.properties.setHidden(False) + case _ if num_selected > 1: + # File path label + self.file_path_label.setText( + Translations.format("preview.multiple_selection", count=num_selected) + ) + self.file_path_label.set_file_path(Path()) + self.file_path_label.setCursor(Qt.CursorShape.ArrowCursor) + self.file_path_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Properties + self.date_created_label.setHidden(True) + self.date_modified_label.setHidden(True) + self.properties.setHidden(True) diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel/preview_panel_view.py similarity index 80% rename from src/tagstudio/qt/views/preview_panel_view.py rename to src/tagstudio/qt/views/preview_panel/preview_panel_view.py index 5ae7004cd..01126a79e 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel/preview_panel_view.py @@ -1,6 +1,7 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio - +from functools import partial +import random import traceback import typing from pathlib import Path @@ -19,11 +20,12 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.utils.types import unwrap -from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb +from tagstudio.qt.controllers.attributes.file_attributes_controller import FileAttributes +from tagstudio.qt.controllers.preview_panel.thumbnail.preview_thumb_controller import PreviewThumb from tagstudio.qt.mixed.field_containers import FieldContainers -from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color from tagstudio.qt.translations import Translations +from tagstudio.qt.views.preview_panel.attributes.file_attributes_view import FileAttributeData if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -64,8 +66,8 @@ def __init__(self, library: Library, driver: "QtDriver"): super().__init__() self.lib = library - self.__thumb = PreviewThumb(self.lib, driver) - self.__file_attrs = FileAttributes(self.lib, driver) + self._thumb = PreviewThumb(self.lib, driver) + self._file_attributes = FileAttributes(self.lib, driver) self._fields = FieldContainers( self.lib, driver ) # TODO: this should be name mangled, but is still needed on the controller side atm @@ -84,28 +86,32 @@ def __init__(self, library: Library, driver: "QtDriver"): splitter.setOrientation(Qt.Orientation.Vertical) splitter.setHandleWidth(12) + # Add buttons add_buttons_container = QWidget() add_buttons_layout = QHBoxLayout(add_buttons_container) add_buttons_layout.setContentsMargins(0, 0, 0, 0) add_buttons_layout.setSpacing(6) + # Add tag button self.__add_tag_button = QPushButton(Translations["tag.add"]) self.__add_tag_button.setEnabled(False) self.__add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor) self.__add_tag_button.setMinimumHeight(28) self.__add_tag_button.setStyleSheet(BUTTON_STYLE) + add_buttons_layout.addWidget(self.__add_tag_button) + + # Add field button self.__add_field_button = QPushButton(Translations["library.field.add"]) self.__add_field_button.setEnabled(False) self.__add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) self.__add_field_button.setMinimumHeight(28) self.__add_field_button.setStyleSheet(BUTTON_STYLE) - add_buttons_layout.addWidget(self.__add_tag_button) add_buttons_layout.addWidget(self.__add_field_button) - preview_layout.addWidget(self.__thumb) - info_layout.addWidget(self.__file_attrs) + preview_layout.addWidget(self._thumb) + info_layout.addWidget(self._file_attributes) info_layout.addWidget(self._fields) splitter.addWidget(preview_section) @@ -132,6 +138,12 @@ def _add_tag_button_callback(self): def _set_selection_callback(self): raise NotImplementedError() + def _file_dimensions_changed_callback(self, width: int, height: int) -> None: + raise NotImplementedError() + + def _file_duration_changed_callback(self, duration: int) -> None: + raise NotImplementedError() + def set_selection(self, selected: list[int], update_preview: bool = True): """Render the panel widgets with the newest data from the Library. @@ -144,9 +156,8 @@ def set_selection(self, selected: list[int], update_preview: bool = True): try: # No Items Selected if len(selected) == 0: - self.__thumb.hide_preview() - self.__file_attrs.update_stats() - self.__file_attrs.update_date_label() + self._thumb.hide_preview() + self._file_attributes.set_selection_size(len(selected)) self._fields.hide_containers() self.add_buttons_enabled = False @@ -159,9 +170,12 @@ def set_selection(self, selected: list[int], update_preview: bool = True): filepath: Path = unwrap(self.lib.library_dir) / entry.path if update_preview: - stats: FileAttributeData = self.__thumb.display_file(filepath) - self.__file_attrs.update_stats(filepath, stats) - self.__file_attrs.update_date_label(filepath) + self._file_attributes.update_file_path(filepath) + stats: FileAttributeData = self._thumb.display_file(filepath) + logger.debug("[Preview Panel] Updating file stats", stats=stats) + + self._file_attributes.set_selection_size(len(selected)) + self._file_attributes.update_date_label(filepath) self._fields.update_from_entry(entry_id) self._set_selection_callback() @@ -171,9 +185,9 @@ def set_selection(self, selected: list[int], update_preview: bool = True): # Multiple Selected Items elif len(selected) > 1: # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected] - self.__thumb.hide_preview() # TODO: Render mixed selection - self.__file_attrs.update_multi_selection(len(selected)) - self.__file_attrs.update_date_label() + self._thumb.hide_preview() # TODO: Render mixed selection + self._file_attributes.set_selection_size(len(selected)) + self._file_attributes.update_date_label() self._fields.hide_containers() # TODO: Allow for mixed editing self._set_selection_callback() @@ -199,7 +213,7 @@ def add_buttons_enabled(self, enabled: bool): @property def _file_attributes_widget(self) -> FileAttributes: # needed for the tests """Getter for the file attributes widget.""" - return self.__file_attrs + return self._file_attributes @property def field_containers_widget(self) -> FieldContainers: # needed for the tests @@ -208,4 +222,4 @@ def field_containers_widget(self) -> FieldContainers: # needed for the tests @property def preview_thumb(self) -> PreviewThumb: - return self.__thumb + return self._thumb diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_panel/thumbnail/preview_thumb_view.py similarity index 63% rename from src/tagstudio/qt/views/preview_thumb_view.py rename to src/tagstudio/qt/views/preview_panel/thumbnail/preview_thumb_view.py index e50509ad7..fa0f0a570 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_panel/thumbnail/preview_thumb_view.py @@ -7,17 +7,17 @@ from typing import TYPE_CHECKING, override import structlog -from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt +from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal from PySide6.QtGui import QAction, QMovie, QPixmap, QResizeEvent from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStackedLayout, QWidget from tagstudio.core.library.alchemy.library import Library from tagstudio.core.media_types import MediaType -from tagstudio.qt.mixed.file_attributes import FileAttributeData -from tagstudio.qt.mixed.media_player import MediaPlayer +from tagstudio.qt.mixed.preview_panel.thumbnail.media_player import MediaPlayer from tagstudio.qt.platform_strings import open_file_str, trash_term from tagstudio.qt.previews.renderer import ThumbRenderer from tagstudio.qt.translations import Translations +from tagstudio.qt.views.preview_panel.attributes.file_attributes_view import FileAttributeData from tagstudio.qt.views.styles.rounded_pixmap_style import RoundedPixmapStyle if TYPE_CHECKING: @@ -32,8 +32,8 @@ class PreviewThumbView(QWidget): """The Preview Panel Widget.""" - __img_button_size: tuple[int, int] - __image_ratio: float + __thumbnail_button_size: tuple[int, int] + __thumbnail_ratio: float __filepath: Path | None __rendered_res: tuple[int, int] @@ -41,13 +41,14 @@ class PreviewThumbView(QWidget): def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() - self.__img_button_size = (266, 266) - self.__image_ratio = 1.0 + self.__thumbnail_button_size = (266, 266) + self.__thumbnail_ratio = 1.0 - 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) + self.__thumb_layout = QStackedLayout(self) + self.__thumb_layout.setObjectName("thumbnail_layout") + self.__thumb_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.__thumb_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) + self.__thumb_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) @@ -60,7 +61,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: delete_action.triggered.connect(self._delete_action_callback) self.__button_wrapper = QPushButton() - self.__button_wrapper.setMinimumSize(*self.__img_button_size) + self.__button_wrapper.setMinimumSize(*self.__thumbnail_button_size) self.__button_wrapper.setFlat(True) self.__button_wrapper.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.__button_wrapper.addAction(open_file_action) @@ -68,13 +69,19 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__button_wrapper.addAction(delete_action) self.__button_wrapper.clicked.connect(self._button_wrapper_callback) + # Image preview # 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.__preview_img_page.setObjectName("image_preview_page") + self.__stacked_page_setup(self.__preview_img_page, self.__button_wrapper) + self.__thumb_layout.addWidget(self.__preview_img_page) + # GIF preview self.__preview_gif = QLabel() - self.__preview_gif.setMinimumSize(*self.__img_button_size) + self.__preview_gif.setObjectName("gif_preview") + self.__preview_gif.setMinimumSize(*self.__thumbnail_button_size) self.__preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.__preview_gif.setCursor(Qt.CursorShape.ArrowCursor) self.__preview_gif.addAction(open_file_action) @@ -83,31 +90,35 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__gif_buffer: QBuffer = QBuffer() self.__preview_gif_page = QWidget() + self.__preview_gif_page.setObjectName("gif_preview_page") + self.__stacked_page_setup(self.__preview_gif_page, self.__preview_gif) + self.__thumb_layout.addWidget(self.__preview_gif_page) - 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) + # Media preview + 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) # Need to watch for this to resize the player appropriately. - self.__media_player.player.hasVideoChanged.connect( + 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.__media_player_page.setObjectName("media_preview_page") + + self.__stacked_page_setup(self.__media_player_page, self._media_player) + self.__thumb_layout.addWidget(self.__media_player_page) + # Thumbnail renderer self.__thumb_renderer = ThumbRenderer(driver) 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.__img_button_size) + self.setMinimumSize(*self.__thumbnail_button_size) self.hide_preview() def _open_file_action_callback(self): @@ -123,7 +134,7 @@ 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())) + self.__update_thumbnail_size(self.size()) def __thumb_renderer_updated_callback( self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path @@ -131,13 +142,8 @@ def __thumb_renderer_updated_callback( 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(), - ) - ) + self.__thumbnail_ratio = ratio + self.__update_thumbnail_size(self.size()) def __stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: layout = QHBoxLayout(page) @@ -146,52 +152,58 @@ def __stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: layout.setContentsMargins(0, 0, 0, 0) page.setLayout(layout) - def __update_image_size(self, size: tuple[int, int]) -> None: - adj_width: float = size[0] - adj_height: float = size[1] + def __update_thumbnail_size(self, size: QSize) -> None: + adjusted_size: QSize = size + # Landscape - if self.__image_ratio > 1: - adj_height = size[0] * (1 / self.__image_ratio) + if self.__thumbnail_ratio > 1: + adjusted_size.setHeight( + int(size.width() * (1 / self.__thumbnail_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] + elif self.__thumbnail_ratio <= 1: + adjusted_size.setWidth( + int(size.height() * self.__thumbnail_ratio) + ) - adj_size = QSize(int(adj_width), int(adj_height)) + if adjusted_size.width() > size.width(): + adjusted_size.setHeight( + int(adjusted_size.height() * (size.width() / adjusted_size.width())) + ) + adjusted_size.setWidth(size.width()) + elif adjusted_size.height() > size.height(): + adjusted_size.setWidth( + int(adjusted_size.width() * (size.height() / adjusted_size.height())) + ) + adjusted_size.setHeight(size.height()) - self.__img_button_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.__thumbnail_button_size = (adjusted_size.width(), adjusted_size.height()) + self.__button_wrapper.setMaximumSize(adjusted_size) + self.__button_wrapper.setIconSize(adjusted_size) + self.__preview_gif.setMaximumSize(adjusted_size) + self.__preview_gif.setMinimumSize(adjusted_size) - self.__media_player.setMaximumSize(adj_size) - self.__media_player.setMinimumSize(adj_size) + self._media_player.setMaximumSize(adjusted_size) + self._media_player.setMinimumSize(adjusted_size) proxy_style = RoundedPixmapStyle(radius=8) self.__preview_gif.setStyle(proxy_style) - self.__media_player.setStyle(proxy_style) + self._media_player.setStyle(proxy_style) m = self.__preview_gif.movie() if m: - m.setScaledSize(adj_size) + m.setScaledSize(adjusted_size) def __switch_preview(self, preview: MediaType | None) -> None: if preview in [MediaType.AUDIO, MediaType.VIDEO]: - self.__media_player.show() - self.__image_layout.setCurrentWidget(self.__media_player_page) + self._media_player.show() + self.__thumb_layout.setCurrentWidget(self.__media_player_page) else: - self.__media_player.stop() - self.__media_player.hide() + self._media_player.stop() + self._media_player.hide() if preview in [MediaType.IMAGE, MediaType.AUDIO]: self.__button_wrapper.show() - self.__image_layout.setCurrentWidget( + self.__thumb_layout.setCurrentWidget( self.__preview_img_page if preview == MediaType.IMAGE else self.__media_player_page ) else: @@ -199,7 +211,7 @@ def __switch_preview(self, preview: MediaType | None) -> None: if preview == MediaType.IMAGE_ANIMATED: self.__preview_gif.show() - self.__image_layout.setCurrentWidget(self.__preview_gif_page) + self.__thumb_layout.setCurrentWidget(self.__preview_gif_page) else: if self.__preview_gif.movie(): self.__preview_gif.movie().stop() @@ -209,8 +221,8 @@ def __switch_preview(self, preview: MediaType | None) -> None: def __render_thumb(self, filepath: Path) -> None: self.__filepath = filepath self.__rendered_res = ( - math.ceil(self.__img_button_size[0] * THUMB_SIZE_FACTOR), - math.ceil(self.__img_button_size[1] * THUMB_SIZE_FACTOR), + math.ceil(self.__thumbnail_button_size[0] * THUMB_SIZE_FACTOR), + math.ceil(self.__thumbnail_button_size[1] * THUMB_SIZE_FACTOR), ) self.__thumb_renderer.render( @@ -221,15 +233,15 @@ def __render_thumb(self, filepath: Path) -> None: update_on_ratio_change=True, ) - def __update_media_player(self, filepath: Path) -> int: + def __update_media_player(self, filepath: Path) -> None: """Display either audio or video. Returns the duration of the audio / video. """ - self.__media_player.play(filepath) - return self.__media_player.player.duration() * 1000 - def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData: + self._media_player.play(filepath) + + def _display_video(self, filepath: Path, size: QSize | None) -> None: self.__switch_preview(MediaType.VIDEO) stats = FileAttributeData(duration=self.__update_media_player(filepath)) @@ -237,7 +249,7 @@ def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeDat stats.width = size.width() stats.height = size.height() - self.__image_ratio = stats.width / stats.height + self.__thumbnail_ratio = stats.width / stats.height self.resizeEvent( QResizeEvent( QSize(stats.width, stats.height), @@ -245,26 +257,19 @@ def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeDat ) ) - return stats - - def _display_audio(self, filepath: Path) -> FileAttributeData: + def _display_audio(self, filepath: Path) -> None: self.__switch_preview(MediaType.AUDIO) self.__render_thumb(filepath) - return FileAttributeData(duration=self.__update_media_player(filepath)) - def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None: + def _display_gif(self, gif_data: bytes, size: QSize) -> None: """Update the animated image preview from a filepath.""" - stats = FileAttributeData() - # Ensure that any movie and buffer from previous animations are cleared. + # 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.__thumbnail_ratio = size.width() / size.height() self.__gif_buffer.setData(gif_data) movie = QMovie(self.__gif_buffer, QByteArray()) @@ -278,17 +283,12 @@ def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeD self.__switch_preview(MediaType.IMAGE_ANIMATED) self.resizeEvent( QResizeEvent( - QSize(stats.width, stats.height), - QSize(stats.width, stats.height), + size, size ) ) movie.start() - stats.duration = movie.frameCount() // 60 - - return stats - - def _display_image(self, filepath: Path): + def _display_image(self, filepath: Path) -> None: """Renders the given file as an image, no matter its media type.""" self.__switch_preview(MediaType.IMAGE) self.__render_thumb(filepath) @@ -300,13 +300,13 @@ def hide_preview(self) -> None: @override def resizeEvent(self, event: QResizeEvent) -> None: - self.__update_image_size((self.size().width(), self.size().height())) + self.__update_thumbnail_size(self.size()) - if self.__filepath is not None and self.__rendered_res < self.__img_button_size: + if self.__filepath is not None and self.__rendered_res < self.__thumbnail_button_size: self.__render_thumb(self.__filepath) return super().resizeEvent(event) @property def media_player(self) -> MediaPlayer: - return self.__media_player + return self._media_player From ebbbc0f30b2c75984a7c1aff28d5e679dd980c59 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Sat, 1 Nov 2025 11:00:37 -0400 Subject: [PATCH 04/21] File dimensions --- .../attributes/file_attributes_controller.py | 2 +- .../preview_panel/preview_panel_controller.py | 11 ++--- .../thumbnail/preview_thumb_controller.py | 20 +++++--- .../preview_panel/thumbnail/media_player.py | 2 +- .../attributes/file_attributes_model.py | 10 ++-- .../attributes/dimension_property_widget.py | 6 +-- .../attributes/duration_property_widget.py | 2 +- .../attributes/file_attributes_view.py | 2 +- .../attributes/file_property_widget.py | 2 +- .../views/preview_panel/preview_panel_view.py | 10 ++-- .../thumbnail/preview_thumb_view.py | 46 ++++--------------- 11 files changed, 45 insertions(+), 68 deletions(-) diff --git a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py index f4f161577..88faa684b 100644 --- a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py +++ b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py @@ -11,4 +11,4 @@ class FileAttributes(FileAttributesView): """A widget displaying a list of a file's attributes.""" def __init__(self, library: Library, driver: "QtDriver"): - super().__init__(library, driver) \ No newline at end of file + super().__init__(library, driver) diff --git a/src/tagstudio/qt/controllers/preview_panel/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel/preview_panel_controller.py index 6cef381ea..953e2a75c 100644 --- a/src/tagstudio/qt/controllers/preview_panel/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel/preview_panel_controller.py @@ -4,6 +4,7 @@ import typing from warnings import catch_warnings +from PySide6.QtCore import QSize from PySide6.QtWidgets import QListWidgetItem from tagstudio.core.library.alchemy.library import Library @@ -40,17 +41,13 @@ def _set_selection_callback(self): self.__add_field_modal.done.connect(self._add_field_to_selected) self.__add_tag_modal.tsp.tag_chosen.connect(self._add_tag_to_selected) - def _file_dimensions_changed_callback(self, width: int, height: int) -> None: + def _file_dimensions_changed_callback(self, size: QSize) -> None: self._file_attributes.update_file_property( - FilePropertyType.DIMENSIONS, - width=width, height=height + FilePropertyType.DIMENSIONS, width=size.width(), height=size.height() ) def _file_duration_changed_callback(self, duration: int) -> None: - self._file_attributes.update_file_property( - FilePropertyType.DURATION, - duration=duration - ) + self._file_attributes.update_file_property(FilePropertyType.DURATION, duration=duration) def _add_field_to_selected(self, field_list: list[QListWidgetItem]): self._fields.add_field_to_selected(field_list) diff --git a/src/tagstudio/qt/controllers/preview_panel/thumbnail/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_panel/thumbnail/preview_thumb_controller.py index c7005b238..a5e0b0f8e 100644 --- a/src/tagstudio/qt/controllers/preview_panel/thumbnail/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel/thumbnail/preview_thumb_controller.py @@ -11,6 +11,7 @@ from PIL import Image, UnidentifiedImageError from PIL.Image import DecompressionBombError from PySide6.QtCore import QSize, Signal +from PySide6.QtGui import QResizeEvent from tagstudio.core.library.alchemy.library import Library from tagstudio.core.media_types import MediaCategories @@ -26,7 +27,7 @@ class PreviewThumb(PreviewThumbView): - dimensions_changed = Signal(int, int) + dimensions_changed = Signal(QSize) duration_changed = Signal(int) __current_file: Path @@ -38,6 +39,13 @@ def __init__(self, library: Library, driver: "QtDriver"): self._media_player.duration_changed.connect(self.duration_changed.emit) + def _on_dimensions_change(self, size: QSize | None) -> None: + if size is None: + size = QSize(0, 0) + + self.resizeEvent(QResizeEvent(size, size)) + self.dimensions_changed.emit(size) + def __get_image_size(self, filepath: Path) -> QSize: """Get width and height of an image as dict.""" size = QSize() @@ -127,10 +135,8 @@ def display_file(self, filepath: Path) -> None: except cv2.error as e: logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) - if video_size: - self.dimensions_changed.emit(video_size.width(), video_size.height()) - else: - self.dimensions_changed.emit(0, 0) + self._display_video(filepath, video_size) + self._on_dimensions_change(video_size) # Audio elif MediaCategories.AUDIO_TYPES.contains(ext, mime_fallback=True): @@ -146,14 +152,14 @@ def display_file(self, filepath: Path) -> None: self._display_image(filepath) gif_size = self.__get_image_size(filepath) - self.dimensions_changed.emit(gif_size.width(), gif_size.height()) + self._on_dimensions_change(gif_size) # Other Types (Including Images) else: self._display_image(filepath) image_size: QSize = self.__get_image_size(filepath) - self.dimensions_changed.emit(image_size.width(), image_size.height()) + self._on_dimensions_change(image_size) def _open_file_action_callback(self): open_file( diff --git a/src/tagstudio/qt/mixed/preview_panel/thumbnail/media_player.py b/src/tagstudio/qt/mixed/preview_panel/thumbnail/media_player.py index 1fb6be50c..9de883272 100644 --- a/src/tagstudio/qt/mixed/preview_panel/thumbnail/media_player.py +++ b/src/tagstudio/qt/mixed/preview_panel/thumbnail/media_player.py @@ -58,7 +58,6 @@ def __init__(self, driver: "QtDriver") -> None: super().__init__() self.driver = driver - slider_style = """ QSlider { background: transparent; @@ -494,6 +493,7 @@ def resizeEvent(self, event: QResizeEvent) -> None: def volume_slider_changed(self, position: int) -> None: self.player.audioOutput().setVolume(position / 100) + class VideoPreview(QGraphicsVideoItem): @override def boundingRect(self): diff --git a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py index 6fd52e70d..9efa693e1 100644 --- a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py +++ b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py @@ -1,7 +1,11 @@ from enum import Enum -from tagstudio.qt.views.preview_panel.attributes.dimension_property_widget import DimensionPropertyWidget -from tagstudio.qt.views.preview_panel.attributes.duration_property_widget import DurationPropertyWidget +from tagstudio.qt.views.preview_panel.attributes.dimension_property_widget import ( + DimensionPropertyWidget, +) +from tagstudio.qt.views.preview_panel.attributes.duration_property_widget import ( + DurationPropertyWidget, +) from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget @@ -11,4 +15,4 @@ class FilePropertyType(Enum): def __init__(self, name: str, widget_class: type[FilePropertyWidget]): self.__name = name - self.widget_class = widget_class \ No newline at end of file + self.widget_class = widget_class diff --git a/src/tagstudio/qt/views/preview_panel/attributes/dimension_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/dimension_property_widget.py index e49844caa..23fe8eeaf 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/dimension_property_widget.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/dimension_property_widget.py @@ -10,7 +10,7 @@ def __init__(self) -> None: self.setObjectName("dimensions_property") def set_value(self, **kwargs) -> None: - width: int = kwargs.get('width', 0) - height: int = kwargs.get('height', 0) + width: int = kwargs.get("width", 0) + height: int = kwargs.get("height", 0) - self.setText(f"{width} x {height} px") \ No newline at end of file + self.setText(f"{width} x {height} px") diff --git a/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py index 3209413cd..a3379de60 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py @@ -34,4 +34,4 @@ def set_value(self, **kwargs) -> None: if formatted_duration == "": formatted_duration = unknown_duration - self.setText(formatted_duration) \ No newline at end of file + self.setText(formatted_duration) diff --git a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py index d3570f406..0dc2063e9 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py @@ -39,6 +39,7 @@ class FileAttributeData: DATE_LABEL_STYLE = "font-size: 12px;" + class FileAttributesView(QWidget): """A widget displaying a list of a file's attributes.""" @@ -153,7 +154,6 @@ def update_file_path(self, file_path: Path) -> None: self.file_path_label.setText(path_string) - def update_date_label(self, filepath: Path | None = None) -> None: """Update the "Date Created" and "Date Modified" file property labels.""" if filepath and filepath.is_file(): diff --git a/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py index 99330ef53..8b1ed9183 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py @@ -8,4 +8,4 @@ def __init__(self) -> None: super().__init__() def set_value(self, **kwargs) -> None: - raise NotImplementedError() \ No newline at end of file + raise NotImplementedError() diff --git a/src/tagstudio/qt/views/preview_panel/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel/preview_panel_view.py index 01126a79e..b849bc39a 100644 --- a/src/tagstudio/qt/views/preview_panel/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel/preview_panel_view.py @@ -1,13 +1,11 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from functools import partial -import random import traceback import typing from pathlib import Path import structlog -from PySide6.QtCore import Qt +from PySide6.QtCore import QSize, Qt from PySide6.QtWidgets import ( QHBoxLayout, QPushButton, @@ -25,7 +23,6 @@ from tagstudio.qt.mixed.field_containers import FieldContainers from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color from tagstudio.qt.translations import Translations -from tagstudio.qt.views.preview_panel.attributes.file_attributes_view import FileAttributeData if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -138,7 +135,7 @@ def _add_tag_button_callback(self): def _set_selection_callback(self): raise NotImplementedError() - def _file_dimensions_changed_callback(self, width: int, height: int) -> None: + def _file_dimensions_changed_callback(self, size: QSize) -> None: raise NotImplementedError() def _file_duration_changed_callback(self, duration: int) -> None: @@ -170,9 +167,8 @@ def set_selection(self, selected: list[int], update_preview: bool = True): filepath: Path = unwrap(self.lib.library_dir) / entry.path if update_preview: + self._thumb.display_file(filepath) self._file_attributes.update_file_path(filepath) - stats: FileAttributeData = self._thumb.display_file(filepath) - logger.debug("[Preview Panel] Updating file stats", stats=stats) self._file_attributes.set_selection_size(len(selected)) self._file_attributes.update_date_label(filepath) diff --git a/src/tagstudio/qt/views/preview_panel/thumbnail/preview_thumb_view.py b/src/tagstudio/qt/views/preview_panel/thumbnail/preview_thumb_view.py index fa0f0a570..bce41da16 100644 --- a/src/tagstudio/qt/views/preview_panel/thumbnail/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_panel/thumbnail/preview_thumb_view.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, override import structlog -from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal +from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt from PySide6.QtGui import QAction, QMovie, QPixmap, QResizeEvent from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStackedLayout, QWidget @@ -17,7 +17,6 @@ from tagstudio.qt.platform_strings import open_file_str, trash_term from tagstudio.qt.previews.renderer import ThumbRenderer from tagstudio.qt.translations import Translations -from tagstudio.qt.views.preview_panel.attributes.file_attributes_view import FileAttributeData from tagstudio.qt.views.styles.rounded_pixmap_style import RoundedPixmapStyle if TYPE_CHECKING: @@ -117,7 +116,6 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__thumb_renderer.updated.connect(self.__thumb_renderer_updated_callback) self.__thumb_renderer.updated_ratio.connect(self.__thumb_renderer_updated_ratio_callback) - self.setMinimumSize(*self.__thumbnail_button_size) self.hide_preview() @@ -157,14 +155,10 @@ def __update_thumbnail_size(self, size: QSize) -> None: # Landscape if self.__thumbnail_ratio > 1: - adjusted_size.setHeight( - int(size.width() * (1 / self.__thumbnail_ratio)) - ) + adjusted_size.setHeight(int(size.width() * (1 / self.__thumbnail_ratio))) # Portrait elif self.__thumbnail_ratio <= 1: - adjusted_size.setWidth( - int(size.height() * self.__thumbnail_ratio) - ) + adjusted_size.setWidth(int(size.height() * self.__thumbnail_ratio)) if adjusted_size.width() > size.width(): adjusted_size.setHeight( @@ -238,33 +232,20 @@ def __update_media_player(self, filepath: Path) -> None: Returns the duration of the audio / video. """ - self._media_player.play(filepath) def _display_video(self, filepath: Path, size: QSize | None) -> None: self.__switch_preview(MediaType.VIDEO) - stats = FileAttributeData(duration=self.__update_media_player(filepath)) - - if size is not None: - stats.width = size.width() - stats.height = size.height() - - self.__thumbnail_ratio = stats.width / stats.height - self.resizeEvent( - QResizeEvent( - QSize(stats.width, stats.height), - QSize(stats.width, stats.height), - ) - ) + self.__update_media_player(filepath) def _display_audio(self, filepath: Path) -> None: self.__switch_preview(MediaType.AUDIO) self.__render_thumb(filepath) + self.__update_media_player(filepath) def _display_gif(self, gif_data: bytes, size: QSize) -> None: """Update the animated image preview from a filepath.""" - - # Ensure that any movie and buffer from previous animations are cleared. + # 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() @@ -276,17 +257,9 @@ def _display_gif(self, gif_data: bytes, size: QSize) -> None: 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(MediaType.IMAGE_ANIMATED) - self.resizeEvent( - QResizeEvent( - size, size - ) - ) - movie.start() + if movie.frameCount() > 1: + self.__switch_preview(MediaType.IMAGE_ANIMATED) + movie.start() def _display_image(self, filepath: Path) -> None: """Renders the given file as an image, no matter its media type.""" @@ -300,6 +273,7 @@ def hide_preview(self) -> None: @override def resizeEvent(self, event: QResizeEvent) -> None: + self.__thumbnail_ratio = self.size().width() / self.size().height() self.__update_thumbnail_size(self.size()) if self.__filepath is not None and self.__rendered_res < self.__thumbnail_button_size: From 5dd1f44fe4031148c8bc27b4652d312dd97bd74e Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Sun, 2 Nov 2025 18:22:12 -0500 Subject: [PATCH 05/21] Correctly use milliseconds, instead of seconds --- .../views/preview_panel/attributes/duration_property_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py index a3379de60..824ef72d6 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py @@ -22,7 +22,7 @@ def set_value(self, **kwargs) -> None: logger.debug("[DurationPropertyWidget] Updating duration", duration=duration) try: - formatted_duration = str(timedelta(seconds=float(duration)))[:-7] + formatted_duration = str(timedelta(milliseconds=float(duration)))[:-7] logger.debug("[DurationPropertyWidget]", formatted_duration=formatted_duration) if formatted_duration.startswith("0:"): formatted_duration = formatted_duration[2:] From 12f3e0487ace3d3327b20fcb569d8be9e8936f9f Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Sun, 2 Nov 2025 20:12:27 -0500 Subject: [PATCH 06/21] Better comply with MVC, and clear file properties when changing selection --- .../attributes/file_attributes_controller.py | 75 +++++++++++++++++++ .../attributes/file_attributes_model.py | 37 +++++++++ .../attributes/file_attributes_view.py | 51 ------------- .../views/preview_panel/preview_panel_view.py | 2 + 4 files changed, 114 insertions(+), 51 deletions(-) diff --git a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py index 88faa684b..33c81a7f8 100644 --- a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py +++ b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py @@ -1,14 +1,89 @@ +from pathlib import Path import typing +from PySide6.QtGui import Qt +import structlog + from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.models.preview_panel.attributes.file_attributes_model import FileAttributesModel, FilePropertyType +from tagstudio.qt.translations import Translations from tagstudio.qt.views.preview_panel.attributes.file_attributes_view import FileAttributesView +from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver +logger = structlog.get_logger(__name__) + + class FileAttributes(FileAttributesView): """A widget displaying a list of a file's attributes.""" def __init__(self, library: Library, driver: "QtDriver"): super().__init__(library, driver) + + self.model = FileAttributesModel() + + def update_file_property(self, property_type: FilePropertyType, **kwargs) -> None: + """Update a property of the file.""" + logger.debug("[FileAttributes] Updating file property", type=property_type, **kwargs) + + if property_type not in self.model.get_properties(): + new_property_widget: FilePropertyWidget = property_type.widget_class() + new_property_widget.set_value(**kwargs) + + self.model.add_property(property_type, new_property_widget) + self.properties_layout.addWidget(new_property_widget) + else: + property_widget: FilePropertyWidget | None = self.model.get_property(property_type) + if property_widget: + property_widget.set_value(**kwargs) + + self.model.set_property(property_type, property_widget) + property_widget.show() + + def clear_file_properties(self) -> None: + """Clears the existing file properties.""" + logger.debug("[FileAttributes] Clearing file properties") + + for property_widget in self.model.get_properties().values(): + property_widget.hide() + + self.model.delete_properties() + + def set_selection_size(self, num_selected: int): + match num_selected: + case 0: + # File path label + self.file_path_label.setText(f"{Translations['preview.no_selection']}") + self.file_path_label.set_file_path(Path()) + self.file_path_label.setCursor(Qt.CursorShape.ArrowCursor) + self.file_path_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Properties + self.date_created_label.setHidden(True) + self.date_modified_label.setHidden(True) + self.properties.setHidden(True) + case 1: + # File path label + self.file_path_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.file_path_label.setCursor(Qt.CursorShape.PointingHandCursor) + + # Properties + self.date_created_label.setHidden(False) + self.date_modified_label.setHidden(False) + self.properties.setHidden(False) + case _ if num_selected > 1: + # File path label + self.file_path_label.setText( + Translations.format("preview.multiple_selection", count=num_selected) + ) + self.file_path_label.set_file_path(Path()) + self.file_path_label.setCursor(Qt.CursorShape.ArrowCursor) + self.file_path_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Properties + self.date_created_label.setHidden(True) + self.date_modified_label.setHidden(True) + self.properties.setHidden(True) \ No newline at end of file diff --git a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py index 9efa693e1..385c81c4d 100644 --- a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py +++ b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py @@ -1,5 +1,7 @@ from enum import Enum +from PySide6.QtCore import QAbstractItemModel, Signal + from tagstudio.qt.views.preview_panel.attributes.dimension_property_widget import ( DimensionPropertyWidget, ) @@ -16,3 +18,38 @@ class FilePropertyType(Enum): def __init__(self, name: str, widget_class: type[FilePropertyWidget]): self.__name = name self.widget_class = widget_class + +class FileAttributesModel(QAbstractItemModel): + properties_changed: Signal = Signal(dict) + + def __init__(self): + super().__init__() + + self.__property_widgets: dict[FilePropertyType, FilePropertyWidget] = {} + + def get_properties(self) -> dict[FilePropertyType, FilePropertyWidget]: + return self.__property_widgets + + def get_property(self, property_type: FilePropertyType) -> FilePropertyWidget | None: + if property_type in self.__property_widgets: + return self.__property_widgets[property_type] + + return None + + def add_property(self, property_type: FilePropertyType, widget: FilePropertyWidget) -> None: + if property_type not in self.__property_widgets: + self.__property_widgets[property_type] = widget + + self.properties_changed.emit(self.get_properties()) + + def set_property(self, property_type: FilePropertyType, widget: FilePropertyWidget) -> None: + if property_type not in self.__property_widgets: + self.__property_widgets[property_type] = widget + + self.properties_changed.emit(self.get_properties()) + + def delete_property(self, property_type: FilePropertyType) -> None: + self.__property_widgets.pop(property_type, None) + + def delete_properties(self) -> None: + self.__property_widgets.clear() \ No newline at end of file diff --git a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py index 0dc2063e9..531bd937d 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py @@ -17,10 +17,8 @@ from tagstudio.core.enums import ShowFilepathOption, Theme from tagstudio.core.library.alchemy.library import Library from tagstudio.core.utils.types import unwrap -from tagstudio.qt.models.preview_panel.attributes.file_attributes_model import FilePropertyType from tagstudio.qt.translations import Translations from tagstudio.qt.utils.file_opener import FileOpenerLabel -from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -125,8 +123,6 @@ def __init__(self, library: Library, driver: "QtDriver"): self.__root_layout.addWidget(self.properties) - self.__property_widgets: dict[FilePropertyType, FilePropertyWidget] = {} - def update_file_path(self, file_path: Path) -> None: self.file_path_label.set_file_path(file_path) @@ -186,17 +182,6 @@ def update_date_label(self, filepath: Path | None = None) -> None: self.date_created_label.setHidden(True) self.date_modified_label.setHidden(True) - def update_file_property(self, property_type: FilePropertyType, **kwargs) -> None: - """Update a property of the file.""" - logger.debug("[FileAttributes] Updating file property", type=property_type, **kwargs) - - if property_type not in self.__property_widgets: - property_widget: FilePropertyWidget = property_type.widget_class() - self.__property_widgets[property_type] = property_widget - self.properties_layout.addWidget(property_widget) - - self.__property_widgets[property_type].set_value(**kwargs) - # def update_stats(self, filepath: Path | None = None, stats: FileAttributeData | None = None): # """Render the panel widgets with the newest data from the Library.""" # if not stats: @@ -256,39 +241,3 @@ def update_file_property(self, property_type: FilePropertyType, **kwargs) -> Non # stats_label_text += f"{font_family}" # # self.dimensions_label.setText(stats_label_text) - - def set_selection_size(self, num_selected: int): - match num_selected: - case 0: - # File path label - self.file_path_label.setText(f"{Translations['preview.no_selection']}") - self.file_path_label.set_file_path(Path()) - self.file_path_label.setCursor(Qt.CursorShape.ArrowCursor) - self.file_path_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Properties - self.date_created_label.setHidden(True) - self.date_modified_label.setHidden(True) - self.properties.setHidden(True) - case 1: - # File path label - self.file_path_label.setAlignment(Qt.AlignmentFlag.AlignLeft) - self.file_path_label.setCursor(Qt.CursorShape.PointingHandCursor) - - # Properties - self.date_created_label.setHidden(False) - self.date_modified_label.setHidden(False) - self.properties.setHidden(False) - case _ if num_selected > 1: - # File path label - self.file_path_label.setText( - Translations.format("preview.multiple_selection", count=num_selected) - ) - self.file_path_label.set_file_path(Path()) - self.file_path_label.setCursor(Qt.CursorShape.ArrowCursor) - self.file_path_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Properties - self.date_created_label.setHidden(True) - self.date_modified_label.setHidden(True) - self.properties.setHidden(True) diff --git a/src/tagstudio/qt/views/preview_panel/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel/preview_panel_view.py index b849bc39a..af6777518 100644 --- a/src/tagstudio/qt/views/preview_panel/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel/preview_panel_view.py @@ -151,6 +151,8 @@ def set_selection(self, selected: list[int], update_preview: bool = True): """ self._selected = selected try: + self._file_attributes.clear_file_properties() + # No Items Selected if len(selected) == 0: self._thumb.hide_preview() From 3722e28d9ee95501d6564b7161ceedb3dc37bf6d Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Sun, 2 Nov 2025 20:35:23 -0500 Subject: [PATCH 07/21] Transfer non-view methods to the file attribute's controller (and simplify them) --- .../attributes/file_attributes_controller.py | 79 +++++++++++++++++-- .../attributes/file_attributes_model.py | 3 +- .../attributes/file_attributes_view.py | 77 +----------------- 3 files changed, 79 insertions(+), 80 deletions(-) diff --git a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py index 33c81a7f8..10e78bd9b 100644 --- a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py +++ b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py @@ -1,11 +1,19 @@ -from pathlib import Path +import os +import platform import typing +from datetime import datetime as dt +from pathlib import Path -from PySide6.QtGui import Qt import structlog +from PySide6.QtGui import Qt +from tagstudio.core.enums import ShowFilepathOption from tagstudio.core.library.alchemy.library import Library -from tagstudio.qt.models.preview_panel.attributes.file_attributes_model import FileAttributesModel, FilePropertyType +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.models.preview_panel.attributes.file_attributes_model import ( + FileAttributesModel, + FilePropertyType, +) from tagstudio.qt.translations import Translations from tagstudio.qt.views.preview_panel.attributes.file_attributes_view import FileAttributesView from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget @@ -21,10 +29,71 @@ class FileAttributes(FileAttributesView): """A widget displaying a list of a file's attributes.""" def __init__(self, library: Library, driver: "QtDriver"): - super().__init__(library, driver) + super().__init__() + self.library = library + self.driver = driver self.model = FileAttributesModel() + def update_file_path(self, file_path: Path) -> None: + self.file_path_label.set_file_path(file_path) + + # Format the path according to the user's settings + display_path: Path = file_path + match self.driver.settings.show_filepath: + case ShowFilepathOption.SHOW_FULL_PATHS: + display_path = file_path + case ShowFilepathOption.SHOW_RELATIVE_PATHS: + display_path = Path(file_path).relative_to(unwrap(self.library.library_dir)) + case ShowFilepathOption.SHOW_FILENAMES_ONLY: + display_path = Path(file_path.name) + + # Stringify the path + path_separator: str = f"{os.path.sep}" # Gray + + path_parts: list[str] = list(display_path.parts) + path_parts[-1] = f"
{path_parts[-1]}" + + path_string: str = path_separator.join(path_parts) + self.file_path_label.setText(path_string) + + def update_date_label(self, file_path: Path | None = None) -> None: + """Update the "Date Created" and "Date Modified" file property labels.""" + date_created: str | None = None + date_modified: str | None = None + if file_path and file_path.is_file(): + # Date created + created_timestamp: dt + if platform.system() == "Windows" or platform.system() == "Darwin": + created_timestamp = dt.fromtimestamp(file_path.stat().st_birthtime) # type: ignore[attr-defined, unused-ignore] + else: + created_timestamp = dt.fromtimestamp(file_path.stat().st_ctime) + + date_created = self.driver.settings.format_datetime(created_timestamp) + + # Date modified + modified_timestamp: dt = dt.fromtimestamp(file_path.stat().st_mtime) + date_modified = self.driver.settings.format_datetime(modified_timestamp) + elif file_path: + date_created = "N/A" + date_modified = "N/A" + + if date_created is not None: + self.date_created_label.setText( + f"{Translations['file.date_created']}: {date_created}" + ) + self.date_created_label.setHidden(False) + else: + self.date_created_label.setHidden(True) + + if date_modified is not None: + self.date_modified_label.setText( + f"{Translations['file.date_modified']}: {date_modified}" + ) + self.date_modified_label.setHidden(False) + else: + self.date_modified_label.setHidden(True) + def update_file_property(self, property_type: FilePropertyType, **kwargs) -> None: """Update a property of the file.""" logger.debug("[FileAttributes] Updating file property", type=property_type, **kwargs) @@ -86,4 +155,4 @@ def set_selection_size(self, num_selected: int): # Properties self.date_created_label.setHidden(True) self.date_modified_label.setHidden(True) - self.properties.setHidden(True) \ No newline at end of file + self.properties.setHidden(True) diff --git a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py index 385c81c4d..92e9ac95f 100644 --- a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py +++ b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py @@ -19,6 +19,7 @@ def __init__(self, name: str, widget_class: type[FilePropertyWidget]): self.__name = name self.widget_class = widget_class + class FileAttributesModel(QAbstractItemModel): properties_changed: Signal = Signal(dict) @@ -52,4 +53,4 @@ def delete_property(self, property_type: FilePropertyType) -> None: self.__property_widgets.pop(property_type, None) def delete_properties(self) -> None: - self.__property_widgets.clear() \ No newline at end of file + self.__property_widgets.clear() diff --git a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py index 531bd937d..9229f579a 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py @@ -2,27 +2,17 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import os -import platform -import typing + from dataclasses import dataclass -from datetime import datetime as dt -from pathlib import Path import structlog from PySide6.QtCore import Qt from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget -from tagstudio.core.enums import ShowFilepathOption, Theme -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.utils.types import unwrap -from tagstudio.qt.translations import Translations +from tagstudio.core.enums import Theme from tagstudio.qt.utils.file_opener import FileOpenerLabel -if typing.TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - logger = structlog.get_logger(__name__) @@ -41,10 +31,8 @@ class FileAttributeData: class FileAttributesView(QWidget): """A widget displaying a list of a file's attributes.""" - def __init__(self, library: Library, driver: "QtDriver"): + def __init__(self): super().__init__() - self.library = library - self.driver = driver label_bg_color = ( Theme.COLOR_BG_DARK.value @@ -123,65 +111,6 @@ def __init__(self, library: Library, driver: "QtDriver"): self.__root_layout.addWidget(self.properties) - def update_file_path(self, file_path: Path) -> None: - self.file_path_label.set_file_path(file_path) - - # Format the path according to the user's settings - display_path: Path = file_path - match self.driver.settings.show_filepath: - case ShowFilepathOption.SHOW_FULL_PATHS: - display_path = file_path - case ShowFilepathOption.SHOW_RELATIVE_PATHS: - display_path = Path(file_path).relative_to(unwrap(self.library.library_dir)) - case ShowFilepathOption.SHOW_FILENAMES_ONLY: - display_path = Path(file_path.name) - - # Stringify the path - path_string: str = "" - path_separator: str = f"{os.path.sep}" # Gray - for i, part in enumerate(display_path.parts): - directory_name = part.strip(os.path.sep) - if i < len(display_path.parts) - 1: - path_string += f"{'\u200b'.join(directory_name)}{path_separator}" - else: - if path_string != "": - path_string += "
" - path_string += f"{'\u200b'.join(directory_name)}" - - self.file_path_label.setText(path_string) - - def update_date_label(self, filepath: Path | None = None) -> None: - """Update the "Date Created" and "Date Modified" file property labels.""" - if filepath and filepath.is_file(): - created: dt - if platform.system() == "Windows" or platform.system() == "Darwin": - 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) - self.date_created_label.setText( - f"{Translations['file.date_created']}:" - + f" {self.driver.settings.format_datetime(created)}" - ) - self.date_modified_label.setText( - f"{Translations['file.date_modified']}: " - f"{self.driver.settings.format_datetime(modified)}" - ) - self.date_created_label.setHidden(False) - self.date_modified_label.setHidden(False) - elif filepath: - self.date_created_label.setText( - f"{Translations['file.date_created']}: N/A" - ) - self.date_modified_label.setText( - f"{Translations['file.date_modified']}: N/A" - ) - self.date_created_label.setHidden(False) - self.date_modified_label.setHidden(False) - else: - self.date_created_label.setHidden(True) - self.date_modified_label.setHidden(True) - # def update_stats(self, filepath: Path | None = None, stats: FileAttributeData | None = None): # """Render the panel widgets with the newest data from the Library.""" # if not stats: From a87f6009f0e310050b1765d52578f45f6b834c8f Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 3 Nov 2025 16:39:59 -0500 Subject: [PATCH 08/21] Re-implement extension and size properties --- .../attributes/file_attributes_controller.py | 3 ++ .../attributes/file_attributes_model.py | 2 ++ .../extension_and_size_property_widget.py | 28 +++++++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py diff --git a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py index 10e78bd9b..3f9852fab 100644 --- a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py +++ b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py @@ -38,6 +38,9 @@ def __init__(self, library: Library, driver: "QtDriver"): def update_file_path(self, file_path: Path) -> None: self.file_path_label.set_file_path(file_path) + # Update path-based properties + self.update_file_property(FilePropertyType.EXTENSION_AND_SIZE, file_path=file_path) + # Format the path according to the user's settings display_path: Path = file_path match self.driver.settings.show_filepath: diff --git a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py index 92e9ac95f..fb530f640 100644 --- a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py +++ b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py @@ -8,10 +8,12 @@ from tagstudio.qt.views.preview_panel.attributes.duration_property_widget import ( DurationPropertyWidget, ) +from tagstudio.qt.views.preview_panel.attributes.extension_and_size_property_widget import ExtensionAndSizePropertyWidget from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget class FilePropertyType(Enum): + EXTENSION_AND_SIZE = "extension_and_size", ExtensionAndSizePropertyWidget DIMENSIONS = "dimensions", DimensionPropertyWidget DURATION = "duration", DurationPropertyWidget diff --git a/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py new file mode 100644 index 000000000..55515e1b4 --- /dev/null +++ b/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py @@ -0,0 +1,28 @@ +from pathlib import Path + +from humanfriendly import format_size + +from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget + + +class ExtensionAndSizePropertyWidget(FilePropertyWidget): + """A widget representing a file's extension and size.""" + + def __init__(self) -> None: + super().__init__() + + self.setObjectName("extension_and_size_property") + + def set_value(self, **kwargs) -> None: + file_path = kwargs.get("file_path", Path()) + + extension: str = file_path.suffix.upper()[1:] or file_path.stem.upper() + file_size: int = 0 + + if file_path and file_path.is_file(): + file_size = file_path.stat().st_size + + if file_size > 0: + self.setText(f"{extension} • {format_size(file_size)}") + else: + self.setText(extension) \ No newline at end of file From 70702e432c9cd93f1c949933185e269073cc7533 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 3 Nov 2025 19:12:46 -0500 Subject: [PATCH 09/21] Ensure property widgets are ordered correctly --- .../attributes/file_attributes_controller.py | 5 ++++- .../attributes/file_attributes_model.py | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py index 3f9852fab..25510fbb7 100644 --- a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py +++ b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py @@ -106,7 +106,10 @@ def update_file_property(self, property_type: FilePropertyType, **kwargs) -> Non new_property_widget.set_value(**kwargs) self.model.add_property(property_type, new_property_widget) - self.properties_layout.addWidget(new_property_widget) + self.properties_layout.insertWidget( + self.model.get_property_index(property_type), + new_property_widget + ) else: property_widget: FilePropertyWidget | None = self.model.get_property(property_type) if property_widget: diff --git a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py index fb530f640..423cbd768 100644 --- a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py +++ b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py @@ -1,6 +1,7 @@ from enum import Enum from PySide6.QtCore import QAbstractItemModel, Signal +import structlog from tagstudio.qt.views.preview_panel.attributes.dimension_property_widget import ( DimensionPropertyWidget, @@ -12,6 +13,9 @@ from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget +logger = structlog.get_logger(__name__) + + class FilePropertyType(Enum): EXTENSION_AND_SIZE = "extension_and_size", ExtensionAndSizePropertyWidget DIMENSIONS = "dimensions", DimensionPropertyWidget @@ -31,7 +35,18 @@ def __init__(self): self.__property_widgets: dict[FilePropertyType, FilePropertyWidget] = {} def get_properties(self) -> dict[FilePropertyType, FilePropertyWidget]: - return self.__property_widgets + return dict( + sorted( + self.__property_widgets.items(), + key=lambda item: list(FilePropertyType.__members__.values()).index(item[0]), + ) + ) + + def get_property_index(self, property_type: FilePropertyType) -> int: + for index, key in enumerate(self.get_properties()): + if property_type == key: return index + + return -1 def get_property(self, property_type: FilePropertyType) -> FilePropertyWidget | None: if property_type in self.__property_widgets: From 844464d50fc061ae6a6456735f5ee17c6776bd64 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 3 Nov 2025 19:24:46 -0500 Subject: [PATCH 10/21] Reimplement font family property --- .../attributes/file_attributes_controller.py | 8 +++++++ .../attributes/file_attributes_model.py | 3 ++- .../attributes/file_attributes_view.py | 14 ----------- .../attributes/font_family_property_widget.py | 23 +++++++++++++++++++ 4 files changed, 33 insertions(+), 15 deletions(-) create mode 100644 src/tagstudio/qt/views/preview_panel/attributes/font_family_property_widget.py diff --git a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py index 25510fbb7..6cd432355 100644 --- a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py +++ b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py @@ -9,6 +9,7 @@ from tagstudio.core.enums import ShowFilepathOption from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.media_types import MediaCategories from tagstudio.core.utils.types import unwrap from tagstudio.qt.models.preview_panel.attributes.file_attributes_model import ( FileAttributesModel, @@ -41,6 +42,13 @@ def update_file_path(self, file_path: Path) -> None: # Update path-based properties self.update_file_property(FilePropertyType.EXTENSION_AND_SIZE, file_path=file_path) + if MediaCategories.is_ext_in_category( + file_path.suffix.lower(), MediaCategories.FONT_TYPES, mime_fallback=True + ): + self.update_file_property( + FilePropertyType.FONT_FAMILY, file_path=file_path + ) + # Format the path according to the user's settings display_path: Path = file_path match self.driver.settings.show_filepath: diff --git a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py index 423cbd768..7f52419f0 100644 --- a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py +++ b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py @@ -11,7 +11,7 @@ ) from tagstudio.qt.views.preview_panel.attributes.extension_and_size_property_widget import ExtensionAndSizePropertyWidget from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget - +from tagstudio.qt.views.preview_panel.attributes.font_family_property_widget import FontFamilyPropertyWidget logger = structlog.get_logger(__name__) @@ -20,6 +20,7 @@ class FilePropertyType(Enum): EXTENSION_AND_SIZE = "extension_and_size", ExtensionAndSizePropertyWidget DIMENSIONS = "dimensions", DimensionPropertyWidget DURATION = "duration", DurationPropertyWidget + FONT_FAMILY = "font_family", FontFamilyPropertyWidget def __init__(self, name: str, widget_class: type[FilePropertyWidget]): self.__name = name diff --git a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py index 9229f579a..c064dd1be 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py @@ -125,21 +125,11 @@ def __init__(self): # try: # file_size = format_size(filepath.stat().st_size) # - # if MediaCategories.is_ext_in_category( - # ext, MediaCategories.FONT_TYPES, mime_fallback=True - # ): - # font = ImageFont.truetype(filepath) - # font_family = f"{font.getname()[0]} ({font.getname()[1]}) " # except (FileNotFoundError, OSError) as e: # logger.error( # "[FileAttributes] Could not process file stats", filepath=filepath, error=e # ) # - # # Format and display any stat variables - # def add_newline(stats_label_text: str) -> str: - # if stats_label_text and stats_label_text[-4:] != "
": - # return stats_label_text + "
" - # return stats_label_text # # if ext_display: # stats_label_text += ext_display @@ -165,8 +155,4 @@ def __init__(self): # elif file_size: # stats_label_text += file_size # - # if font_family: - # stats_label_text = add_newline(stats_label_text) - # stats_label_text += f"{font_family}" - # # self.dimensions_label.setText(stats_label_text) diff --git a/src/tagstudio/qt/views/preview_panel/attributes/font_family_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/font_family_property_widget.py new file mode 100644 index 000000000..d72a9532f --- /dev/null +++ b/src/tagstudio/qt/views/preview_panel/attributes/font_family_property_widget.py @@ -0,0 +1,23 @@ +from pathlib import Path + +from PIL import ImageFont + +from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget + + +class FontFamilyPropertyWidget(FilePropertyWidget): + """A widget representing a file's font family.""" + + def __init__(self) -> None: + super().__init__() + + self.setObjectName("font_family_property") + + def set_value(self, **kwargs) -> None: + file_path = kwargs.get("file_path", Path()) + + font = ImageFont.truetype(file_path) + font_family = font.getname()[0] + font_style = font.getname()[1] + + self.setText(f"{font_family} ({font_style})") \ No newline at end of file From b47a31abc3d290cba30047e15c7cba011c0f058e Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 3 Nov 2025 19:35:20 -0500 Subject: [PATCH 11/21] File property widget error handling --- .../attributes/file_attributes_controller.py | 6 ++-- .../attributes/dimension_property_widget.py | 6 +++- .../attributes/duration_property_widget.py | 3 +- .../extension_and_size_property_widget.py | 36 +++++++++++++------ .../attributes/file_property_widget.py | 2 +- .../attributes/font_family_property_widget.py | 23 ++++++++---- 6 files changed, 54 insertions(+), 22 deletions(-) diff --git a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py index 6cd432355..75997fbdc 100644 --- a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py +++ b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py @@ -111,7 +111,8 @@ def update_file_property(self, property_type: FilePropertyType, **kwargs) -> Non if property_type not in self.model.get_properties(): new_property_widget: FilePropertyWidget = property_type.widget_class() - new_property_widget.set_value(**kwargs) + result = new_property_widget.set_value(**kwargs) + new_property_widget.setHidden(not result) self.model.add_property(property_type, new_property_widget) self.properties_layout.insertWidget( @@ -121,7 +122,8 @@ def update_file_property(self, property_type: FilePropertyType, **kwargs) -> Non else: property_widget: FilePropertyWidget | None = self.model.get_property(property_type) if property_widget: - property_widget.set_value(**kwargs) + result = property_widget.set_value(**kwargs) + property_widget.setHidden(not result) self.model.set_property(property_type, property_widget) property_widget.show() diff --git a/src/tagstudio/qt/views/preview_panel/attributes/dimension_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/dimension_property_widget.py index 23fe8eeaf..3c88bb4de 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/dimension_property_widget.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/dimension_property_widget.py @@ -9,8 +9,12 @@ def __init__(self) -> None: self.setObjectName("dimensions_property") - def set_value(self, **kwargs) -> None: + def set_value(self, **kwargs) -> bool: width: int = kwargs.get("width", 0) height: int = kwargs.get("height", 0) + if width < 1 or height < 1: + return False + self.setText(f"{width} x {height} px") + return True diff --git a/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py index 824ef72d6..87a87cad4 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py @@ -15,7 +15,7 @@ def __init__(self) -> None: self.setObjectName("duration_property") - def set_value(self, **kwargs) -> None: + def set_value(self, **kwargs) -> bool: unknown_duration: str = "-:--" duration: int = kwargs.get("duration", 0) @@ -35,3 +35,4 @@ def set_value(self, **kwargs) -> None: formatted_duration = unknown_duration self.setText(formatted_duration) + return True diff --git a/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py index 55515e1b4..b0eda9b99 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py @@ -1,10 +1,14 @@ from pathlib import Path from humanfriendly import format_size +import structlog from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget +logger = structlog.get_logger(__name__) + + class ExtensionAndSizePropertyWidget(FilePropertyWidget): """A widget representing a file's extension and size.""" @@ -13,16 +17,26 @@ def __init__(self) -> None: self.setObjectName("extension_and_size_property") - def set_value(self, **kwargs) -> None: + def set_value(self, **kwargs) -> bool: file_path = kwargs.get("file_path", Path()) - extension: str = file_path.suffix.upper()[1:] or file_path.stem.upper() - file_size: int = 0 - - if file_path and file_path.is_file(): - file_size = file_path.stat().st_size - - if file_size > 0: - self.setText(f"{extension} • {format_size(file_size)}") - else: - self.setText(extension) \ No newline at end of file + try: + extension: str = file_path.suffix.upper()[1:] or file_path.stem.upper() + file_size: int = 0 + + if file_path and file_path.is_file(): + file_size = file_path.stat().st_size + + if file_size > 0: + self.setText(f"{extension} • {format_size(file_size)}") + else: + self.setText(extension) + + return True + except (FileNotFoundError, OSError) as error: + logger.error( + "[ExtensionAndSizePropertyWidget] Could not process file stats", + file_path=file_path, + error=error, + ) + return False \ No newline at end of file diff --git a/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py index 8b1ed9183..29f6a037a 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py @@ -7,5 +7,5 @@ class FilePropertyWidget(QLabel): def __init__(self) -> None: super().__init__() - def set_value(self, **kwargs) -> None: + def set_value(self, **kwargs) -> bool: raise NotImplementedError() diff --git a/src/tagstudio/qt/views/preview_panel/attributes/font_family_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/font_family_property_widget.py index d72a9532f..ce4b51734 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/font_family_property_widget.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/font_family_property_widget.py @@ -1,10 +1,14 @@ from pathlib import Path from PIL import ImageFont +import structlog from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget +logger = structlog.get_logger(__name__) + + class FontFamilyPropertyWidget(FilePropertyWidget): """A widget representing a file's font family.""" @@ -13,11 +17,18 @@ def __init__(self) -> None: self.setObjectName("font_family_property") - def set_value(self, **kwargs) -> None: + def set_value(self, **kwargs) -> bool: file_path = kwargs.get("file_path", Path()) - font = ImageFont.truetype(file_path) - font_family = font.getname()[0] - font_style = font.getname()[1] - - self.setText(f"{font_family} ({font_style})") \ No newline at end of file + try: + font = ImageFont.truetype(file_path) + font_family = font.getname()[0] + font_style = font.getname()[1] + + self.setText(f"{font_family} ({font_style})") + return True + except (FileNotFoundError, OSError) as error: + logger.error( + "[FontFamilyPropertyWidget] Could not process font family", file_path=file_path, error=error + ) + return False \ No newline at end of file From ab2c21db7ac7bc4a353033bced936ad70127ac6a Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 3 Nov 2025 20:01:42 -0500 Subject: [PATCH 12/21] Reimplement the 'Ignored' and 'Unlinked' indicators --- .../attributes/file_attributes_controller.py | 2 +- .../extension_and_size_property_widget.py | 35 ++++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py index 75997fbdc..99db7ae67 100644 --- a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py +++ b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py @@ -40,7 +40,7 @@ def update_file_path(self, file_path: Path) -> None: self.file_path_label.set_file_path(file_path) # Update path-based properties - self.update_file_property(FilePropertyType.EXTENSION_AND_SIZE, file_path=file_path) + self.update_file_property(FilePropertyType.EXTENSION_AND_SIZE, file_path=file_path, library_dir=self.library.library_dir) if MediaCategories.is_ext_in_category( file_path.suffix.lower(), MediaCategories.FONT_TYPES, mime_fallback=True diff --git a/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py index b0eda9b99..230cc7034 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py @@ -3,7 +3,12 @@ from humanfriendly import format_size import structlog +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color +from tagstudio.qt.translations import Translations from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget +from tagstudio.core.library.ignore import Ignore logger = structlog.get_logger(__name__) @@ -12,6 +17,9 @@ class ExtensionAndSizePropertyWidget(FilePropertyWidget): """A widget representing a file's extension and size.""" + red = get_ui_color(ColorType.PRIMARY, UiColor.RED) + orange = get_ui_color(ColorType.PRIMARY, UiColor.ORANGE) + def __init__(self) -> None: super().__init__() @@ -19,19 +27,36 @@ def __init__(self) -> None: def set_value(self, **kwargs) -> bool: file_path = kwargs.get("file_path", Path()) + library_dir: Path | None = kwargs.get("library_dir", None) try: + components: list[str] = [] + + # File extension extension: str = file_path.suffix.upper()[1:] or file_path.stem.upper() - file_size: int = 0 + components.append(extension) + # File size if file_path and file_path.is_file(): file_size = file_path.stat().st_size + if file_size and file_size > 0: + components.append(format_size(file_size)) + + # Ignored + if library_dir and Ignore.compiled_patterns and Ignore.compiled_patterns.match( + file_path.relative_to(unwrap(library_dir)) + ): + components.append( + f"{Translations['preview.ignored'].upper()}" + ) - if file_size > 0: - self.setText(f"{extension} • {format_size(file_size)}") - else: - self.setText(extension) + # Unlinked + if not file_path.exists(): + components.append( + f"{Translations['preview.unlinked'].upper()}" + ) + self.setText(" • ".join(components)) return True except (FileNotFoundError, OSError) as error: logger.error( From f740cf35086a56ca063f9e56f097b5401dd33a32 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 3 Nov 2025 20:03:36 -0500 Subject: [PATCH 13/21] Cleanup --- .../attributes/file_attributes_controller.py | 13 +++--- .../attributes/file_attributes_model.py | 13 ++++-- .../extension_and_size_property_widget.py | 24 ++++++---- .../attributes/file_attributes_view.py | 46 ------------------- .../attributes/font_family_property_widget.py | 9 ++-- 5 files changed, 35 insertions(+), 70 deletions(-) diff --git a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py index 99db7ae67..45a19b821 100644 --- a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py +++ b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py @@ -40,14 +40,16 @@ def update_file_path(self, file_path: Path) -> None: self.file_path_label.set_file_path(file_path) # Update path-based properties - self.update_file_property(FilePropertyType.EXTENSION_AND_SIZE, file_path=file_path, library_dir=self.library.library_dir) + self.update_file_property( + FilePropertyType.EXTENSION_AND_SIZE, + file_path=file_path, + library_dir=self.library.library_dir, + ) if MediaCategories.is_ext_in_category( file_path.suffix.lower(), MediaCategories.FONT_TYPES, mime_fallback=True ): - self.update_file_property( - FilePropertyType.FONT_FAMILY, file_path=file_path - ) + self.update_file_property(FilePropertyType.FONT_FAMILY, file_path=file_path) # Format the path according to the user's settings display_path: Path = file_path @@ -116,8 +118,7 @@ def update_file_property(self, property_type: FilePropertyType, **kwargs) -> Non self.model.add_property(property_type, new_property_widget) self.properties_layout.insertWidget( - self.model.get_property_index(property_type), - new_property_widget + self.model.get_property_index(property_type), new_property_widget ) else: property_widget: FilePropertyWidget | None = self.model.get_property(property_type) diff --git a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py index 7f52419f0..99648a3e2 100644 --- a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py +++ b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py @@ -1,7 +1,7 @@ from enum import Enum -from PySide6.QtCore import QAbstractItemModel, Signal import structlog +from PySide6.QtCore import QAbstractItemModel, Signal from tagstudio.qt.views.preview_panel.attributes.dimension_property_widget import ( DimensionPropertyWidget, @@ -9,9 +9,13 @@ from tagstudio.qt.views.preview_panel.attributes.duration_property_widget import ( DurationPropertyWidget, ) -from tagstudio.qt.views.preview_panel.attributes.extension_and_size_property_widget import ExtensionAndSizePropertyWidget +from tagstudio.qt.views.preview_panel.attributes.extension_and_size_property_widget import ( + ExtensionAndSizePropertyWidget, +) from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget -from tagstudio.qt.views.preview_panel.attributes.font_family_property_widget import FontFamilyPropertyWidget +from tagstudio.qt.views.preview_panel.attributes.font_family_property_widget import ( + FontFamilyPropertyWidget, +) logger = structlog.get_logger(__name__) @@ -45,7 +49,8 @@ def get_properties(self) -> dict[FilePropertyType, FilePropertyWidget]: def get_property_index(self, property_type: FilePropertyType) -> int: for index, key in enumerate(self.get_properties()): - if property_type == key: return index + if property_type == key: + return index return -1 diff --git a/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py index 230cc7034..a417cb2f3 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py @@ -1,15 +1,13 @@ from pathlib import Path -from humanfriendly import format_size import structlog +from humanfriendly import format_size -from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.library.ignore import Ignore from tagstudio.core.utils.types import unwrap from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color from tagstudio.qt.translations import Translations from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget -from tagstudio.core.library.ignore import Ignore - logger = structlog.get_logger(__name__) @@ -27,7 +25,7 @@ def __init__(self) -> None: def set_value(self, **kwargs) -> bool: file_path = kwargs.get("file_path", Path()) - library_dir: Path | None = kwargs.get("library_dir", None) + library_dir: Path | None = kwargs.get("library_dir") try: components: list[str] = [] @@ -43,17 +41,23 @@ def set_value(self, **kwargs) -> bool: components.append(format_size(file_size)) # Ignored - if library_dir and Ignore.compiled_patterns and Ignore.compiled_patterns.match( - file_path.relative_to(unwrap(library_dir)) + if ( + library_dir + and Ignore.compiled_patterns + and Ignore.compiled_patterns.match(file_path.relative_to(unwrap(library_dir))) ): components.append( - f"{Translations['preview.ignored'].upper()}" + f""" + {Translations["preview.ignored"].upper()} + """ ) # Unlinked if not file_path.exists(): components.append( - f"{Translations['preview.unlinked'].upper()}" + f""" + {Translations["preview.unlinked"].upper()} + """ ) self.setText(" • ".join(components)) @@ -64,4 +68,4 @@ def set_value(self, **kwargs) -> bool: file_path=file_path, error=error, ) - return False \ No newline at end of file + return False diff --git a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py index c064dd1be..991878269 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py @@ -110,49 +110,3 @@ def __init__(self): self.properties_layout.setSpacing(0) self.__root_layout.addWidget(self.properties) - - # def update_stats(self, filepath: Path | None = None, stats: FileAttributeData | None = None): - # """Render the panel widgets with the newest data from the Library.""" - # if not stats: - # stats = FileAttributeData() - # - # else: - # ext = filepath.suffix.lower() - # - # # Attempt to populate the stat variables - # ext_display = ext.upper()[1:] or filepath.stem.upper() - # if filepath and filepath.is_file(): - # try: - # file_size = format_size(filepath.stat().st_size) - # - # except (FileNotFoundError, OSError) as e: - # logger.error( - # "[FileAttributes] Could not process file stats", filepath=filepath, error=e - # ) - # - # - # if ext_display: - # stats_label_text += ext_display - # red = get_ui_color(ColorType.PRIMARY, UiColor.RED) - # orange = get_ui_color(ColorType.PRIMARY, UiColor.ORANGE) - # - # if Ignore.compiled_patterns and Ignore.compiled_patterns.match( - # filepath.relative_to(unwrap(self.library.library_dir)) - # ): - # stats_label_text = ( - # f"{stats_label_text}" - # f" • " - # f"{Translations['preview.ignored'].upper()}" - # ) - # if not filepath.exists(): - # stats_label_text = ( - # f"{stats_label_text}" - # f" • " - # f"{Translations['preview.unlinked'].upper()}" - # ) - # if file_size: - # stats_label_text += f" • {file_size}" - # elif file_size: - # stats_label_text += file_size - # - # self.dimensions_label.setText(stats_label_text) diff --git a/src/tagstudio/qt/views/preview_panel/attributes/font_family_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/font_family_property_widget.py index ce4b51734..e1ecd8f12 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/font_family_property_widget.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/font_family_property_widget.py @@ -1,11 +1,10 @@ from pathlib import Path -from PIL import ImageFont import structlog +from PIL import ImageFont from tagstudio.qt.views.preview_panel.attributes.file_property_widget import FilePropertyWidget - logger = structlog.get_logger(__name__) @@ -29,6 +28,8 @@ def set_value(self, **kwargs) -> bool: return True except (FileNotFoundError, OSError) as error: logger.error( - "[FontFamilyPropertyWidget] Could not process font family", file_path=file_path, error=error + "[FontFamilyPropertyWidget] Could not process font family", + file_path=file_path, + error=error, ) - return False \ No newline at end of file + return False From 81ed61e7663714dae47266043bbde5a7929667a7 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 3 Nov 2025 20:30:52 -0500 Subject: [PATCH 14/21] Fix styling --- .../attributes/file_attributes_view.py | 22 +++++++------------ .../attributes/file_property_widget.py | 10 +++++++++ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py index 991878269..9cd5bbae3 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py @@ -34,22 +34,16 @@ class FileAttributesView(QWidget): def __init__(self): super().__init__() - label_bg_color = ( + self.panel_bg_color = ( Theme.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark else Theme.COLOR_BG_LIGHT.value ) - properties_style = f""" - background-color: {label_bg_color}; - color: #FFFFFF; - font-family: Oxanium; - font-weight: bold; - font-size: 12px; - border-radius: 3px; - padding-top: 4px; - padding-right: 1px; - padding-bottom: 1px; - padding-left: 1px; + self.properties_style = f""" + QWidget#properties{{ + background: {self.panel_bg_color}; + border-radius: 3px; + }} """ self.__root_layout = QVBoxLayout(self) @@ -102,11 +96,11 @@ def __init__(self): # File properties self.properties = QWidget() self.properties.setObjectName("properties") - self.properties.setStyleSheet(properties_style) + self.properties.setStyleSheet(self.properties_style) self.properties_layout = QVBoxLayout(self.properties) self.properties_layout.setObjectName("properties_layout") - self.properties_layout.setContentsMargins(0, 0, 0, 0) + self.properties_layout.setContentsMargins(4, 4, 4, 4) self.properties_layout.setSpacing(0) self.__root_layout.addWidget(self.properties) diff --git a/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py index 29f6a037a..1a7097d2a 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py @@ -7,5 +7,15 @@ class FilePropertyWidget(QLabel): def __init__(self) -> None: super().__init__() + self.label_style = """ + QLabel{ + color: #FFFFFF; + font-family: Oxanium; + font-weight: bold; + font-size: 12px; + } + """ + self.setStyleSheet(self.label_style) + def set_value(self, **kwargs) -> bool: raise NotImplementedError() From 445417889306f81ae5fdd7333601e7fcb2ff8a92 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 3 Nov 2025 21:21:52 -0500 Subject: [PATCH 15/21] Fix tests --- .../attributes/file_attributes_controller.py | 40 +++++++++++-------- tests/qt/test_field_containers.py | 2 +- tests/qt/test_file_path_options.py | 25 ++++-------- tests/qt/test_preview_panel.py | 2 +- 4 files changed, 32 insertions(+), 37 deletions(-) diff --git a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py index 45a19b821..dc86b95dc 100644 --- a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py +++ b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py @@ -52,23 +52,7 @@ def update_file_path(self, file_path: Path) -> None: self.update_file_property(FilePropertyType.FONT_FAMILY, file_path=file_path) # Format the path according to the user's settings - display_path: Path = file_path - match self.driver.settings.show_filepath: - case ShowFilepathOption.SHOW_FULL_PATHS: - display_path = file_path - case ShowFilepathOption.SHOW_RELATIVE_PATHS: - display_path = Path(file_path).relative_to(unwrap(self.library.library_dir)) - case ShowFilepathOption.SHOW_FILENAMES_ONLY: - display_path = Path(file_path.name) - - # Stringify the path - path_separator: str = f"{os.path.sep}" # Gray - - path_parts: list[str] = list(display_path.parts) - path_parts[-1] = f"
{path_parts[-1]}" - - path_string: str = path_separator.join(path_parts) - self.file_path_label.setText(path_string) + self.file_path_label.setText(self.format_path(file_path)) def update_date_label(self, file_path: Path | None = None) -> None: """Update the "Date Created" and "Date Modified" file property labels.""" @@ -173,3 +157,25 @@ def set_selection_size(self, num_selected: int): self.date_created_label.setHidden(True) self.date_modified_label.setHidden(True) self.properties.setHidden(True) + + def format_path(self, path: Path) -> str: + display_path: Path = path + + # Tweak path to match library settings + match self.driver.settings.show_filepath: + case ShowFilepathOption.SHOW_FULL_PATHS: + display_path = path + case ShowFilepathOption.SHOW_RELATIVE_PATHS: + display_path = Path(path).relative_to(unwrap(self.library.library_dir)) + case ShowFilepathOption.SHOW_FILENAMES_ONLY: + display_path = Path(path.name) + + # Format the path + path_separator: str = f"{os.path.sep}" # Gray + + path_parts: list[str] = list(display_path.parts) + path_parts[-1] = f"
{path_parts[-1]}" + + path_string: str = path_separator.join(path_parts) + + return path_string \ No newline at end of file diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 2b9921146..064d94866 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -6,7 +6,7 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry, Tag from tagstudio.core.utils.types import unwrap -from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel +from tagstudio.qt.controllers.preview_panel.preview_panel_controller import PreviewPanel from tagstudio.qt.ts_qt import QtDriver diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index 412cd56fe..a9de61f83 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -18,9 +18,9 @@ from tagstudio.core.enums import ShowFilepathOption from tagstudio.core.library.alchemy.library import Library, LibraryStatus from tagstudio.core.library.alchemy.models import Entry -from tagstudio.core.utils.types import unwrap -from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel +from tagstudio.qt.controllers.preview_panel.preview_panel_controller import PreviewPanel from tagstudio.qt.mixed.settings_panel import SettingsPanel +from tagstudio.qt.models.preview_panel.attributes.file_attributes_model import FilePropertyType from tagstudio.qt.ts_qt import QtDriver @@ -76,26 +76,15 @@ def test_file_path_display( # Apply the mock value entry = library.get_entry(2) assert isinstance(entry, Entry) - filename = entry.path - panel._file_attributes_widget.update_stats(filepath=unwrap(library.library_dir) / filename) # pyright: ignore[reportPrivateUsage] - # Generate the expected file string. - # This is copied directly from the file_attributes.py file - # can be imported as a function in the future + panel._file_attributes.update_file_property(FilePropertyType.EXTENSION_AND_SIZE, file_path=entry.path) + display_path: Path = expected_path(library) - file_str: str = "" - separator: str = f"{os.path.sep}" # Gray - for i, part in enumerate(display_path.parts): - part_ = part.strip(os.path.sep) - if i != len(display_path.parts) - 1: - file_str += f"{'\u200b'.join(part_)}{separator}" - else: - if file_str != "": - file_str += "
" - file_str += f"{'\u200b'.join(part_)}" + print(display_path) + path_string: str = panel._file_attributes.format_path(display_path) # Assert the file path is displayed correctly - assert panel._file_attributes_widget.file_label.text() == file_str # pyright: ignore[reportPrivateUsage] + assert panel._file_attributes_widget.file_path_label.text() == path_string # pyright: ignore[reportPrivateUsage] @pytest.mark.parametrize( diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py index 12282c9b2..f5e2beab5 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -5,7 +5,7 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry -from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel +from tagstudio.qt.controllers.preview_panel.preview_panel_controller import PreviewPanel from tagstudio.qt.ts_qt import QtDriver From 908013261f8405fad23e6d5ef524badef4789eec Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 3 Nov 2025 21:33:10 -0500 Subject: [PATCH 16/21] I may be stupid --- .../controllers/attributes/file_attributes_controller.py | 2 +- tests/qt/test_file_path_options.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py index dc86b95dc..73e1152f7 100644 --- a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py +++ b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py @@ -178,4 +178,4 @@ def format_path(self, path: Path) -> str: path_string: str = path_separator.join(path_parts) - return path_string \ No newline at end of file + return path_string diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index a9de61f83..7330f608d 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -3,7 +3,6 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import os from collections.abc import Callable from pathlib import Path from unittest.mock import patch @@ -18,9 +17,9 @@ from tagstudio.core.enums import ShowFilepathOption from tagstudio.core.library.alchemy.library import Library, LibraryStatus from tagstudio.core.library.alchemy.models import Entry +from tagstudio.core.utils.types import unwrap from tagstudio.qt.controllers.preview_panel.preview_panel_controller import PreviewPanel from tagstudio.qt.mixed.settings_panel import SettingsPanel -from tagstudio.qt.models.preview_panel.attributes.file_attributes_model import FilePropertyType from tagstudio.qt.ts_qt import QtDriver @@ -77,10 +76,10 @@ def test_file_path_display( entry = library.get_entry(2) assert isinstance(entry, Entry) - panel._file_attributes.update_file_property(FilePropertyType.EXTENSION_AND_SIZE, file_path=entry.path) + file_name = entry.path + panel._file_attributes.update_file_path(unwrap(library.library_dir) / file_name) display_path: Path = expected_path(library) - print(display_path) path_string: str = panel._file_attributes.format_path(display_path) # Assert the file path is displayed correctly From a5a9e884dc17565c8f789768bc279662b1c40877 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 3 Nov 2025 21:40:31 -0500 Subject: [PATCH 17/21] Fix it for real this time --- .../attributes/file_attributes_controller.py | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py index 73e1152f7..68b702770 100644 --- a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py +++ b/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py @@ -51,8 +51,18 @@ def update_file_path(self, file_path: Path) -> None: ): self.update_file_property(FilePropertyType.FONT_FAMILY, file_path=file_path) + display_path: Path = file_path + # Format the path according to the user's settings - self.file_path_label.setText(self.format_path(file_path)) + match self.driver.settings.show_filepath: + case ShowFilepathOption.SHOW_FULL_PATHS: + display_path = file_path + case ShowFilepathOption.SHOW_RELATIVE_PATHS: + display_path = Path(file_path).relative_to(unwrap(self.library.library_dir)) + case ShowFilepathOption.SHOW_FILENAMES_ONLY: + display_path = Path(file_path.name) + + self.file_path_label.setText(self.format_path(display_path)) def update_date_label(self, file_path: Path | None = None) -> None: """Update the "Date Created" and "Date Modified" file property labels.""" @@ -159,21 +169,9 @@ def set_selection_size(self, num_selected: int): self.properties.setHidden(True) def format_path(self, path: Path) -> str: - display_path: Path = path - - # Tweak path to match library settings - match self.driver.settings.show_filepath: - case ShowFilepathOption.SHOW_FULL_PATHS: - display_path = path - case ShowFilepathOption.SHOW_RELATIVE_PATHS: - display_path = Path(path).relative_to(unwrap(self.library.library_dir)) - case ShowFilepathOption.SHOW_FILENAMES_ONLY: - display_path = Path(path.name) - - # Format the path path_separator: str = f"{os.path.sep}" # Gray - path_parts: list[str] = list(display_path.parts) + path_parts: list[str] = list(path.parts) path_parts[-1] = f"
{path_parts[-1]}" path_string: str = path_separator.join(path_parts) From e9d768908eab33cbe145edb33ac6fff2a6f8515b Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 4 Nov 2025 10:24:30 -0500 Subject: [PATCH 18/21] Remove the now redundant `FileAttributeData` --- .../preview_panel/attributes/file_attributes_view.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py index 9cd5bbae3..6260ab01c 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py @@ -3,8 +3,6 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from dataclasses import dataclass - import structlog from PySide6.QtCore import Qt from PySide6.QtGui import QGuiApplication @@ -16,13 +14,6 @@ logger = structlog.get_logger(__name__) -@dataclass -class FileAttributeData: - width: int | None = None - height: int | None = None - duration: int | None = None - - FILE_NAME_LABEL_STYLE = "font-size: 12px;" DATE_LABEL_STYLE = "font-size: 12px;" From 4445478f3db97707b2de22b0a841da7a7b37c1a5 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 4 Nov 2025 10:56:35 -0500 Subject: [PATCH 19/21] Some final cleanup --- .../attributes/file_attributes_controller.py | 51 +++++++++---------- .../attributes/file_attributes_model.py | 20 ++++---- .../attributes/file_attributes_view.py | 2 +- .../views/preview_panel/preview_panel_view.py | 4 +- 4 files changed, 40 insertions(+), 37 deletions(-) rename src/tagstudio/qt/controllers/{ => preview_panel}/attributes/file_attributes_controller.py (80%) diff --git a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py b/src/tagstudio/qt/controllers/preview_panel/attributes/file_attributes_controller.py similarity index 80% rename from src/tagstudio/qt/controllers/attributes/file_attributes_controller.py rename to src/tagstudio/qt/controllers/preview_panel/attributes/file_attributes_controller.py index 68b702770..98dcf33bb 100644 --- a/src/tagstudio/qt/controllers/attributes/file_attributes_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel/attributes/file_attributes_controller.py @@ -29,12 +29,12 @@ class FileAttributes(FileAttributesView): """A widget displaying a list of a file's attributes.""" - def __init__(self, library: Library, driver: "QtDriver"): + def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() - self.library = library - self.driver = driver + self.__library = library + self.__driver = driver - self.model = FileAttributesModel() + self.__model = FileAttributesModel() def update_file_path(self, file_path: Path) -> None: self.file_path_label.set_file_path(file_path) @@ -43,7 +43,7 @@ def update_file_path(self, file_path: Path) -> None: self.update_file_property( FilePropertyType.EXTENSION_AND_SIZE, file_path=file_path, - library_dir=self.library.library_dir, + library_dir=self.__library.library_dir, ) if MediaCategories.is_ext_in_category( @@ -54,11 +54,11 @@ def update_file_path(self, file_path: Path) -> None: display_path: Path = file_path # Format the path according to the user's settings - match self.driver.settings.show_filepath: + match self.__driver.settings.show_filepath: case ShowFilepathOption.SHOW_FULL_PATHS: display_path = file_path case ShowFilepathOption.SHOW_RELATIVE_PATHS: - display_path = Path(file_path).relative_to(unwrap(self.library.library_dir)) + display_path = Path(file_path).relative_to(unwrap(self.__library.library_dir)) case ShowFilepathOption.SHOW_FILENAMES_ONLY: display_path = Path(file_path.name) @@ -76,11 +76,11 @@ def update_date_label(self, file_path: Path | None = None) -> None: else: created_timestamp = dt.fromtimestamp(file_path.stat().st_ctime) - date_created = self.driver.settings.format_datetime(created_timestamp) + date_created = self.__driver.settings.format_datetime(created_timestamp) # Date modified modified_timestamp: dt = dt.fromtimestamp(file_path.stat().st_mtime) - date_modified = self.driver.settings.format_datetime(modified_timestamp) + date_modified = self.__driver.settings.format_datetime(modified_timestamp) elif file_path: date_created = "N/A" date_modified = "N/A" @@ -102,37 +102,35 @@ def update_date_label(self, file_path: Path | None = None) -> None: self.date_modified_label.setHidden(True) def update_file_property(self, property_type: FilePropertyType, **kwargs) -> None: - """Update a property of the file.""" + """Update a file property with a new value.""" logger.debug("[FileAttributes] Updating file property", type=property_type, **kwargs) - if property_type not in self.model.get_properties(): - new_property_widget: FilePropertyWidget = property_type.widget_class() - result = new_property_widget.set_value(**kwargs) - new_property_widget.setHidden(not result) + property_widget: FilePropertyWidget | None = self.__model.get_property_widget(property_type) + widget_exists: bool = property_widget is not None + if not widget_exists: + property_widget = property_type.widget_class() - self.model.add_property(property_type, new_property_widget) + result: bool = property_widget.set_value(**kwargs) + property_widget.setHidden(not result) + + self.__model.set_property_widget(property_type, property_widget) + + if not widget_exists: self.properties_layout.insertWidget( - self.model.get_property_index(property_type), new_property_widget + self.__model.get_property_index(property_type), property_widget ) - else: - property_widget: FilePropertyWidget | None = self.model.get_property(property_type) - if property_widget: - result = property_widget.set_value(**kwargs) - property_widget.setHidden(not result) - - self.model.set_property(property_type, property_widget) - property_widget.show() def clear_file_properties(self) -> None: """Clears the existing file properties.""" logger.debug("[FileAttributes] Clearing file properties") - for property_widget in self.model.get_properties().values(): + for property_widget in self.__model.get_properties().values(): property_widget.hide() - self.model.delete_properties() + self.__model.delete_properties() def set_selection_size(self, num_selected: int): + """Sets the number of selected entries to adjust how the file properties are displayed.""" match num_selected: case 0: # File path label @@ -169,6 +167,7 @@ def set_selection_size(self, num_selected: int): self.properties.setHidden(True) def format_path(self, path: Path) -> str: + """Formats a file path for display.""" path_separator: str = f"{os.path.sep}" # Gray path_parts: list[str] = list(path.parts) diff --git a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py index 99648a3e2..bb1f683d8 100644 --- a/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py +++ b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py @@ -34,12 +34,13 @@ def __init__(self, name: str, widget_class: type[FilePropertyWidget]): class FileAttributesModel(QAbstractItemModel): properties_changed: Signal = Signal(dict) - def __init__(self): + def __init__(self) -> None: super().__init__() self.__property_widgets: dict[FilePropertyType, FilePropertyWidget] = {} def get_properties(self) -> dict[FilePropertyType, FilePropertyWidget]: + """Returns a sorted dictionary of all file properties.""" return dict( sorted( self.__property_widgets.items(), @@ -48,32 +49,33 @@ def get_properties(self) -> dict[FilePropertyType, FilePropertyWidget]: ) def get_property_index(self, property_type: FilePropertyType) -> int: + """Returns the sorted index of the given property type.""" for index, key in enumerate(self.get_properties()): if property_type == key: return index return -1 - def get_property(self, property_type: FilePropertyType) -> FilePropertyWidget | None: + def get_property_widget(self, property_type: FilePropertyType) -> FilePropertyWidget | None: + """Returns the widget for the given property type.""" if property_type in self.__property_widgets: return self.__property_widgets[property_type] return None - def add_property(self, property_type: FilePropertyType, widget: FilePropertyWidget) -> None: - if property_type not in self.__property_widgets: - self.__property_widgets[property_type] = widget - - self.properties_changed.emit(self.get_properties()) - - def set_property(self, property_type: FilePropertyType, widget: FilePropertyWidget) -> None: + def set_property_widget( + self, property_type: FilePropertyType, widget: FilePropertyWidget + ) -> None: + """Sets the widget for the given property type.""" if property_type not in self.__property_widgets: self.__property_widgets[property_type] = widget self.properties_changed.emit(self.get_properties()) def delete_property(self, property_type: FilePropertyType) -> None: + """Removes the given property type.""" self.__property_widgets.pop(property_type, None) def delete_properties(self) -> None: + """Removes all file properties.""" self.__property_widgets.clear() diff --git a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py index 6260ab01c..f891d1111 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py @@ -22,7 +22,7 @@ class FileAttributesView(QWidget): """A widget displaying a list of a file's attributes.""" - def __init__(self): + def __init__(self) -> None: super().__init__() self.panel_bg_color = ( diff --git a/src/tagstudio/qt/views/preview_panel/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel/preview_panel_view.py index af6777518..11e63eab4 100644 --- a/src/tagstudio/qt/views/preview_panel/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel/preview_panel_view.py @@ -18,7 +18,9 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.utils.types import unwrap -from tagstudio.qt.controllers.attributes.file_attributes_controller import FileAttributes +from tagstudio.qt.controllers.preview_panel.attributes.file_attributes_controller import ( + FileAttributes, +) from tagstudio.qt.controllers.preview_panel.thumbnail.preview_thumb_controller import PreviewThumb from tagstudio.qt.mixed.field_containers import FieldContainers from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color From cfcc5dfc7a64845da710eebfdd3cc60ea30641d8 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 4 Nov 2025 11:23:57 -0500 Subject: [PATCH 20/21] Style tweaks --- .../qt/views/preview_panel/attributes/file_attributes_view.py | 2 +- .../qt/views/preview_panel/attributes/file_property_widget.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py index f891d1111..4d02c82e7 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py @@ -92,6 +92,6 @@ def __init__(self) -> None: self.properties_layout = QVBoxLayout(self.properties) self.properties_layout.setObjectName("properties_layout") self.properties_layout.setContentsMargins(4, 4, 4, 4) - self.properties_layout.setSpacing(0) + self.properties_layout.setSpacing(4) self.__root_layout.addWidget(self.properties) diff --git a/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py index 1a7097d2a..e4be29890 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py @@ -16,6 +16,7 @@ def __init__(self) -> None: } """ self.setStyleSheet(self.label_style) + self.setMaximumHeight(12) def set_value(self, **kwargs) -> bool: raise NotImplementedError() From 34d097434200d439eb5e08ab5e030153dd43c46a Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 4 Nov 2025 11:35:21 -0500 Subject: [PATCH 21/21] Fix ordering of extension and size property widget --- .../attributes/extension_and_size_property_widget.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py index a417cb2f3..a689f4324 100644 --- a/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py +++ b/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py @@ -34,12 +34,6 @@ def set_value(self, **kwargs) -> bool: extension: str = file_path.suffix.upper()[1:] or file_path.stem.upper() components.append(extension) - # File size - if file_path and file_path.is_file(): - file_size = file_path.stat().st_size - if file_size and file_size > 0: - components.append(format_size(file_size)) - # Ignored if ( library_dir @@ -60,6 +54,12 @@ def set_value(self, **kwargs) -> bool: """ ) + # File size + if file_path and file_path.is_file(): + file_size = file_path.stat().st_size + if file_size and file_size > 0: + components.append(format_size(file_size)) + self.setText(" • ".join(components)) return True except (FileNotFoundError, OSError) as error: