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