diff --git a/src/tagstudio/qt/controllers/preview_panel/attributes/file_attributes_controller.py b/src/tagstudio/qt/controllers/preview_panel/attributes/file_attributes_controller.py new file mode 100644 index 000000000..98dcf33bb --- /dev/null +++ b/src/tagstudio/qt/controllers/preview_panel/attributes/file_attributes_controller.py @@ -0,0 +1,178 @@ +import os +import platform +import typing +from datetime import datetime as dt +from pathlib import Path + +import structlog +from PySide6.QtGui import Qt + +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, + 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") -> None: + 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) + + # Update path-based properties + 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) + + display_path: Path = file_path + + # Format the path according to the user's settings + 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.""" + 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 file property with a new value.""" + logger.debug("[FileAttributes] Updating file property", type=property_type, **kwargs) + + 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() + + 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), property_widget + ) + + 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): + """Sets the number of selected entries to adjust how the file properties are displayed.""" + 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) + + 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) + path_parts[-1] = f"
{path_parts[-1]}" + + path_string: str = path_separator.join(path_parts) + + return path_string 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..953e2a75c 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel/preview_panel_controller.py @@ -4,12 +4,14 @@ 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 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 +24,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 +41,14 @@ 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, size: QSize) -> None: + self._file_attributes.update_file_property( + 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) + 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 72% 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..a5e0b0f8e 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,14 @@ import structlog from PIL import Image, UnidentifiedImageError from PIL.Image import DecompressionBombError -from PySide6.QtCore import QSize +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 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 +27,9 @@ class PreviewThumb(PreviewThumbView): + dimensions_changed = Signal(QSize) + duration_changed = Signal(int) + __current_file: Path def __init__(self, library: Library, driver: "QtDriver"): @@ -34,9 +37,18 @@ 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 _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.""" - stats = FileAttributeData() + size = QSize() ext = filepath.suffix.lower() if filepath.is_dir(): @@ -46,8 +58,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 +68,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 +79,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 +100,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 +115,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 +127,39 @@ 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) + self._display_video(filepath, video_size) + self._on_dimensions_change(video_size) + # 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._on_dimensions_change(gif_size) + # Other Types (Including Images) else: self._display_image(filepath) - return self.__get_image_stats(filepath) + + image_size: QSize = self.__get_image_size(filepath) + self._on_dimensions_change(image_size) 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 5704e6e9e..000000000 --- a/src/tagstudio/qt/mixed/file_attributes.py +++ /dev/null @@ -1,269 +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) - 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:] - except OverflowError: - dur_str = "-:--" - stats_label_text += f"{dur_str}" - - 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..9de883272 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,6 +50,8 @@ 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: @@ -105,7 +107,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 +134,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) 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..bb1f683d8 --- /dev/null +++ b/src/tagstudio/qt/models/preview_panel/attributes/file_attributes_model.py @@ -0,0 +1,81 @@ +from enum import Enum + +import structlog +from PySide6.QtCore import QAbstractItemModel, Signal + +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.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__) + + +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 + self.widget_class = widget_class + + +class FileAttributesModel(QAbstractItemModel): + properties_changed: Signal = Signal(dict) + + 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(), + key=lambda item: list(FilePropertyType.__members__.values()).index(item[0]), + ) + ) + + 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_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 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/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/dimension_property_widget.py b/src/tagstudio/qt/views/preview_panel/attributes/dimension_property_widget.py new file mode 100644 index 000000000..3c88bb4de --- /dev/null +++ b/src/tagstudio/qt/views/preview_panel/attributes/dimension_property_widget.py @@ -0,0 +1,20 @@ +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) -> 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 new file mode 100644 index 000000000..87a87cad4 --- /dev/null +++ b/src/tagstudio/qt/views/preview_panel/attributes/duration_property_widget.py @@ -0,0 +1,38 @@ +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) -> bool: + unknown_duration: str = "-:--" + duration: int = kwargs.get("duration", 0) + + logger.debug("[DurationPropertyWidget] Updating duration", duration=duration) + + try: + 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:] + 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) + 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 new file mode 100644 index 000000000..a689f4324 --- /dev/null +++ b/src/tagstudio/qt/views/preview_panel/attributes/extension_and_size_property_widget.py @@ -0,0 +1,71 @@ +from pathlib import Path + +import structlog +from humanfriendly import format_size + +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 + +logger = structlog.get_logger(__name__) + + +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__() + + self.setObjectName("extension_and_size_property") + + def set_value(self, **kwargs) -> bool: + file_path = kwargs.get("file_path", Path()) + library_dir: Path | None = kwargs.get("library_dir") + + try: + components: list[str] = [] + + # File extension + extension: str = file_path.suffix.upper()[1:] or file_path.stem.upper() + components.append(extension) + + # 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()} + """ + ) + + # Unlinked + if not file_path.exists(): + components.append( + f""" + {Translations["preview.unlinked"].upper()} + """ + ) + + # 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: + logger.error( + "[ExtensionAndSizePropertyWidget] Could not process file stats", + file_path=file_path, + error=error, + ) + 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 new file mode 100644 index 000000000..4d02c82e7 --- /dev/null +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_attributes_view.py @@ -0,0 +1,97 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +import structlog +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication +from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget + +from tagstudio.core.enums import Theme +from tagstudio.qt.utils.file_opener import FileOpenerLabel + +logger = structlog.get_logger(__name__) + + +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) -> None: + super().__init__() + + self.panel_bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + self.properties_style = f""" + QWidget#properties{{ + background: {self.panel_bg_color}; + border-radius: 3px; + }} + """ + + 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(self.properties_style) + + self.properties_layout = QVBoxLayout(self.properties) + self.properties_layout.setObjectName("properties_layout") + self.properties_layout.setContentsMargins(4, 4, 4, 4) + 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 new file mode 100644 index 000000000..e4be29890 --- /dev/null +++ b/src/tagstudio/qt/views/preview_panel/attributes/file_property_widget.py @@ -0,0 +1,22 @@ +from PySide6.QtWidgets import QLabel + + +class FilePropertyWidget(QLabel): + """A widget representing a property of a file.""" + + 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) + self.setMaximumHeight(12) + + 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 new file mode 100644 index 000000000..e1ecd8f12 --- /dev/null +++ b/src/tagstudio/qt/views/preview_panel/attributes/font_family_property_widget.py @@ -0,0 +1,35 @@ +from pathlib import Path + +import structlog +from PIL import ImageFont + +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.""" + + def __init__(self) -> None: + super().__init__() + + self.setObjectName("font_family_property") + + def set_value(self, **kwargs) -> bool: + file_path = kwargs.get("file_path", Path()) + + 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 diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel/preview_panel_view.py similarity index 81% rename from src/tagstudio/qt/views/preview_panel_view.py rename to src/tagstudio/qt/views/preview_panel/preview_panel_view.py index 5ae7004cd..11e63eab4 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel/preview_panel_view.py @@ -1,12 +1,11 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio - 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, @@ -19,9 +18,11 @@ 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.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.mixed.file_attributes import FileAttributeData, FileAttributes from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color from tagstudio.qt.translations import Translations @@ -64,8 +65,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 +85,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 +137,12 @@ def _add_tag_button_callback(self): def _set_selection_callback(self): raise NotImplementedError() + def _file_dimensions_changed_callback(self, size: QSize) -> 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. @@ -142,11 +153,12 @@ 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() - 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 +171,11 @@ 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._thumb.display_file(filepath) + self._file_attributes.update_file_path(filepath) + + 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 60% 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..bce41da16 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_panel/thumbnail/preview_thumb_view.py @@ -13,8 +13,7 @@ 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 @@ -32,8 +31,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 +40,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 +60,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 +68,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 +89,34 @@ 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 +132,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 +140,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 +150,54 @@ 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 + elif self.__thumbnail_ratio <= 1: + adjusted_size.setWidth(int(size.height() * self.__thumbnail_ratio)) - if adj_width > size[0]: - adj_height = adj_height * (size[0] / adj_width) - adj_width = size[0] - elif adj_height > size[1]: - adj_width = adj_width * (size[1] / adj_height) - adj_height = size[1] - - adj_size = QSize(int(adj_width), int(adj_height)) + 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 +205,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 +215,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,74 +227,41 @@ 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 + self._media_player.play(filepath) - def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData: + 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.__image_ratio = stats.width / stats.height - self.resizeEvent( - QResizeEvent( - QSize(stats.width, stats.height), - QSize(stats.width, stats.height), - ) - ) - - return stats + self.__update_media_player(filepath) - 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)) + 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. 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()) 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( - QSize(stats.width, stats.height), - QSize(stats.width, stats.height), - ) - ) - movie.start() - - stats.duration = movie.frameCount() // 60 - - return stats + if movie.frameCount() > 1: + self.__switch_preview(MediaType.IMAGE_ANIMATED) + movie.start() - 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 +273,14 @@ def hide_preview(self) -> None: @override def resizeEvent(self, event: QResizeEvent) -> None: - self.__update_image_size((self.size().width(), self.size().height())) + 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.__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 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..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 @@ -19,7 +18,7 @@ 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.ts_qt import QtDriver @@ -76,26 +75,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 + file_name = entry.path + panel._file_attributes.update_file_path(unwrap(library.library_dir) / file_name) + 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_)}" + 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