Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 = "<i>N/A</i>"
date_modified = "<i>N/A</i>"

if date_created is not None:
self.date_created_label.setText(
f"<b>{Translations['file.date_created']}:</b> {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"<b>{Translations['file.date_modified']}:</b> {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"<i>{Translations['preview.no_selection']}</i>")
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"<a style='color: #777777'><b>{os.path.sep}</b></a>" # Gray

path_parts: list[str] = list(path.parts)
path_parts[-1] = f"<br><b>{path_parts[-1]}</b>"

path_string: str = path_separator.join(path_parts)

return path_string
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,16 +27,28 @@


class PreviewThumb(PreviewThumbView):
dimensions_changed = Signal(QSize)
duration_changed = Signal(int)

__current_file: Path

def __init__(self, library: Library, driver: "QtDriver"):
super().__init__(library, driver)

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():
Expand All @@ -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]
Expand All @@ -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,
Expand All @@ -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()

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

Expand All @@ -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(
Expand Down
Loading