diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py
index 416e8c64..781bba0a 100644
--- a/tagstudio/src/core/enums.py
+++ b/tagstudio/src/core/enums.py
@@ -65,4 +65,4 @@ class LibraryPrefs(DefaultEnum):
IS_EXCLUDE_LIST = True
EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"]
PAGE_SIZE: int = 500
- DB_VERSION: int = 2
+ DB_VERSION: int = 3
diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py
index 6b6ee83a..56513e8a 100644
--- a/tagstudio/src/qt/ts_qt.py
+++ b/tagstudio/src/qt/ts_qt.py
@@ -138,6 +138,8 @@ class QtDriver(DriverMixin, QObject):
self.args = args
self.frame_content = []
self.filter = FilterState.show_all()
+ self.frame_content: list[Entry] = []
+ self.filter = FilterState().show_all()
self.pages_count = 0
self.scrollbar_pos = 0
@@ -642,7 +644,7 @@ class QtDriver(DriverMixin, QObject):
self.main_window.setWindowTitle(self.base_title)
- self.selected = []
+ self.selected: list[int] = []
self.frame_content = []
[x.set_mode(None) for x in self.item_thumbs]
diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py
new file mode 100644
index 00000000..c05950f7
--- /dev/null
+++ b/tagstudio/src/qt/widgets/preview/field_containers.py
@@ -0,0 +1,720 @@
+# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
+# Licensed under the GPL-3.0 License.
+# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
+
+import typing
+
+import structlog
+from PySide6.QtCore import Signal
+from PySide6.QtWidgets import (
+ QWidget,
+)
+from src.core.library.alchemy.library import Library
+from src.qt.translations import Translations
+
+if typing.TYPE_CHECKING:
+ from src.qt.ts_qt import QtDriver
+
+logger = structlog.get_logger(__name__)
+
+
+class FieldContainers(QWidget):
+ """The Preview Panel Widget."""
+
+ tags_updated = Signal()
+
+ def __init__(self, library: Library, driver: "QtDriver"):
+ super().__init__()
+
+
+# self.is_connected = False
+# self.lib = library
+# self.driver: QtDriver = driver
+# self.initialized = False
+# self.is_open: bool = False
+# self.common_fields: list = []
+# self.mixed_fields: list = []
+# self.selected: list[int] = [] # New way of tracking items
+# self.containers: list[FieldContainer] = []
+
+# self.panel_bg_color = (
+# Theme.COLOR_BG_DARK.value
+# if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
+# else Theme.COLOR_BG_LIGHT.value
+# )
+
+# self.scroll_layout = QVBoxLayout()
+# self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+# self.scroll_layout.setContentsMargins(6, 1, 6, 6)
+
+# scroll_container: QWidget = QWidget()
+# scroll_container.setObjectName("entryScrollContainer")
+# scroll_container.setLayout(self.scroll_layout)
+
+# info_section = QWidget()
+# info_layout = QVBoxLayout(info_section)
+# info_layout.setContentsMargins(0, 0, 0, 0)
+# info_layout.setSpacing(6)
+
+# self.scroll_area = QScrollArea()
+# self.scroll_area.setObjectName("entryScrollArea")
+# self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+# self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+# self.scroll_area.setWidgetResizable(True)
+# self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
+# self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
+# # NOTE: I would rather have this style applied to the scroll_area
+# # background and NOT the scroll container background, so that the
+# # rounded corners are maintained when scrolling. I was unable to
+# # find the right trick to only select that particular element.
+
+# self.scroll_area.setStyleSheet(
+# "QWidget#entryScrollContainer{"
+# f"background:{self.panel_bg_color};"
+# "border-radius:6px;"
+# "}"
+# )
+# self.scroll_area.setWidget(scroll_container)
+
+# self.afb_container = QWidget()
+# self.afb_layout = QVBoxLayout(self.afb_container)
+# self.afb_layout.setContentsMargins(0, 12, 0, 0)
+
+# self.add_field_button = QPushButtonWrapper()
+# self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
+# self.add_field_button.setMinimumSize(96, 28)
+# self.add_field_button.setMaximumSize(96, 28)
+# Translations.translate_qobject(self.add_field_button, "library.field.add")
+# self.afb_layout.addWidget(self.add_field_button)
+# self.add_field_modal = AddFieldModal(self.lib)
+# self.place_add_field_button()
+
+# def remove_field_prompt(self, name: str) -> str:
+# return Translations.translate_formatted("library.field.confirm_remove", name=name)
+
+# def place_add_field_button(self):
+# self.scroll_layout.addWidget(self.afb_container)
+# self.scroll_layout.setAlignment(self.afb_container, Qt.AlignmentFlag.AlignHCenter)
+
+# if self.add_field_modal.is_connected:
+# self.add_field_modal.done.disconnect()
+# if self.add_field_button.is_connected:
+# self.add_field_button.clicked.disconnect()
+
+# self.add_field_modal.done.connect(
+# lambda f: (self.add_field_to_selected(f), self.update_widgets())
+# )
+# self.add_field_modal.is_connected = True
+# self.add_field_button.clicked.connect(self.add_field_modal.show)
+
+# def add_field_to_selected(self, field_list: list):
+# """Add list of entry fields to one or more selected items."""
+# logger.info("add_field_to_selected", selected=self.selected, fields=field_list)
+# for grid_idx in self.selected:
+# entry = self.driver.frame_content[grid_idx]
+# for field_item in field_list:
+# self.lib.add_entry_field_type(
+# entry.id,
+# field_id=field_item.data(Qt.ItemDataRole.UserRole),
+# )
+
+# def write_container(self, index: int, field: BaseField, is_mixed: bool = False):
+# """Update/Create data for a FieldContainer.
+
+# Args:
+# index(int): The container index.
+# field(BaseField): The type of field to write to.
+# is_mixed(bool): Relevant when multiple items are selected.
+
+# If True, field is not present in all selected items.
+# """
+# # Remove 'Add Field' button from scroll_layout, to be re-added later.
+# self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget()
+# if len(self.containers) < (index + 1):
+# container = FieldContainer()
+# self.containers.append(container)
+# self.scroll_layout.addWidget(container)
+# else:
+# container = self.containers[index]
+
+# # if isinstance(field, TagBoxField):
+# # container.set_title(field.type.name)
+# # container.set_inline(False)
+# # title = f"{field.type.name} (Tag Box)"
+# #
+# # if not is_mixed:
+# # inner_container = container.get_inner_widget()
+# # if isinstance(inner_container, TagBoxWidget):
+# # inner_container.set_field(field)
+# # inner_container.set_tags(list(field.tags))
+# #
+# # try:
+# # inner_container.updated.disconnect()
+# # except RuntimeError:
+# # logger.error("Failed to disconnect inner_container.updated")
+# #
+# # else:
+# # inner_container = TagBoxWidget(
+# # field,
+# # title,
+# # self.driver,
+# # )
+# #
+# # container.set_inner_widget(inner_container)
+# #
+# # inner_container.updated.connect(
+# # lambda: (
+# # self.write_container(index, field),
+# # self.update_widgets(),
+# # )
+# # )
+# # # NOTE: Tag Boxes have no Edit Button
+# # (But will when you can convert field types)
+# # container.set_remove_callback(
+# # lambda: self.remove_message_box(
+# # prompt=self.remove_field_prompt(field.type.name),
+# # callback=lambda: (
+# # self.remove_field(field),
+# # self.update_selected_entry(self.driver),
+# # # reload entry and its fields
+# # self.update_widgets(),
+# # ),
+# # )
+# # )
+# # else:
+# # text = "Mixed Data"
+# # title = f"{field.type.name} (Wacky Tag Box)"
+# # inner_container = TextWidget(title, text)
+# # container.set_inner_widget(inner_container)
+# #
+# # self.tags_updated.emit()
+# # # self.dynamic_widgets.append(inner_container)
+# # elif field.type.type == FieldTypeEnum.TEXT_LINE:
+# if field.type.type == FieldTypeEnum.TEXT_LINE:
+# container.set_title(field.type.name)
+# container.set_inline(False)
+
+# # Normalize line endings in any text content.
+# if not is_mixed:
+# assert isinstance(field.value, (str, type(None)))
+# text = field.value or ""
+# else:
+# text = "Mixed Data"
+
+# title = f"{field.type.name} ({field.type.type.value})"
+# inner_container = TextWidget(title, text)
+# container.set_inner_widget(inner_container)
+# if not is_mixed:
+# modal = PanelModal(
+# EditTextLine(field.value),
+# title=title,
+# window_title=f"Edit {field.type.type.value}",
+# save_callback=(
+# lambda content: (
+# self.update_field(field, content),
+# self.update_widgets(),
+# )
+# ),
+# )
+# if "pytest" in sys.modules:
+# # for better testability
+# container.modal = modal # type: ignore
+
+# container.set_edit_callback(modal.show)
+# container.set_remove_callback(
+# lambda: self.remove_message_box(
+# prompt=self.remove_field_prompt(field.type.type.value),
+# callback=lambda: (
+# self.remove_field(field),
+# self.update_widgets(),
+# ),
+# )
+# )
+
+# elif field.type.type == FieldTypeEnum.TEXT_BOX:
+# container.set_title(field.type.name)
+# # container.set_editable(True)
+# container.set_inline(False)
+# # Normalize line endings in any text content.
+# if not is_mixed:
+# assert isinstance(field.value, (str, type(None)))
+# text = (field.value or "").replace("\r", "\n")
+# else:
+# text = "Mixed Data"
+# title = f"{field.type.name} (Text Box)"
+# inner_container = TextWidget(title, text)
+# container.set_inner_widget(inner_container)
+# if not is_mixed:
+# modal = PanelModal(
+# EditTextBox(field.value),
+# title=title,
+# window_title=f"Edit {field.type.name}",
+# save_callback=(
+# lambda content: (
+# self.update_field(field, content),
+# self.update_widgets(),
+# )
+# ),
+# )
+# container.set_edit_callback(modal.show)
+# container.set_remove_callback(
+# lambda: self.remove_message_box(
+# prompt=self.remove_field_prompt(field.type.name),
+# callback=lambda: (
+# self.remove_field(field),
+# self.update_widgets(),
+# ),
+# )
+# )
+
+# elif field.type.type == FieldTypeEnum.DATETIME:
+# if not is_mixed:
+# try:
+# container.set_title(field.type.name)
+# # container.set_editable(False)
+# container.set_inline(False)
+# # TODO: Localize this and/or add preferences.
+# date = dt.strptime(field.value, "%Y-%m-%d %H:%M:%S")
+# title = f"{field.type.name} (Date)"
+# inner_container = TextWidget(title, date.strftime("%D - %r"))
+# container.set_inner_widget(inner_container)
+# except Exception:
+# container.set_title(field.type.name)
+# # container.set_editable(False)
+# container.set_inline(False)
+# title = f"{field.type.name} (Date) (Unknown Format)"
+# inner_container = TextWidget(title, str(field.value))
+# container.set_inner_widget(inner_container)
+
+# container.set_remove_callback(
+# lambda: self.remove_message_box(
+# prompt=self.remove_field_prompt(field.type.name),
+# callback=lambda: (
+# self.remove_field(field),
+# self.update_widgets(),
+# ),
+# )
+# )
+# else:
+# text = "Mixed Data"
+# title = f"{field.type.name} (Wacky Date)"
+# inner_container = TextWidget(title, text)
+# container.set_inner_widget(inner_container)
+# else:
+# logger.warning("write_container - unknown field", field=field)
+# container.set_title(field.type.name)
+# container.set_inline(False)
+# title = f"{field.type.name} (Unknown Field Type)"
+# inner_container = TextWidget(title, field.type.name)
+# container.set_inner_widget(inner_container)
+# container.set_remove_callback(
+# lambda: self.remove_message_box(
+# prompt=self.remove_field_prompt(field.type.name),
+# callback=lambda: (
+# self.remove_field(field),
+# self.update_widgets(),
+# ),
+# )
+# )
+
+# container.edit_button.setHidden(True)
+# container.setHidden(False)
+# self.place_add_field_button()
+
+# def remove_field(self, field: BaseField):
+# """Remove a field from all selected Entries."""
+# logger.info("removing field", field=field, selected=self.selected)
+# entry_ids = []
+
+# for grid_idx in self.selected:
+# entry = self.driver.frame_content[grid_idx]
+# entry_ids.append(entry.id)
+
+# self.lib.remove_entry_field(field, entry_ids)
+
+# # # if the field is meta tags, update the badges
+# # if field.type_key == _FieldID.TAGS_META.value:
+# # self.driver.update_badges(self.selected)
+
+# def update_field(self, field: BaseField, content: str) -> None:
+# """Update a field in all selected Entries, given a field object."""
+# assert isinstance(
+# field,
+# (TextField, DatetimeField), # , TagBoxField)
+# ), f"instance: {type(field)}"
+
+# entry_ids = []
+# for grid_idx in self.selected:
+# entry = self.driver.frame_content[grid_idx]
+# entry_ids.append(entry.id)
+
+# assert entry_ids, "No entries selected"
+# self.lib.update_entry_field(
+# entry_ids,
+# field,
+# content,
+# )
+
+# def remove_message_box(self, prompt: str, callback: Callable) -> None:
+# remove_mb = QMessageBox()
+# remove_mb.setText(prompt)
+# remove_mb.setWindowTitle("Remove Field")
+# remove_mb.setIcon(QMessageBox.Icon.Warning)
+# cancel_button = remove_mb.addButton(
+# Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole
+# )
+# remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole)
+# # remove_mb.setStandardButtons(QMessageBox.StandardButton.Cancel)
+# remove_mb.setDefaultButton(cancel_button)
+# remove_mb.setEscapeButton(cancel_button)
+# result = remove_mb.exec_()
+# # logging.info(result)
+# if result == 3: # TODO - what is this magic number?
+# callback()
+
+# def set_tags_updated_slot(self, slot: object):
+# """Replacement for tag_callback."""
+# if self.is_connected:
+# self.tags_updated.disconnect()
+
+# logger.info("[UPDATE CONTAINER] Setting tags updated slot")
+# self.tags_updated.connect(slot)
+# self.is_connected = True
+
+# def update_widgets(self):
+# """Render the panel widgets with the newest data from the Library."""
+# logger.info("update_widgets", selected=self.driver.selected)
+# self.is_open = True
+# # self.tag_callback = tag_callback if tag_callback else None
+# window_title = ""
+
+# # # update list of libraries
+# # self.fill_libs_widget(self.libs_layout)
+
+# if not self.driver.selected:
+# if self.selected or not self.initialized:
+# self.file_label.setText("No Items Selected")
+# self.file_label.set_file_path("")
+# self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
+
+# self.dimensions_label.setText("")
+# self.update_date_label()
+# self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
+# self.preview_img.setCursor(Qt.CursorShape.ArrowCursor)
+
+# ratio = self.devicePixelRatio()
+# self.thumb_renderer.render(
+# time.time(),
+# "",
+# (512, 512),
+# ratio,
+# is_loading=True,
+# update_on_ratio_change=True,
+# )
+# if self.preview_img.is_connected:
+# self.preview_img.clicked.disconnect()
+# for c in self.containers:
+# c.setHidden(True)
+# self.preview_img.show()
+# self.preview_vid.stop()
+# self.preview_vid.hide()
+# self.media_player.hide()
+# self.media_player.stop()
+# self.preview_gif.hide()
+# self.selected = list(self.driver.selected)
+# self.add_field_button.setHidden(True)
+
+# # common code
+# self.initialized = True
+# self.setWindowTitle(window_title)
+# self.show()
+# return True
+
+# # reload entry and fill it into the grid again
+# # TODO - do this more granular
+# # TODO - Entry reload is maybe not necessary
+# for grid_idx in self.driver.selected:
+# entry = self.driver.frame_content[grid_idx]
+# results = self.lib.search_library(FilterState(id=entry.id))
+# logger.info(
+# "found item",
+# entries=len(results.items),
+# grid_idx=grid_idx,
+# lookup_id=entry.id,
+# )
+# self.driver.frame_content[grid_idx] = results[0]
+
+# if len(self.driver.selected) == 1:
+# # 1 Selected Entry
+# selected_idx = self.driver.selected[0]
+# item = self.driver.frame_content[selected_idx]
+
+# self.preview_img.show()
+# self.preview_vid.stop()
+# self.preview_vid.hide()
+# self.media_player.stop()
+# self.media_player.hide()
+# self.preview_gif.hide()
+
+# # If a new selection is made, update the thumbnail and filepath.
+# if not self.selected or self.selected != self.driver.selected:
+# filepath = self.lib.library_dir / item.path
+# self.file_label.set_file_path(filepath)
+# ratio = self.devicePixelRatio()
+# self.thumb_renderer.render(
+# time.time(),
+# filepath,
+# (512, 512),
+# ratio,
+# update_on_ratio_change=True,
+# )
+# file_str: str = ""
+# separator: str = f"{os.path.sep}" # Gray
+# for i, part in enumerate(filepath.parts):
+# part_ = part.strip(os.path.sep)
+# if i != len(filepath.parts) - 1:
+# file_str += f"{"\u200b".join(part_)}{separator}"
+# else:
+# file_str += f"
{"\u200b".join(part_)}"
+# self.file_label.setText(file_str)
+# self.file_label.setCursor(Qt.CursorShape.PointingHandCursor)
+
+# self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
+# self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor)
+
+# self.opener = FileOpenerHelper(filepath)
+# self.open_file_action.triggered.connect(self.opener.open_file)
+# self.open_explorer_action.triggered.connect(self.opener.open_explorer)
+
+# # TODO: Do this all somewhere else, this is just here temporarily.
+# ext: str = filepath.suffix.lower()
+# try:
+# if MediaCategories.is_ext_in_category(
+# ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True
+# ):
+# if self.preview_gif.movie():
+# self.preview_gif.movie().stop()
+# self.gif_buffer.close()
+
+# image: Image.Image = Image.open(filepath)
+# anim_image: Image.Image = image
+# image_bytes_io: io.BytesIO = io.BytesIO()
+# anim_image.save(
+# image_bytes_io,
+# "GIF",
+# lossless=True,
+# save_all=True,
+# loop=0,
+# disposal=2,
+# )
+# image_bytes_io.seek(0)
+# ba: bytes = image_bytes_io.read()
+
+# self.gif_buffer.setData(ba)
+# movie = QMovie(self.gif_buffer, QByteArray())
+# self.preview_gif.setMovie(movie)
+# movie.start()
+
+# self.resizeEvent(
+# QResizeEvent(
+# QSize(image.width, image.height),
+# QSize(image.width, image.height),
+# )
+# )
+# self.preview_img.hide()
+# self.preview_vid.hide()
+# self.preview_gif.show()
+
+# image = None
+# if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RASTER_TYPES):
+# image = Image.open(str(filepath))
+# elif MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES):
+# try:
+# with rawpy.imread(str(filepath)) as raw:
+# rgb = raw.postprocess()
+# image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black")
+# except (
+# rawpy._rawpy.LibRawIOError,
+# rawpy._rawpy.LibRawFileUnsupportedError,
+# ):
+# pass
+# elif MediaCategories.is_ext_in_category(ext, MediaCategories.AUDIO_TYPES):
+# self.media_player.show()
+# self.media_player.play(filepath)
+# elif MediaCategories.is_ext_in_category(
+# ext, MediaCategories.VIDEO_TYPES
+# ) and is_readable_video(filepath):
+# video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
+# video.set(
+# cv2.CAP_PROP_POS_FRAMES,
+# (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
+# )
+# success, frame = video.read()
+# frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
+# image = Image.fromarray(frame)
+# if success:
+# self.preview_img.hide()
+# self.preview_vid.play(filepath, QSize(image.width, image.height))
+# self.resizeEvent(
+# QResizeEvent(
+# QSize(image.width, image.height),
+# QSize(image.width, image.height),
+# )
+# )
+# self.preview_vid.show()
+
+# # Stats for specific file types are displayed here.
+# if image and (
+# MediaCategories.is_ext_in_category(
+# ext, MediaCategories.IMAGE_RASTER_TYPES, mime_fallback=True
+# )
+# or MediaCategories.is_ext_in_category(
+# ext, MediaCategories.VIDEO_TYPES, mime_fallback=True
+# )
+# or MediaCategories.is_ext_in_category(
+# ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True
+# )
+# ):
+# self.dimensions_label.setText(
+# f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n"
+# f"{image.width} x {image.height} px"
+# )
+# elif MediaCategories.is_ext_in_category(
+# ext, MediaCategories.FONT_TYPES, mime_fallback=True
+# ):
+# try:
+# font = ImageFont.truetype(filepath)
+# self.dimensions_label.setText(
+# f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n"
+# f"{font.getname()[0]} ({font.getname()[1]}) "
+# )
+# except OSError:
+# self.dimensions_label.setText(
+# f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}"
+# )
+# logger.info(
+# f"[PreviewPanel][ERROR] Couldn't read font file: {filepath}"
+# )
+# else:
+# self.dimensions_label.setText(f"{ext.upper()[1:]}")
+# self.dimensions_label.setText(
+# f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}"
+# )
+# self.update_date_label(filepath)
+
+# if not filepath.is_file():
+# raise FileNotFoundError
+
+# except (FileNotFoundError, cv2.error) as e:
+# self.dimensions_label.setText(f"{ext.upper()[1:]}")
+# logger.error("Couldn't render thumbnail", filepath=filepath, error=e)
+# self.update_date_label()
+# except (
+# UnidentifiedImageError,
+# DecompressionBombError,
+# ) as e:
+# self.dimensions_label.setText(
+# f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}"
+# )
+# logger.error("Couldn't render thumbnail", filepath=filepath, error=e)
+# self.update_date_label(filepath)
+
+# if self.preview_img.is_connected:
+# self.preview_img.clicked.disconnect()
+# self.preview_img.clicked.connect(lambda checked=False, pth=filepath: open_file(pth))
+# self.preview_img.is_connected = True
+
+# self.selected = self.driver.selected
+# logger.info(
+# "rendering item fields",
+# item=item.id,
+# fields=[x.type_key for x in item.fields],
+# )
+# for idx, field in enumerate(item.fields):
+# self.write_container(idx, field)
+
+# # Hide leftover containers
+# if len(self.containers) > len(item.fields):
+# for i, c in enumerate(self.containers):
+# if i > (len(item.fields) - 1):
+# c.setHidden(True)
+
+# self.add_field_button.setHidden(False)
+
+# # Multiple Selected Items
+# elif len(self.driver.selected) > 1:
+# self.preview_img.show()
+# self.preview_gif.hide()
+# self.preview_vid.stop()
+# self.preview_vid.hide()
+# self.media_player.stop()
+# self.media_player.hide()
+# self.update_date_label()
+# if self.selected != self.driver.selected:
+# self.file_label.setText(f"{len(self.driver.selected)} Items Selected")
+# self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
+# self.file_label.set_file_path("")
+# self.dimensions_label.setText("")
+
+# self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
+# self.preview_img.setCursor(Qt.CursorShape.ArrowCursor)
+
+# ratio = self.devicePixelRatio()
+# self.thumb_renderer.render(
+# time.time(),
+# "",
+# (512, 512),
+# ratio,
+# is_loading=True,
+# update_on_ratio_change=True,
+# )
+# if self.preview_img.is_connected:
+# self.preview_img.clicked.disconnect()
+# self.preview_img.is_connected = False
+
+# # fill shared fields from first item
+# first_item = self.driver.frame_content[self.driver.selected[0]]
+# common_fields = [f for f in first_item.fields]
+# mixed_fields = []
+
+# # iterate through other items
+# for grid_idx in self.driver.selected[1:]:
+# item = self.driver.frame_content[grid_idx]
+# item_field_types = {f.type_key for f in item.fields}
+# for f in common_fields[:]:
+# if f.type_key not in item_field_types:
+# common_fields.remove(f)
+# mixed_fields.append(f)
+
+# self.common_fields = common_fields
+# self.mixed_fields = sorted(mixed_fields, key=lambda x: x.type.position)
+
+# self.selected = list(self.driver.selected)
+# logger.info(
+# "update_widgets common_fields",
+# common_fields=self.common_fields,
+# )
+# for i, f in enumerate(self.common_fields):
+# self.write_container(i, f)
+
+# logger.info(
+# "update_widgets mixed_fields",
+# mixed_fields=self.mixed_fields,
+# start=len(self.common_fields),
+# )
+# for i, f in enumerate(self.mixed_fields, start=len(self.common_fields)):
+# self.write_container(i, f, is_mixed=True)
+
+# # Hide leftover containers
+# if len(self.containers) > len(self.common_fields) + len(self.mixed_fields):
+# for i, c in enumerate(self.containers):
+# if i > (len(self.common_fields) + len(self.mixed_fields) - 1):
+# c.setHidden(True)
+
+# self.add_field_button.setHidden(False)
+
+# self.initialized = True
+
+# self.setWindowTitle(window_title)
+# self.show()
+# return True
diff --git a/tagstudio/src/qt/widgets/preview/file_attributes.py b/tagstudio/src/qt/widgets/preview/file_attributes.py
new file mode 100644
index 00000000..23f20720
--- /dev/null
+++ b/tagstudio/src/qt/widgets/preview/file_attributes.py
@@ -0,0 +1,232 @@
+# Copyright (C) 2024 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 datetime import datetime as dt
+from pathlib import Path
+
+import cv2
+import structlog
+from humanfriendly import format_size
+from PIL import Image, ImageFont, UnidentifiedImageError
+from PIL.Image import DecompressionBombError
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtGui import QGuiApplication
+from PySide6.QtWidgets import (
+ QLabel,
+ QVBoxLayout,
+ QWidget,
+)
+from src.core.enums import Theme
+from src.core.library.alchemy.library import Library
+from src.core.media_types import MediaCategories
+from src.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel
+from src.qt.translations import Translations
+
+if typing.TYPE_CHECKING:
+ from src.qt.ts_qt import QtDriver
+
+logger = structlog.get_logger(__name__)
+
+
+class FileAttributes(QWidget):
+ """The Preview Panel Widget."""
+
+ tags_updated = Signal()
+
+ label_bg_color = (
+ Theme.COLOR_BG_DARK.value
+ if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
+ else Theme.COLOR_DARK_LABEL.value
+ )
+
+ # panel_bg_color = (
+ # Theme.COLOR_BG_DARK.value
+ # if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
+ # else Theme.COLOR_BG_LIGHT.value
+ # )
+
+ file_label_style = "font-size: 12px"
+ 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;"
+ )
+ date_style = "font-size:12px;"
+
+ def __init__(self, library: Library, driver: "QtDriver"):
+ super().__init__()
+ # self.is_connected = False
+ # self.lib = library
+ # self.driver: QtDriver = driver
+ # self.initialized = False
+ # self.is_open: bool = False
+
+ root_layout = QVBoxLayout(self)
+ root_layout.setContentsMargins(0, 0, 0, 0)
+ root_layout.setSpacing(6)
+
+ 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(FileAttributes.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(FileAttributes.date_style)
+
+ 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(FileAttributes.date_style)
+
+ self.dimensions_label = QLabel()
+ self.dimensions_label.setObjectName("dimensionsLabel")
+ self.dimensions_label.setWordWrap(True)
+ self.dimensions_label.setStyleSheet(FileAttributes.properties_style)
+
+ 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)
+
+ def update_date_label(self, filepath: Path | None = None) -> None:
+ """Update the "Date Created" and "Date Modified" file property labels."""
+ logger.info(filepath)
+ if filepath and filepath.is_file():
+ created: dt = None
+ if platform.system() == "Windows" or platform.system() == "Darwin":
+ created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined, unused-ignore]
+ else:
+ created = dt.fromtimestamp(filepath.stat().st_ctime)
+ modified: dt = dt.fromtimestamp(filepath.stat().st_mtime)
+ self.date_created_label.setText(
+ f"Date Created: {dt.strftime(created, "%a, %x, %X")}"
+ )
+ self.date_modified_label.setText(
+ f"Date Modified: {dt.strftime(modified, "%a, %x, %X")}"
+ )
+ self.date_created_label.setHidden(False)
+ self.date_modified_label.setHidden(False)
+ elif filepath:
+ self.date_created_label.setText("Date Created: N/A")
+ self.date_modified_label.setText("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):
+ """Render the panel widgets with the newest data from the Library."""
+ logger.info("update_stats", selected=filepath)
+
+ if not filepath:
+ self.file_label.setText("No Items Selected")
+ self.file_label.set_file_path("")
+ self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
+ self.dimensions_label.setText("")
+ else:
+ self.file_label.set_file_path(filepath)
+
+ file_str: str = ""
+ separator: str = f"{os.path.sep}" # Gray
+ for i, part in enumerate(filepath.parts):
+ part_ = part.strip(os.path.sep)
+ if i != len(filepath.parts) - 1:
+ file_str += f"{"\u200b".join(part_)}{separator}"
+ else:
+ file_str += f"
{"\u200b".join(part_)}"
+ self.file_label.setText(file_str)
+ self.file_label.setCursor(Qt.CursorShape.PointingHandCursor)
+
+ self.opener = FileOpenerHelper(filepath)
+ # self.open_file_action = QAction(self)
+ # Translations.translate_qobject(self.open_file_action, "file.open_file")
+ # self.open_explorer_action = QAction(PlatformStrings.open_file_str, self)
+
+ # TODO: Do this all somewhere else, this is just here temporarily.
+ ext: str = filepath.suffix.lower()
+ try:
+ image: Image.Image = Image.open(filepath)
+ # Stats for specific file types are displayed here.
+ if image and (
+ MediaCategories.is_ext_in_category(
+ ext, MediaCategories.IMAGE_RASTER_TYPES, mime_fallback=True
+ )
+ or MediaCategories.is_ext_in_category(
+ ext, MediaCategories.VIDEO_TYPES, mime_fallback=True
+ )
+ or MediaCategories.is_ext_in_category(
+ ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True
+ )
+ ):
+ self.dimensions_label.setText(
+ f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n"
+ f"{image.width} x {image.height} px"
+ )
+ elif MediaCategories.is_ext_in_category(
+ ext, MediaCategories.FONT_TYPES, mime_fallback=True
+ ):
+ try:
+ font = ImageFont.truetype(filepath)
+ self.dimensions_label.setText(
+ f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n"
+ f"{font.getname()[0]} ({font.getname()[1]}) "
+ )
+ except OSError:
+ self.dimensions_label.setText(
+ f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}"
+ )
+ logger.info(f"[PreviewPanel][ERROR] Couldn't read font file: {filepath}")
+ else:
+ self.dimensions_label.setText(f"{ext.upper()[1:]}")
+ self.dimensions_label.setText(
+ f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}"
+ )
+ self.update_date_label(filepath)
+
+ if not filepath.is_file():
+ raise FileNotFoundError
+
+ except (FileNotFoundError, cv2.error) as e:
+ self.dimensions_label.setText(f"{ext.upper()[1:]}")
+ logger.error("Couldn't render thumbnail", filepath=filepath, error=e)
+ self.update_date_label()
+ except (
+ UnidentifiedImageError,
+ DecompressionBombError, # noqa: F821
+ ) as e:
+ self.dimensions_label.setText(
+ f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}"
+ )
+ logger.error("Couldn't render thumbnail", filepath=filepath, error=e)
+ self.update_date_label(filepath)
+
+ def update_multi_selection(self, count: int):
+ # Multiple Selected Items
+ self.file_label.setText(f"{count} Items Selected")
+ self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
+ self.file_label.set_file_path("")
+ self.dimensions_label.setText("")
diff --git a/tagstudio/src/qt/widgets/preview/preview_thumb.py b/tagstudio/src/qt/widgets/preview/preview_thumb.py
new file mode 100644
index 00000000..f64fbfc5
--- /dev/null
+++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py
@@ -0,0 +1,300 @@
+# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
+# Licensed under the GPL-3.0 License.
+# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
+
+import io
+import time
+import typing
+from pathlib import Path
+
+import cv2
+import rawpy
+import structlog
+from PIL import Image, UnidentifiedImageError
+from PIL.Image import DecompressionBombError
+from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal
+from PySide6.QtGui import QMovie, QResizeEvent
+from PySide6.QtWidgets import (
+ QHBoxLayout,
+ QLabel,
+ QWidget,
+)
+from src.core.library.alchemy.library import Library
+from src.core.library.alchemy.models import Entry
+from src.core.media_types import MediaCategories
+from src.qt.helpers.file_opener import FileOpenerHelper, open_file
+from src.qt.helpers.file_tester import is_readable_video
+from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
+from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
+from src.qt.translations import Translations
+from src.qt.widgets.media_player import MediaPlayer
+from src.qt.widgets.thumb_renderer import ThumbRenderer
+from src.qt.widgets.video_player import VideoPlayer
+
+if typing.TYPE_CHECKING:
+ from src.qt.ts_qt import QtDriver
+
+logger = structlog.get_logger(__name__)
+
+
+class PreviewThumb(QWidget):
+ """The Preview Panel Widget."""
+
+ tags_updated = Signal()
+
+ def __init__(self, library: Library, driver: "QtDriver"):
+ super().__init__()
+
+ self.is_connected = False
+ self.lib = library
+ self.driver: QtDriver = driver
+
+ self.img_button_size: tuple[int, int] = (266, 266)
+ self.image_ratio: float = 1.0
+
+ # self.panel_bg_color = (
+ # Theme.COLOR_BG_DARK.value
+ # if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
+ # else Theme.COLOR_BG_LIGHT.value
+ # )
+
+ self.image_container = QWidget()
+ image_layout = QHBoxLayout(self.image_container)
+ image_layout.setContentsMargins(0, 0, 0, 0)
+
+ # self.open_file_action = QAction("Open file", self)
+ # self.open_explorer_action = QAction(PlatformStrings.open_file_str, self)
+
+ self.preview_img = QPushButtonWrapper()
+ self.preview_img.setMinimumSize(*self.img_button_size)
+ self.preview_img.setFlat(True)
+ self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
+ # self.preview_img.addAction(self.open_file_action)
+ # self.preview_img.addAction(self.open_explorer_action)
+
+ self.preview_gif = QLabel()
+ self.preview_gif.setMinimumSize(*self.img_button_size)
+ self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
+ self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
+ # self.preview_gif.addAction(self.open_file_action)
+ # self.preview_gif.addAction(self.open_explorer_action)
+ self.preview_gif.hide()
+ self.gif_buffer: QBuffer = QBuffer()
+
+ self.preview_vid = VideoPlayer(driver)
+ self.preview_vid.hide()
+ self.thumb_renderer = ThumbRenderer()
+ self.thumb_renderer.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i)))
+ self.thumb_renderer.updated_ratio.connect(
+ lambda ratio: (
+ self.set_image_ratio(ratio),
+ self.update_image_size(
+ (
+ self.image_container.size().width(),
+ self.image_container.size().height(),
+ ),
+ ratio,
+ ),
+ )
+ )
+
+ self.media_player = MediaPlayer(driver)
+ self.media_player.hide()
+
+ image_layout.addWidget(self.preview_img)
+ image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter)
+ image_layout.addWidget(self.preview_gif)
+ image_layout.setAlignment(self.preview_gif, Qt.AlignmentFlag.AlignCenter)
+ image_layout.addWidget(self.preview_vid)
+ image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter)
+ self.image_container.setMinimumSize(*self.img_button_size)
+
+ def set_image_ratio(self, ratio: float):
+ self.image_ratio = ratio
+
+ def update_image_size(self, size: tuple[int, int], ratio: float = None):
+ if ratio:
+ self.set_image_ratio(ratio)
+
+ adj_width: float = size[0]
+ adj_height: float = size[1]
+ # Landscape
+ if self.image_ratio > 1:
+ adj_height = size[0] * (1 / self.image_ratio)
+ # Portrait
+ elif self.image_ratio <= 1:
+ adj_width = size[1] * self.image_ratio
+
+ if adj_width > size[0]:
+ adj_height = adj_height * (size[0] / adj_width)
+ adj_width = size[0]
+ elif adj_height > size[1]:
+ adj_width = adj_width * (size[1] / adj_height)
+ adj_height = size[1]
+
+ adj_size = QSize(int(adj_width), int(adj_height))
+ self.img_button_size = (int(adj_width), int(adj_height))
+ self.preview_img.setMaximumSize(adj_size)
+ self.preview_img.setIconSize(adj_size)
+ self.preview_vid.resize_video(adj_size)
+ self.preview_vid.setMaximumSize(adj_size)
+ self.preview_vid.setMinimumSize(adj_size)
+ self.preview_gif.setMaximumSize(adj_size)
+ self.preview_gif.setMinimumSize(adj_size)
+ proxy_style = RoundedPixmapStyle(radius=8)
+ self.preview_gif.setStyle(proxy_style)
+ self.preview_vid.setStyle(proxy_style)
+ m = self.preview_gif.movie()
+ if m:
+ m.setScaledSize(adj_size)
+
+ def get_preview_size(self) -> tuple[int, int]:
+ return (
+ self.image_container.size().width(),
+ self.image_container.size().height(),
+ )
+
+ def update_preview(self, entry: Entry, filepath: Path) -> dict:
+ """Render a single file preview."""
+ # self.tag_callback = tag_callback if tag_callback else None
+
+ # # update list of libraries
+ # self.fill_libs_widget(self.libs_layout)
+
+ # self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
+ # self.preview_img.setCursor(Qt.CursorShape.ArrowCursor)
+
+ # ratio = self.devicePixelRatio()
+ # self.thumb_renderer.render(
+ # time.time(),
+ # "",
+ # (512, 512),
+ # ratio,
+ # is_loading=True,
+ # update_on_ratio_change=True,
+ # )
+ # if self.preview_img.is_connected:
+ # self.preview_img.clicked.disconnect()
+ # self.preview_img.show()
+ # self.preview_vid.stop()
+ # self.preview_vid.hide()
+ # self.media_player.hide()
+ # self.media_player.stop()
+ # self.preview_gif.hide()
+ # self.selected = list(self.driver.selected)
+ # self.add_field_button.setHidden(True)
+
+ # reload entry and fill it into the grid again
+ # 1 Selected Entry
+ # selected_idx = self.driver.selected[0]
+ # item = self.driver.frame_content[selected_idx]
+
+ self.preview_img.show()
+ self.preview_vid.stop()
+ self.preview_vid.hide()
+ self.media_player.stop()
+ self.media_player.hide()
+ self.preview_gif.hide()
+
+ # If a new selection is made, update the thumbnail and filepath.
+ ratio = self.devicePixelRatio()
+ self.thumb_renderer.render(
+ time.time(),
+ filepath,
+ (512, 512),
+ ratio,
+ update_on_ratio_change=True,
+ )
+
+ self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
+ self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor)
+
+ self.opener = FileOpenerHelper(filepath)
+ # self.open_file_action.triggered.connect(self.opener.open_file)
+ # self.open_explorer_action.triggered.connect(self.opener.open_explorer)
+
+ # TODO: Do this all somewhere else, this is just here temporarily.
+ ext: str = filepath.suffix.lower()
+ try:
+ if MediaCategories.is_ext_in_category(
+ ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True
+ ):
+ if self.preview_gif.movie():
+ self.preview_gif.movie().stop()
+ self.gif_buffer.close()
+
+ image: Image.Image = Image.open(filepath)
+ anim_image: Image.Image = image
+ image_bytes_io: io.BytesIO = io.BytesIO()
+ anim_image.save(
+ image_bytes_io,
+ "GIF",
+ lossless=True,
+ save_all=True,
+ loop=0,
+ disposal=2,
+ )
+ image_bytes_io.seek(0)
+ ba: bytes = image_bytes_io.read()
+
+ self.gif_buffer.setData(ba)
+ movie = QMovie(self.gif_buffer, QByteArray())
+ self.preview_gif.setMovie(movie)
+ movie.start()
+
+ self.resizeEvent(
+ QResizeEvent(
+ QSize(image.width, image.height),
+ QSize(image.width, image.height),
+ )
+ )
+ self.preview_img.hide()
+ self.preview_vid.hide()
+ self.preview_gif.show()
+
+ image = None
+ if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RASTER_TYPES):
+ image = Image.open(str(filepath))
+ elif MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES):
+ try:
+ with rawpy.imread(str(filepath)) as raw:
+ rgb = raw.postprocess()
+ image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black")
+ except (
+ rawpy._rawpy.LibRawIOError,
+ rawpy._rawpy.LibRawFileUnsupportedError,
+ ):
+ pass
+ elif MediaCategories.is_ext_in_category(ext, MediaCategories.AUDIO_TYPES):
+ self.media_player.show()
+ self.media_player.play(filepath)
+ elif MediaCategories.is_ext_in_category(
+ ext, MediaCategories.VIDEO_TYPES
+ ) and is_readable_video(filepath):
+ video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
+ video.set(
+ cv2.CAP_PROP_POS_FRAMES,
+ (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
+ )
+ success, frame = video.read()
+ frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
+ image = Image.fromarray(frame)
+ if success:
+ self.preview_img.hide()
+ self.preview_vid.play(str(filepath), QSize(image.width, image.height))
+ self.resizeEvent(
+ QResizeEvent(
+ QSize(image.width, image.height),
+ QSize(image.width, image.height),
+ )
+ )
+ self.preview_vid.show()
+
+ except (FileNotFoundError, cv2.error, UnidentifiedImageError, DecompressionBombError) as e:
+ if self.preview_img.is_connected:
+ self.preview_img.clicked.disconnect()
+ self.preview_img.clicked.connect(lambda checked=False, pth=filepath: open_file(pth))
+ self.preview_img.is_connected = True
+ logger.error(f"Preview thumb error: {e} - {filepath}")
+
+ return {}
diff --git a/tagstudio/src/qt/widgets/preview/recent_libraries.py b/tagstudio/src/qt/widgets/preview/recent_libraries.py
new file mode 100644
index 00000000..f47c2163
--- /dev/null
+++ b/tagstudio/src/qt/widgets/preview/recent_libraries.py
@@ -0,0 +1,133 @@
+# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
+# Licensed under the GPL-3.0 License.
+# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
+
+import typing
+
+import structlog
+from PySide6.QtWidgets import (
+ QWidget,
+)
+from src.core.library.alchemy.library import Library
+from src.qt.translations import Translations
+
+if typing.TYPE_CHECKING:
+ from src.qt.ts_qt import QtDriver
+
+logger = structlog.get_logger(__name__)
+
+
+class RecentLibraries(QWidget):
+ """The Recent Libraries Widget."""
+
+ def __init__(self, library: Library, driver: "QtDriver"):
+ super().__init__()
+
+
+# # keep list of rendered libraries to avoid needless re-rendering
+# self.render_libs: set = set()
+# self.library = library
+# self.driver = driver
+# layout = QVBoxLayout()
+
+# settings = driver.settings
+# settings.beginGroup(SettingItems.LIBS_LIST)
+# lib_items: dict[str, tuple[str, str]] = {}
+# for item_tstamp in settings.allKeys():
+# val = str(settings.value(item_tstamp, type=str))
+# cut_val = val
+# if len(val) > 45:
+# cut_val = f"{val[0:10]} ... {val[-10:]}"
+# lib_items[item_tstamp] = (val, cut_val)
+
+# settings.endGroup()
+
+# new_keys = set(lib_items.keys())
+# if new_keys == self.render_libs:
+# # no need to re-render
+# return
+
+# # sort lib_items by the key
+# libs_sorted = sorted(lib_items.items(), key=lambda item: item[0], reverse=True)
+
+# self.render_libs = new_keys
+# self.setLayout(layout)
+
+# self._fill_libs_widget(libs_sorted, layout)
+
+# def _fill_libs_widget(self, libraries: list[tuple[str, tuple[str, str]]], layout: QVBoxLayout):
+# def clear_layout(layout_item: QVBoxLayout):
+# for i in reversed(range(layout_item.count())):
+# child = layout_item.itemAt(i)
+# if child.widget() is not None:
+# child.widget().deleteLater()
+# elif child.layout() is not None:
+# clear_layout(child.layout()) # type: ignore
+
+# # remove any potential previous items
+# clear_layout(layout)
+
+# label = QLabel()
+# Translations.translate_qobject(label, "generic.recent_libraries")
+# label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+
+# row_layout = QHBoxLayout()
+# row_layout.addWidget(label)
+# layout.addLayout(row_layout)
+
+# def set_button_style(
+# btn: QPushButtonWrapper | QPushButton, extras: list[str] | None = None
+# ):
+# base_style = [
+# f"background-color:{Theme.COLOR_BG.value};",
+# "border-radius:6px;",
+# "text-align: left;",
+# "padding-top: 3px;",
+# "padding-left: 6px;",
+# "padding-bottom: 4px;",
+# ]
+
+# full_style_rows = base_style + (extras or [])
+
+# btn.setStyleSheet(
+# "QPushButton{"
+# f"{''.join(full_style_rows)}"
+# "}"
+# f"QPushButton::hover{{background-color:{Theme.COLOR_HOVER.value};}}"
+# f"QPushButton::pressed{{background-color:{Theme.COLOR_PRESSED.value};}}"
+# f"QPushButton::disabled{{background-color:{Theme.COLOR_DISABLED_BG.value};}}"
+# )
+# btn.setCursor(Qt.CursorShape.PointingHandCursor)
+
+# for item_key, (full_val, cut_val) in libraries:
+# button = QPushButton(text=cut_val)
+# button.setObjectName(f"path{item_key}")
+
+# lib = Path(full_val)
+# if not lib.exists() or not (lib / TS_FOLDER_NAME).exists():
+# button.setDisabled(True)
+# Translations.translate_with_setter(button.setToolTip, "library.missing")
+
+# def open_library_button_clicked(path):
+# return lambda: self.driver.open_library(Path(path))
+
+# button.clicked.connect(open_library_button_clicked(full_val))
+# set_button_style(button, ["padding-left: 6px;", "text-align: left;"])
+# button_remove = QPushButton("—")
+# button_remove.setCursor(Qt.CursorShape.PointingHandCursor)
+# button_remove.setFixedWidth(24)
+# set_button_style(button_remove, ["font-weight:bold;", "text-align:center;"])
+
+# def remove_recent_library_clicked(key: str):
+# return lambda: (
+# self.driver.remove_recent_library(key),
+# self.fill_libs_widget(self.libs_layout),
+# )
+
+# button_remove.clicked.connect(remove_recent_library_clicked(item_key))
+
+# row_layout = QHBoxLayout()
+# row_layout.addWidget(button)
+# row_layout.addWidget(button_remove)
+
+# layout.addLayout(row_layout)
diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py
index 5bf232d6..41b47475 100644
--- a/tagstudio/src/qt/widgets/preview_panel.py
+++ b/tagstudio/src/qt/widgets/preview_panel.py
@@ -2,64 +2,23 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
-import io
-import os
-import platform
-import sys
-import time
import typing
-from collections.abc import Callable
-from datetime import datetime as dt
from pathlib import Path
-import cv2
-import rawpy
import structlog
-from humanfriendly import format_size
-from PIL import Image, ImageFont, UnidentifiedImageError
-from PIL.Image import DecompressionBombError
-from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal
-from PySide6.QtGui import QAction, QGuiApplication, QMovie, QResizeEvent
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtGui import QResizeEvent
from PySide6.QtWidgets import (
- QFrame,
QHBoxLayout,
- QLabel,
- QMessageBox,
- QPushButton,
- QScrollArea,
- QSizePolicy,
QSplitter,
- QVBoxLayout,
QWidget,
)
-from src.core.constants import (
- TS_FOLDER_NAME,
-)
-from src.core.enums import SettingItems, Theme
-from src.core.library.alchemy.fields import (
- BaseField,
- DatetimeField,
- FieldTypeEnum,
- # TagBoxField,
- TextField,
-)
from src.core.library.alchemy.library import Library
-from src.core.media_types import MediaCategories
-from src.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel, open_file
-from src.qt.helpers.file_tester import is_readable_video
-from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
-from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
-from src.qt.modals.add_field import AddFieldModal
-from src.qt.platform_strings import PlatformStrings
+from src.core.library.alchemy.models import Entry
+from src.qt.widgets.preview.field_containers import FieldContainers
+from src.qt.widgets.preview.file_attributes import FileAttributes
+from src.qt.widgets.preview.preview_thumb import PreviewThumb
from src.qt.translations import Translations
-from src.qt.widgets.fields import FieldContainer
-from src.qt.widgets.media_player import MediaPlayer
-from src.qt.widgets.panel import PanelModal
-from src.qt.widgets.text import TextWidget
-from src.qt.widgets.text_box_edit import EditTextBox
-from src.qt.widgets.text_line_edit import EditTextLine
-from src.qt.widgets.thumb_renderer import ThumbRenderer
-from src.qt.widgets.video_player import VideoPlayer
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
@@ -79,214 +38,38 @@ class PreviewPanel(QWidget):
self.driver: QtDriver = driver
self.initialized = False
self.is_open: bool = False
- self.common_fields: list = []
- self.mixed_fields: list = []
- self.selected: list[int] = [] # New way of tracking items
- self.tag_callback = None
- self.containers: list[FieldContainer] = []
+ # self.selected: list[int] = [] # New way of tracking items
- self.img_button_size: tuple[int, int] = (266, 266)
- self.image_ratio: float = 1.0
+ self.thumb = PreviewThumb(library, driver)
+ self.file_attrs = FileAttributes(library, driver)
+ self.fields = FieldContainers(library, driver)
- self.label_bg_color = (
- Theme.COLOR_BG_DARK.value
- if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
- else Theme.COLOR_DARK_LABEL.value
- )
- self.panel_bg_color = (
- Theme.COLOR_BG_DARK.value
- if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
- else Theme.COLOR_BG_LIGHT.value
- )
+ # info_section = QWidget()
+ # info_layout = QVBoxLayout(info_section)
+ # info_layout.setContentsMargins(0, 0, 0, 0)
+ # info_layout.setSpacing(6)
- self.image_container = QWidget()
- image_layout = QHBoxLayout(self.image_container)
- image_layout.setContentsMargins(0, 0, 0, 0)
-
- file_label_style = "font-size: 12px"
- properties_style = (
- f"background-color:{self.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;"
- )
- date_style = "font-size:12px;"
-
- self.open_file_action = QAction(self)
- Translations.translate_qobject(self.open_file_action, "file.open_file")
- self.open_explorer_action = QAction(PlatformStrings.open_file_str, self)
-
- self.preview_img = QPushButtonWrapper()
- self.preview_img.setMinimumSize(*self.img_button_size)
- self.preview_img.setFlat(True)
- self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
- self.preview_img.addAction(self.open_file_action)
- self.preview_img.addAction(self.open_explorer_action)
-
- self.preview_gif = QLabel()
- self.preview_gif.setMinimumSize(*self.img_button_size)
- self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
- self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
- self.preview_gif.addAction(self.open_file_action)
- self.preview_gif.addAction(self.open_explorer_action)
- self.preview_gif.hide()
- self.gif_buffer: QBuffer = QBuffer()
-
- self.preview_vid = VideoPlayer(driver)
- self.preview_vid.hide()
- self.thumb_renderer = ThumbRenderer()
- self.thumb_renderer.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i)))
- self.thumb_renderer.updated_ratio.connect(
- lambda ratio: (
- self.set_image_ratio(ratio),
- self.update_image_size(
- (
- self.image_container.size().width(),
- self.image_container.size().height(),
- ),
- ratio,
- ),
- )
- )
-
- self.media_player = MediaPlayer(driver)
- self.media_player.hide()
-
- image_layout.addWidget(self.preview_img)
- image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter)
- image_layout.addWidget(self.preview_gif)
- image_layout.setAlignment(self.preview_gif, Qt.AlignmentFlag.AlignCenter)
- image_layout.addWidget(self.preview_vid)
- image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter)
- self.image_container.setMinimumSize(*self.img_button_size)
- self.file_label = FileOpenerLabel()
- self.file_label.setObjectName("filenameLabel")
- self.file_label.setTextFormat(Qt.TextFormat.RichText)
- self.file_label.setWordWrap(True)
- self.file_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
- self.file_label.setStyleSheet(file_label_style)
-
- self.date_created_label = QLabel()
- self.date_created_label.setObjectName("dateCreatedLabel")
- self.date_created_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
- self.date_created_label.setTextFormat(Qt.TextFormat.RichText)
- self.date_created_label.setStyleSheet(date_style)
-
- self.date_modified_label = QLabel()
- self.date_modified_label.setObjectName("dateModifiedLabel")
- self.date_modified_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
- self.date_modified_label.setTextFormat(Qt.TextFormat.RichText)
- self.date_modified_label.setStyleSheet(date_style)
-
- self.dimensions_label = QLabel()
- self.dimensions_label.setObjectName("dimensionsLabel")
- self.dimensions_label.setWordWrap(True)
- self.dimensions_label.setStyleSheet(properties_style)
-
- self.scroll_layout = QVBoxLayout()
- self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
- self.scroll_layout.setContentsMargins(6, 1, 6, 6)
-
- scroll_container: QWidget = QWidget()
- scroll_container.setObjectName("entryScrollContainer")
- scroll_container.setLayout(self.scroll_layout)
-
- info_section = QWidget()
- info_layout = QVBoxLayout(info_section)
- info_layout.setContentsMargins(0, 0, 0, 0)
- info_layout.setSpacing(6)
-
- scroll_area = QScrollArea()
- scroll_area.setObjectName("entryScrollArea")
- scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
- scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
- scroll_area.setWidgetResizable(True)
- scroll_area.setFrameShadow(QFrame.Shadow.Plain)
- scroll_area.setFrameShape(QFrame.Shape.NoFrame)
- # NOTE: I would rather have this style applied to the scroll_area
- # background and NOT the scroll container background, so that the
- # rounded corners are maintained when scrolling. I was unable to
- # find the right trick to only select that particular element.
-
- scroll_area.setStyleSheet(
- "QWidget#entryScrollContainer{"
- f"background:{self.panel_bg_color};"
- "border-radius:6px;"
- "}"
- )
- scroll_area.setWidget(scroll_container)
-
- date_container = QWidget()
- date_layout = QVBoxLayout(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)
-
- info_layout.addWidget(self.file_label)
- info_layout.addWidget(date_container)
- info_layout.addWidget(self.dimensions_label)
- info_layout.addWidget(scroll_area)
-
- # keep list of rendered libraries to avoid needless re-rendering
- self.render_libs: set = set()
- self.libs_layout = QVBoxLayout()
- self.fill_libs_widget(self.libs_layout)
-
- self.libs_flow_container: QWidget = QWidget()
- self.libs_flow_container.setObjectName("librariesList")
- self.libs_flow_container.setLayout(self.libs_layout)
- self.libs_flow_container.setSizePolicy(
- QSizePolicy.Preferred, # type: ignore
- QSizePolicy.Maximum, # type: ignore
- )
-
- # set initial visibility based on settings
- if not self.driver.settings.value(
- SettingItems.WINDOW_SHOW_LIBS, defaultValue=True, type=bool
- ):
- self.libs_flow_container.hide()
+ # info_layout.addWidget(self.file_attrs)
+ # info_layout.addWidget(self.fields.scroll_area)
splitter = QSplitter()
splitter.setOrientation(Qt.Orientation.Vertical)
splitter.setHandleWidth(12)
- splitter.splitterMoved.connect(
- lambda: self.update_image_size(
- (
- self.image_container.size().width(),
- self.image_container.size().height(),
- )
- )
- )
+ # splitter.splitterMoved.connect(
+ # lambda: self.thumb.update_image_size(
+ # (
+ # self.thumb.image_container.size().width(),
+ # self.thumb.image_container.size().height(),
+ # )
+ # )
+ # )
- splitter.addWidget(self.image_container)
- splitter.addWidget(self.media_player)
- splitter.addWidget(info_section)
- splitter.addWidget(self.libs_flow_container)
+ # splitter.addWidget(self.thumb.image_container)
+ # splitter.addWidget(self.thumb.media_player)
+ splitter.addWidget(self.file_attrs)
+ # splitter.addWidget(self.libs_flow_container)
splitter.setStretchFactor(1, 2)
- self.afb_container = QWidget()
- self.afb_layout = QVBoxLayout(self.afb_container)
- self.afb_layout.setContentsMargins(0, 12, 0, 0)
-
- self.add_field_button = QPushButtonWrapper()
- self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
- self.add_field_button.setMinimumSize(96, 28)
- self.add_field_button.setMaximumSize(96, 28)
- Translations.translate_qobject(self.add_field_button, "library.field.add")
- self.afb_layout.addWidget(self.add_field_button)
- self.add_field_modal = AddFieldModal(self.lib)
- self.place_add_field_button()
- self.update_image_size(
- (self.image_container.size().width(), self.image_container.size().height())
- )
-
root_layout = QHBoxLayout(self)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.addWidget(splitter)
@@ -302,810 +85,43 @@ class PreviewPanel(QWidget):
)
self.driver.frame_content[grid_idx] = result
- def remove_field_prompt(self, name: str) -> str:
- return Translations.translate_formatted("library.field.confirm_remove", name=name)
-
- def fill_libs_widget(self, layout: QVBoxLayout):
- settings = self.driver.settings
- settings.beginGroup(SettingItems.LIBS_LIST)
- lib_items: dict[str, tuple[str, str]] = {}
- for item_tstamp in settings.allKeys():
- val = str(settings.value(item_tstamp, type=str))
- cut_val = val
- if len(val) > 45:
- cut_val = f"{val[0:10]} ... {val[-10:]}"
- lib_items[item_tstamp] = (val, cut_val)
-
- settings.endGroup()
-
- new_keys = set(lib_items.keys())
- if new_keys == self.render_libs:
- # no need to re-render
- return
-
- # sort lib_items by the key
- libs_sorted = sorted(lib_items.items(), key=lambda item: item[0], reverse=True)
-
- self.render_libs = new_keys
- self._fill_libs_widget(libs_sorted, layout)
-
- def _fill_libs_widget(self, libraries: list[tuple[str, tuple[str, str]]], layout: QVBoxLayout):
- def clear_layout(layout_item: QVBoxLayout):
- for i in reversed(range(layout_item.count())):
- child = layout_item.itemAt(i)
- if child.widget() is not None:
- child.widget().deleteLater()
- elif child.layout() is not None:
- clear_layout(child.layout()) # type: ignore
-
- # remove any potential previous items
- clear_layout(layout)
-
- label = QLabel()
- Translations.translate_qobject(label, "generic.recent_libraries")
- label.setAlignment(Qt.AlignmentFlag.AlignCenter)
-
- row_layout = QHBoxLayout()
- row_layout.addWidget(label)
- layout.addLayout(row_layout)
-
- def set_button_style(
- btn: QPushButtonWrapper | QPushButton, extras: list[str] | None = None
- ):
- base_style = [
- f"background-color:{Theme.COLOR_BG.value};",
- "border-radius:6px;",
- "text-align: left;",
- "padding-top: 3px;",
- "padding-left: 6px;",
- "padding-bottom: 4px;",
- ]
-
- full_style_rows = base_style + (extras or [])
-
- btn.setStyleSheet(
- "QPushButton{"
- f"{''.join(full_style_rows)}"
- "}"
- f"QPushButton::hover{{background-color:{Theme.COLOR_HOVER.value};}}"
- f"QPushButton::pressed{{background-color:{Theme.COLOR_PRESSED.value};}}"
- f"QPushButton::disabled{{background-color:{Theme.COLOR_DISABLED_BG.value};}}"
- )
- btn.setCursor(Qt.CursorShape.PointingHandCursor)
-
- for item_key, (full_val, cut_val) in libraries:
- button = QPushButton(text=cut_val)
- button.setObjectName(f"path{item_key}")
-
- lib = Path(full_val)
- if not lib.exists() or not (lib / TS_FOLDER_NAME).exists():
- button.setDisabled(True)
- Translations.translate_with_setter(button.setToolTip, "library.missing")
-
- def open_library_button_clicked(path):
- return lambda: self.driver.open_library(Path(path))
-
- button.clicked.connect(open_library_button_clicked(full_val))
- set_button_style(button, ["padding-left: 6px;", "text-align: left;"])
- button_remove = QPushButton("—")
- button_remove.setCursor(Qt.CursorShape.PointingHandCursor)
- button_remove.setFixedWidth(24)
- set_button_style(button_remove, ["font-weight:bold;", "text-align:center;"])
-
- def remove_recent_library_clicked(key: str):
- return lambda: (
- self.driver.remove_recent_library(key),
- self.fill_libs_widget(self.libs_layout),
- )
-
- button_remove.clicked.connect(remove_recent_library_clicked(item_key))
-
- row_layout = QHBoxLayout()
- row_layout.addWidget(button)
- row_layout.addWidget(button_remove)
-
- layout.addLayout(row_layout)
-
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
- self.update_image_size(
- (self.image_container.size().width(), self.image_container.size().height())
- )
+ # self.thumb.update_image_size(
+ # (self.thumb.image_container.size().width(), self.thumb.image_container.size().height())
+ # )
return super().resizeEvent(event)
- def get_preview_size(self) -> tuple[int, int]:
- return (
- self.image_container.size().width(),
- self.image_container.size().height(),
- )
-
- def set_image_ratio(self, ratio: float):
- self.image_ratio = ratio
-
- def update_image_size(self, size: tuple[int, int], ratio: float = None):
- if ratio:
- self.set_image_ratio(ratio)
-
- adj_width: float = size[0]
- adj_height: float = size[1]
- # Landscape
- if self.image_ratio > 1:
- adj_height = size[0] * (1 / self.image_ratio)
- # Portrait
- elif self.image_ratio <= 1:
- adj_width = size[1] * self.image_ratio
-
- if adj_width > size[0]:
- adj_height = adj_height * (size[0] / adj_width)
- adj_width = size[0]
- elif adj_height > size[1]:
- adj_width = adj_width * (size[1] / adj_height)
- adj_height = size[1]
-
- adj_size = QSize(int(adj_width), int(adj_height))
- self.img_button_size = (int(adj_width), int(adj_height))
- self.preview_img.setMaximumSize(adj_size)
- self.preview_img.setIconSize(adj_size)
- self.preview_vid.resize_video(adj_size)
- self.preview_vid.setMaximumSize(adj_size)
- self.preview_vid.setMinimumSize(adj_size)
- self.preview_gif.setMaximumSize(adj_size)
- self.preview_gif.setMinimumSize(adj_size)
- proxy_style = RoundedPixmapStyle(radius=8)
- self.preview_gif.setStyle(proxy_style)
- self.preview_vid.setStyle(proxy_style)
- m = self.preview_gif.movie()
- if m:
- m.setScaledSize(adj_size)
-
- def place_add_field_button(self):
- self.scroll_layout.addWidget(self.afb_container)
- self.scroll_layout.setAlignment(self.afb_container, Qt.AlignmentFlag.AlignHCenter)
-
- if self.add_field_modal.is_connected:
- self.add_field_modal.done.disconnect()
- if self.add_field_button.is_connected:
- self.add_field_button.clicked.disconnect()
-
- self.add_field_modal.done.connect(
- lambda f: (self.add_field_to_selected(f), self.update_widgets())
- )
- self.add_field_modal.is_connected = True
- self.add_field_button.clicked.connect(self.add_field_modal.show)
-
- def add_field_to_selected(self, field_list: list):
- """Add list of entry fields to one or more selected items."""
- logger.info("add_field_to_selected", selected=self.selected, fields=field_list)
- for grid_idx in self.selected:
- entry = self.driver.frame_content[grid_idx]
- for field_item in field_list:
- self.lib.add_entry_field_type(
- entry.id,
- field_id=field_item.data(Qt.ItemDataRole.UserRole),
- )
-
- 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 = None
- 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"Date Created: {dt.strftime(created, "%a, %x, %X")}" # TODO translate
- )
- self.date_modified_label.setText(
- f"Date Modified: {dt.strftime(modified, "%a, %x, %X")}" # TODO translate
- )
- self.date_created_label.setHidden(False)
- self.date_modified_label.setHidden(False)
- elif filepath:
- self.date_created_label.setText("Date Created: N/A") # TODO translate
- self.date_modified_label.setText("Date Modified: N/A") # TODO translate
- 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_widgets(self) -> bool:
"""Render the panel widgets with the newest data from the Library."""
- logger.info("update_widgets", selected=self.driver.selected)
- self.is_open = True
- # self.tag_callback = tag_callback if tag_callback else None
- window_title = ""
-
- # update list of libraries
- self.fill_libs_widget(self.libs_layout)
-
- if not self.driver.selected:
- if self.selected or not self.initialized:
- self.file_label.setText("No Items Selected") # TODO translate
- self.file_label.set_file_path("")
- self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
-
- self.dimensions_label.setText("")
- self.update_date_label()
- self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
- self.preview_img.setCursor(Qt.CursorShape.ArrowCursor)
-
- ratio = self.devicePixelRatio()
- self.thumb_renderer.render(
- time.time(),
- "",
- (512, 512),
- ratio,
- is_loading=True,
- update_on_ratio_change=True,
- )
- if self.preview_img.is_connected:
- self.preview_img.clicked.disconnect()
- for c in self.containers:
- c.setHidden(True)
- self.preview_img.show()
- self.preview_vid.stop()
- self.preview_vid.hide()
- self.media_player.hide()
- self.media_player.stop()
- self.preview_gif.hide()
- self.selected = list(self.driver.selected)
- self.add_field_button.setHidden(True)
-
- # common code
- self.initialized = True
- self.setWindowTitle(window_title)
- self.show()
- return True
-
- # reload entry and fill it into the grid again
- # TODO - do this more granular
- # TODO - Entry reload is maybe not necessary
- for grid_idx in self.driver.selected:
- entry = self.driver.frame_content[grid_idx]
- result = self.lib.get_entry_full(entry.id)
- logger.info(
- "found item",
- grid_idx=grid_idx,
- lookup_id=entry.id,
- )
- self.driver.frame_content[grid_idx] = result
-
- if len(self.driver.selected) == 1:
- # 1 Selected Entry
- selected_idx = self.driver.selected[0]
- item = self.driver.frame_content[selected_idx]
-
- self.preview_img.show()
- self.preview_vid.stop()
- self.preview_vid.hide()
- self.media_player.stop()
- self.media_player.hide()
- self.preview_gif.hide()
-
- # If a new selection is made, update the thumbnail and filepath.
- if not self.selected or self.selected != self.driver.selected:
- filepath = self.lib.library_dir / item.path
- self.file_label.set_file_path(filepath)
- ratio = self.devicePixelRatio()
- self.thumb_renderer.render(
- time.time(),
- filepath,
- (512, 512),
- ratio,
- update_on_ratio_change=True,
- )
- file_str: str = ""
- separator: str = f"{os.path.sep}" # Gray
- for i, part in enumerate(filepath.parts):
- part_ = part.strip(os.path.sep)
- if i != len(filepath.parts) - 1:
- file_str += f"{"\u200b".join(part_)}{separator}"
- else:
- file_str += f"
{"\u200b".join(part_)}"
- self.file_label.setText(file_str)
- self.file_label.setCursor(Qt.CursorShape.PointingHandCursor)
-
- self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
- self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor)
-
- self.opener = FileOpenerHelper(filepath)
- self.open_file_action.triggered.connect(self.opener.open_file)
- self.open_explorer_action.triggered.connect(self.opener.open_explorer)
-
- # TODO: Do this all somewhere else, this is just here temporarily.
- ext: str = filepath.suffix.lower()
- try:
- if MediaCategories.is_ext_in_category(
- ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True
- ):
- if self.preview_gif.movie():
- self.preview_gif.movie().stop()
- self.gif_buffer.close()
-
- image: Image.Image = Image.open(filepath)
- anim_image: Image.Image = image
- image_bytes_io: io.BytesIO = io.BytesIO()
- anim_image.save(
- image_bytes_io,
- "GIF",
- lossless=True,
- save_all=True,
- loop=0,
- disposal=2,
- )
- image_bytes_io.seek(0)
- ba: bytes = image_bytes_io.read()
-
- self.gif_buffer.setData(ba)
- movie = QMovie(self.gif_buffer, QByteArray())
- self.preview_gif.setMovie(movie)
- movie.start()
-
- self.resizeEvent(
- QResizeEvent(
- QSize(image.width, image.height),
- QSize(image.width, image.height),
- )
- )
- self.preview_img.hide()
- self.preview_vid.hide()
- self.preview_gif.show()
-
- image = None
- if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RASTER_TYPES):
- image = Image.open(str(filepath))
- elif MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES):
- try:
- with rawpy.imread(str(filepath)) as raw:
- rgb = raw.postprocess()
- image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black")
- except (
- rawpy._rawpy.LibRawIOError,
- rawpy._rawpy.LibRawFileUnsupportedError,
- ):
- pass
- elif MediaCategories.is_ext_in_category(ext, MediaCategories.AUDIO_TYPES):
- self.media_player.show()
- self.media_player.play(filepath)
- elif MediaCategories.is_ext_in_category(
- ext, MediaCategories.VIDEO_TYPES
- ) and is_readable_video(filepath):
- video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
- video.set(
- cv2.CAP_PROP_POS_FRAMES,
- (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
- )
- success, frame = video.read()
- frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
- image = Image.fromarray(frame)
- if success:
- self.preview_img.hide()
- self.preview_vid.play(filepath, QSize(image.width, image.height))
- self.resizeEvent(
- QResizeEvent(
- QSize(image.width, image.height),
- QSize(image.width, image.height),
- )
- )
- self.preview_vid.show()
-
- # Stats for specific file types are displayed here.
- if image and (
- MediaCategories.is_ext_in_category(
- ext, MediaCategories.IMAGE_RASTER_TYPES, mime_fallback=True
- )
- or MediaCategories.is_ext_in_category(
- ext, MediaCategories.VIDEO_TYPES, mime_fallback=True
- )
- or MediaCategories.is_ext_in_category(
- ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True
- )
- ):
- self.dimensions_label.setText(
- f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n"
- f"{image.width} x {image.height} px"
- )
- elif MediaCategories.is_ext_in_category(
- ext, MediaCategories.FONT_TYPES, mime_fallback=True
- ):
- try:
- font = ImageFont.truetype(filepath)
- self.dimensions_label.setText(
- f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n"
- f"{font.getname()[0]} ({font.getname()[1]}) "
- )
- except OSError:
- self.dimensions_label.setText(
- f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}"
- )
- logger.info(
- f"[PreviewPanel][ERROR] Couldn't read font file: {filepath}"
- )
- else:
- self.dimensions_label.setText(f"{ext.upper()[1:]}")
- self.dimensions_label.setText(
- f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}"
- )
- self.update_date_label(filepath)
-
- if not filepath.is_file():
- raise FileNotFoundError
-
- except (FileNotFoundError, cv2.error) as e:
- self.dimensions_label.setText(f"{ext.upper()[1:]}")
- logger.error("Couldn't render thumbnail", filepath=filepath, error=e)
- self.update_date_label()
- except (
- UnidentifiedImageError,
- DecompressionBombError,
- ) as e:
- self.dimensions_label.setText(
- f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}"
- )
- logger.error("Couldn't render thumbnail", filepath=filepath, error=e)
- self.update_date_label(filepath)
-
- if self.preview_img.is_connected:
- self.preview_img.clicked.disconnect()
- self.preview_img.clicked.connect(lambda checked=False, pth=filepath: open_file(pth))
- self.preview_img.is_connected = True
-
- self.selected = self.driver.selected
- logger.info(
- "rendering item fields",
- item=item.id,
- fields=[x.type_key for x in item.fields],
- )
- for idx, field in enumerate(item.fields):
- self.write_container(idx, field)
-
- # Hide leftover containers
- if len(self.containers) > len(item.fields):
- for i, c in enumerate(self.containers):
- if i > (len(item.fields) - 1):
- c.setHidden(True)
-
- self.add_field_button.setHidden(False)
+ # No Items Selected
+ items: list[Entry] = [self.driver.frame_content[x] for x in self.driver.selected]
+ if len(self.driver.selected) == 0:
+ # TODO: Clear everything to default
+ # self.file_attrs.update_blank()
+ self.file_attrs.update_stats()
+ self.file_attrs.update_date_label()
+ pass
+ elif len(self.driver.selected) == 1:
+ entry: Entry = items[0]
+ filepath: Path = self.lib.library_dir / entry.path
+ stats: dict = self.thumb.update_preview(entry, filepath)
+ self.file_attrs.update_stats(filepath)
+ self.file_attrs.update_date_label(filepath)
+ # TODO: Render regular single selection
+ # TODO: Return known attributes from thumb, and give those to field_attrs
+ # self.file_attrs.update_filename()
+ pass
# Multiple Selected Items
elif len(self.driver.selected) > 1:
- self.preview_img.show()
- self.preview_gif.hide()
- self.preview_vid.stop()
- self.preview_vid.hide()
- self.media_player.stop()
- self.media_player.hide()
- self.update_date_label()
- if self.selected != self.driver.selected:
- self.file_label.setText(
- f"{len(self.driver.selected)} Items Selected"
- ) # TODO translate
- self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
- self.file_label.set_file_path("")
- self.dimensions_label.setText("")
+ # Render mixed selection
+ self.file_attrs.update_multi_selection(len(self.driver.selected))
+ self.file_attrs.update_date_label()
+ # self.file_attrs.update_selection_count()
+ pass
- self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
- self.preview_img.setCursor(Qt.CursorShape.ArrowCursor)
+ # self.thumb.update_widgets()
+ # # self.file_attrs.update_widgets()
+ # self.fields.update_widgets()
- ratio = self.devicePixelRatio()
- self.thumb_renderer.render(
- time.time(),
- "",
- (512, 512),
- ratio,
- is_loading=True,
- update_on_ratio_change=True,
- )
- if self.preview_img.is_connected:
- self.preview_img.clicked.disconnect()
- self.preview_img.is_connected = False
-
- # fill shared fields from first item
- first_item = self.driver.frame_content[self.driver.selected[0]]
- common_fields = [f for f in first_item.fields]
- mixed_fields = []
-
- # iterate through other items
- for grid_idx in self.driver.selected[1:]:
- item = self.driver.frame_content[grid_idx]
- item_field_types = {f.type_key for f in item.fields}
- for f in common_fields[:]:
- if f.type_key not in item_field_types:
- common_fields.remove(f)
- mixed_fields.append(f)
-
- self.common_fields = common_fields
- self.mixed_fields = sorted(mixed_fields, key=lambda x: x.type.position)
-
- self.selected = list(self.driver.selected)
- logger.info(
- "update_widgets common_fields",
- common_fields=self.common_fields,
- )
- for i, f in enumerate(self.common_fields):
- self.write_container(i, f)
-
- logger.info(
- "update_widgets mixed_fields",
- mixed_fields=self.mixed_fields,
- start=len(self.common_fields),
- )
- for i, f in enumerate(self.mixed_fields, start=len(self.common_fields)):
- self.write_container(i, f, is_mixed=True)
-
- # Hide leftover containers
- if len(self.containers) > len(self.common_fields) + len(self.mixed_fields):
- for i, c in enumerate(self.containers):
- if i > (len(self.common_fields) + len(self.mixed_fields) - 1):
- c.setHidden(True)
-
- self.add_field_button.setHidden(False)
-
- self.initialized = True
-
- self.setWindowTitle(window_title)
- self.show()
return True
-
- def set_tags_updated_slot(self, slot: object):
- """Replacement for tag_callback."""
- if self.is_connected:
- self.tags_updated.disconnect()
-
- logger.info("[UPDATE CONTAINER] Setting tags updated slot")
- self.tags_updated.connect(slot)
- self.is_connected = True
-
- def write_container(self, index: int, field: BaseField, is_mixed: bool = False):
- """Update/Create data for a FieldContainer.
-
- Args:
- index(int): The container index.
- field(BaseField): The type of field to write to.
- is_mixed(bool): Relevant when multiple items are selected.
- If True, field is not present in all selected items.
- """
- # Remove 'Add Field' button from scroll_layout, to be re-added later.
- self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget()
- if len(self.containers) < (index + 1):
- container = FieldContainer()
- self.containers.append(container)
- self.scroll_layout.addWidget(container)
- else:
- container = self.containers[index]
-
- # if isinstance(field, TagBoxField):
- # container.set_title(field.type.name)
- # container.set_inline(False)
- # title = f"{field.type.name} (Tag Box)"
- #
- # if not is_mixed:
- # inner_container = container.get_inner_widget()
- # if isinstance(inner_container, TagBoxWidget):
- # inner_container.set_field(field)
- # inner_container.set_tags(list(field.tags))
- #
- # try:
- # inner_container.updated.disconnect()
- # except RuntimeError:
- # logger.error("Failed to disconnect inner_container.updated")
- #
- # else:
- # inner_container = TagBoxWidget(
- # field,
- # title,
- # self.driver,
- # )
- #
- # container.set_inner_widget(inner_container)
- #
- # inner_container.updated.connect(
- # lambda: (
- # self.write_container(index, field),
- # self.update_widgets(),
- # )
- # )
- # # NOTE: Tag Boxes have no Edit Button
- # (But will when you can convert field types)
- # container.set_remove_callback(
- # lambda: self.remove_message_box(
- # prompt=self.remove_field_prompt(field.type.name),
- # callback=lambda: (
- # self.remove_field(field),
- # self.update_selected_entry(self.driver),
- # # reload entry and its fields
- # self.update_widgets(),
- # ),
- # )
- # )
- # else:
- # text = "Mixed Data"
- # title = f"{field.type.name} (Wacky Tag Box)"
- # inner_container = TextWidget(title, text)
- # container.set_inner_widget(inner_container)
- #
- # self.tags_updated.emit()
- # # self.dynamic_widgets.append(inner_container)
- # elif field.type.type == FieldTypeEnum.TEXT_LINE:
- if field.type.type == FieldTypeEnum.TEXT_LINE:
- container.set_title(field.type.name)
- container.set_inline(False)
-
- # Normalize line endings in any text content.
- if not is_mixed:
- assert isinstance(field.value, (str, type(None)))
- text = field.value or ""
- else:
- text = "Mixed Data" # TODO translate
-
- title = f"{field.type.name} ({field.type.type.value})"
- inner_container = TextWidget(title, text)
- container.set_inner_widget(inner_container)
- if not is_mixed:
- modal = PanelModal(
- EditTextLine(field.value),
- title=title,
- window_title=f"Edit {field.type.type.value}", # TODO translate
- save_callback=(
- lambda content: (
- self.update_field(field, content),
- self.update_widgets(),
- )
- ),
- )
- if "pytest" in sys.modules:
- # for better testability
- container.modal = modal # type: ignore
-
- container.set_edit_callback(modal.show)
- container.set_remove_callback(
- lambda: self.remove_message_box(
- prompt=self.remove_field_prompt(field.type.type.value),
- callback=lambda: (
- self.remove_field(field),
- self.update_widgets(),
- ),
- )
- )
-
- elif field.type.type == FieldTypeEnum.TEXT_BOX:
- container.set_title(field.type.name)
- # container.set_editable(True)
- container.set_inline(False)
- # Normalize line endings in any text content.
- if not is_mixed:
- assert isinstance(field.value, (str, type(None)))
- text = (field.value or "").replace("\r", "\n")
- else:
- text = "Mixed Data" # TODO translate
- title = f"{field.type.name} (Text Box)" # TODO translate
- inner_container = TextWidget(title, text)
- container.set_inner_widget(inner_container)
- if not is_mixed:
- modal = PanelModal(
- EditTextBox(field.value),
- title=title,
- window_title=f"Edit {field.type.name}", # TODO translate
- save_callback=(
- lambda content: (
- self.update_field(field, content),
- self.update_widgets(),
- )
- ),
- )
- container.set_edit_callback(modal.show)
- container.set_remove_callback(
- lambda: self.remove_message_box(
- prompt=self.remove_field_prompt(field.type.name),
- callback=lambda: (
- self.remove_field(field),
- self.update_widgets(),
- ),
- )
- )
-
- elif field.type.type == FieldTypeEnum.DATETIME:
- if not is_mixed:
- try:
- container.set_title(field.type.name)
- # container.set_editable(False)
- container.set_inline(False)
- # TODO: Localize this and/or add preferences.
- date = dt.strptime(field.value, "%Y-%m-%d %H:%M:%S")
- title = f"{field.type.name} (Date)" # TODO translate
- inner_container = TextWidget(title, date.strftime("%D - %r"))
- container.set_inner_widget(inner_container)
- except Exception:
- container.set_title(field.type.name)
- # container.set_editable(False)
- container.set_inline(False)
- title = f"{field.type.name} (Date) (Unknown Format)" # TODO translate
- inner_container = TextWidget(title, str(field.value))
- container.set_inner_widget(inner_container)
-
- container.set_remove_callback(
- lambda: self.remove_message_box(
- prompt=self.remove_field_prompt(field.type.name),
- callback=lambda: (
- self.remove_field(field),
- self.update_widgets(),
- ),
- )
- )
- else:
- text = "Mixed Data" # TODO translate
- title = f"{field.type.name} (Wacky Date)" # TODO translate
- inner_container = TextWidget(title, text)
- container.set_inner_widget(inner_container)
- else:
- logger.warning("write_container - unknown field", field=field)
- container.set_title(field.type.name)
- container.set_inline(False)
- title = f"{field.type.name} (Unknown Field Type)" # TODO translate
- inner_container = TextWidget(title, field.type.name)
- container.set_inner_widget(inner_container)
- container.set_remove_callback(
- lambda: self.remove_message_box(
- prompt=self.remove_field_prompt(field.type.name),
- callback=lambda: (
- self.remove_field(field),
- self.update_widgets(),
- ),
- )
- )
-
- container.edit_button.setHidden(True)
- container.setHidden(False)
- self.place_add_field_button()
-
- def remove_field(self, field: BaseField):
- """Remove a field from all selected Entries."""
- logger.info("removing field", field=field, selected=self.selected)
- entry_ids = []
-
- for grid_idx in self.selected:
- entry = self.driver.frame_content[grid_idx]
- entry_ids.append(entry.id)
-
- self.lib.remove_entry_field(field, entry_ids)
-
- # # if the field is meta tags, update the badges
- # if field.type_key == _FieldID.TAGS_META.value:
- # self.driver.update_badges(self.selected)
-
- def update_field(self, field: BaseField, content: str) -> None:
- """Update a field in all selected Entries, given a field object."""
- assert isinstance(
- field,
- (TextField, DatetimeField), # , TagBoxField)
- ), f"instance: {type(field)}"
-
- entry_ids = []
- for grid_idx in self.selected:
- entry = self.driver.frame_content[grid_idx]
- entry_ids.append(entry.id)
-
- assert entry_ids, "No entries selected"
- self.lib.update_entry_field(
- entry_ids,
- field,
- content,
- )
-
- def remove_message_box(self, prompt: str, callback: Callable) -> None:
- remove_mb = QMessageBox()
- remove_mb.setText(prompt)
- remove_mb.setWindowTitle("Remove Field") # TODO translate
- remove_mb.setIcon(QMessageBox.Icon.Warning)
- cancel_button = remove_mb.addButton(
- Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole
- )
- remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole) # TODO translate
- # remove_mb.setStandardButtons(QMessageBox.StandardButton.Cancel)
- remove_mb.setDefaultButton(cancel_button)
- remove_mb.setEscapeButton(cancel_button)
- result = remove_mb.exec_()
- # logging.info(result)
- if result == 3: # TODO - what is this magic number?
- callback()