From 1193cda3b6ea5e5f7a3748e824d83a7af6f8d476 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sat, 7 Jun 2025 20:51:11 +0200 Subject: [PATCH 01/31] refactor: basic split --- .../widgets/preview_panel_controller.py | 30 +++ src/tagstudio/qt/main_window.py | 2 +- src/tagstudio/qt/modals/folders_to_tags.py | 4 +- src/tagstudio/qt/modals/mirror_entities.py | 2 +- src/tagstudio/qt/modals/settings_panel.py | 2 +- src/tagstudio/qt/modals/tag_color_manager.py | 4 +- src/tagstudio/qt/ts_qt.py | 32 +-- .../qt/view/widgets/preview_panel_view.py | 203 +++++++++++++++++ src/tagstudio/qt/widgets/item_thumb.py | 4 +- src/tagstudio/qt/widgets/preview_panel.py | 204 ------------------ src/tagstudio/qt/widgets/tag_box.py | 8 +- 11 files changed, 263 insertions(+), 232 deletions(-) create mode 100644 src/tagstudio/qt/controller/widgets/preview_panel_controller.py create mode 100644 src/tagstudio/qt/view/widgets/preview_panel_view.py delete mode 100644 src/tagstudio/qt/widgets/preview_panel.py diff --git a/src/tagstudio/qt/controller/widgets/preview_panel_controller.py b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py new file mode 100644 index 000000000..0337f54a1 --- /dev/null +++ b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py @@ -0,0 +1,30 @@ +import typing + +from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.translations import Translations +from tagstudio.qt.view.widgets.preview_panel_view import PreviewPanelView +from tagstudio.qt.widgets.panel import PanelModal + +if typing.TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + + +class PreviewPanel(PreviewPanelView): + def __init__(self, library: Library, driver: "QtDriver"): + super().__init__(library, driver) + + self.__add_tag_modal = PanelModal(self._tag_search_panel, Translations["tag.add.plural"]) + self.__add_tag_modal.setWindowTitle(Translations["tag.add.plural"]) + + def _add_tag_button_callback(self): + self.__add_tag_modal.show() + + def update_view(self, selected, update_preview=True): + """Render the panel widgets with the newest data from the Library. + + Args: + selected (list[int]): List of the IDs of the selected entries. + update_preview (bool): Should the file preview be updated? + (Only works with one or more items selected) + """ + return super().update_view(selected, update_preview) diff --git a/src/tagstudio/qt/main_window.py b/src/tagstudio/qt/main_window.py index 88fdfb4fc..87b88de7d 100644 --- a/src/tagstudio/qt/main_window.py +++ b/src/tagstudio/qt/main_window.py @@ -33,12 +33,12 @@ from tagstudio.core.enums import ShowFilepathOption from tagstudio.core.library.alchemy.enums import SortingModeEnum +from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel from tagstudio.qt.flowlayout import FlowLayout from tagstudio.qt.pagination import Pagination from tagstudio.qt.platform_strings import trash_term from tagstudio.qt.translations import Translations from tagstudio.qt.widgets.landing import LandingWidget -from tagstudio.qt.widgets.preview_panel import PreviewPanel # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: diff --git a/src/tagstudio/qt/modals/folders_to_tags.py b/src/tagstudio/qt/modals/folders_to_tags.py index f9d837c51..8a63de82f 100644 --- a/src/tagstudio/qt/modals/folders_to_tags.py +++ b/src/tagstudio/qt/modals/folders_to_tags.py @@ -227,7 +227,9 @@ def __init__(self, library: "Library", driver: "QtDriver"): def on_apply(self, event): folders_to_tags(self.library) self.close() - self.driver.main_window.preview_panel.update_widgets(update_preview=False) + self.driver.main_window.preview_panel.update_view( + self.driver.selected, update_preview=False + ) def on_open(self, event): for i in reversed(range(self.scroll_layout.count())): diff --git a/src/tagstudio/qt/modals/mirror_entities.py b/src/tagstudio/qt/modals/mirror_entities.py index 327f6705a..33311b50b 100644 --- a/src/tagstudio/qt/modals/mirror_entities.py +++ b/src/tagstudio/qt/modals/mirror_entities.py @@ -87,7 +87,7 @@ def displayed_text(x): pw.from_iterable_function( self.mirror_entries_runnable, displayed_text, - self.driver.main_window.preview_panel.update_widgets, + lambda s=self.driver.selected: self.driver.main_window.preview_panel.update_view(s), self.done.emit, ) diff --git a/src/tagstudio/qt/modals/settings_panel.py b/src/tagstudio/qt/modals/settings_panel.py index 9202813dc..aa4d85d73 100644 --- a/src/tagstudio/qt/modals/settings_panel.py +++ b/src/tagstudio/qt/modals/settings_panel.py @@ -230,7 +230,7 @@ def update_settings(self, driver: "QtDriver"): # Apply changes # Show File Path driver.update_recent_lib_menu() - driver.main_window.preview_panel.update_widgets() + driver.main_window.preview_panel.update_view(self.driver.selected) library_directory = driver.lib.library_dir if settings["show_filepath"] == ShowFilepathOption.SHOW_FULL_PATHS: display_path = library_directory or "" diff --git a/src/tagstudio/qt/modals/tag_color_manager.py b/src/tagstudio/qt/modals/tag_color_manager.py index 8bd034f2f..ea46d94fa 100644 --- a/src/tagstudio/qt/modals/tag_color_manager.py +++ b/src/tagstudio/qt/modals/tag_color_manager.py @@ -124,7 +124,7 @@ def setup_color_groups(self): self.setup_color_groups(), () if len(self.driver.selected) < 1 - else self.driver.main_window.preview_panel.fields.update_from_entry( + else self.driver.main_window.preview_panel._fields.update_from_entry( self.driver.selected[0], update_badges=False ), ) @@ -141,7 +141,7 @@ def setup_color_groups(self): self.setup_color_groups(), () if len(self.driver.selected) < 1 - else self.driver.main_window.preview_panel.fields.update_from_entry( + else self.driver.main_window.preview_panel._fields.update_from_entry( self.driver.selected[0], update_badges=False ), ), diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index d25789d3c..52888c559 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -366,8 +366,8 @@ def start(self) -> None: # Initialize the Tag Manager panel self.tag_manager_panel = PanelModal( widget=TagDatabasePanel(self, self.lib), - done_callback=lambda: self.main_window.preview_panel.update_widgets( - update_preview=False + done_callback=lambda s=self.selected: self.main_window.preview_panel.update_view( + s, update_preview=False ), has_save=False, ) @@ -386,9 +386,9 @@ def start(self) -> None: window_title=Translations["tag.add.plural"], ) self.tag_search_panel.tag_chosen.connect( - lambda t: ( + lambda t, s=self.selected: ( self.add_tags_to_selected_callback(t), - self.main_window.preview_panel.update_widgets(), + self.main_window.preview_panel.update_view(s), ) ) @@ -541,12 +541,12 @@ def create_about_modal(): self.main_window.search_field.textChanged.connect(self.update_completions_list) - self.main_window.preview_panel.fields.archived_updated.connect( + self.main_window.preview_panel._fields.archived_updated.connect( lambda hidden: self.update_badges( {BadgeType.ARCHIVED: hidden}, origin_id=0, add_tags=False ) ) - self.main_window.preview_panel.fields.favorite_updated.connect( + self.main_window.preview_panel._fields.favorite_updated.connect( lambda hidden: self.update_badges( {BadgeType.FAVORITE: hidden}, origin_id=0, add_tags=False ) @@ -715,7 +715,7 @@ def close_library(self, is_shutdown: bool = False): self.cached_values.sync() # Reset library state - self.main_window.preview_panel.update_widgets() + self.main_window.preview_panel.update_view(self.selected) self.main_window.search_field.setText("") scrollbar: QScrollArea = self.main_window.entry_scroll_area scrollbar.verticalScrollBar().setValue(0) @@ -739,7 +739,7 @@ def close_library(self, is_shutdown: bool = False): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_widgets() + self.main_window.preview_panel.update_view(self.selected) self.main_window.toggle_landing_page(enabled=True) self.main_window.pagination.setHidden(True) try: @@ -817,7 +817,7 @@ def select_all_action_callback(self): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_widgets(update_preview=False) + self.main_window.preview_panel.update_view(self.selected, update_preview=False) def select_inverse_action_callback(self): """Invert the selection of all visible items.""" @@ -836,7 +836,7 @@ def select_inverse_action_callback(self): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_widgets(update_preview=False) + self.main_window.preview_panel.update_view(self.selected, update_preview=False) def clear_select_action_callback(self): self.selected.clear() @@ -845,7 +845,7 @@ def clear_select_action_callback(self): item.thumb_button.set_selected(False) self.set_clipboard_menu_viability() - self.main_window.preview_panel.update_widgets() + self.main_window.preview_panel.update_view(self.selected) def add_tags_to_selected_callback(self, tag_ids: list[int]): self.lib.add_tags_to_entries(self.selected, tag_ids) @@ -890,7 +890,7 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = for i, tup in enumerate(pending): e_id, f = tup if (origin_path == f) or (not origin_path): - self.main_window.preview_panel.thumb.media_player.stop() + self.main_window.preview_panel.thumb_media_player_stop() if delete_file(self.lib.library_dir / f): self.main_window.status_bar.showMessage( Translations.format( @@ -905,7 +905,7 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = if deleted_count > 0: self.update_browsing_state() - self.main_window.preview_panel.update_widgets() + self.main_window.preview_panel.update_view(self.selected) if len(self.selected) <= 1 and deleted_count == 0: self.main_window.status_bar.showMessage(Translations["status.deleted_none"]) @@ -1230,7 +1230,7 @@ def paste_fields_action_callback(self): if TAG_FAVORITE in self.copy_buffer["tags"]: self.update_badges({BadgeType.FAVORITE: True}, origin_id=0, add_tags=False) else: - self.main_window.preview_panel.update_widgets() + self.main_window.preview_panel.update_view(self.selected) def toggle_item_selection(self, item_id: int, append: bool, bridge: bool): """Toggle the selection of an item in the Thumbnail Grid. @@ -1304,7 +1304,7 @@ def toggle_item_selection(self, item_id: int, append: bool, bridge: bool): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_widgets() + self.main_window.preview_panel.update_view(self.selected) def set_clipboard_menu_viability(self): if len(self.selected) == 1: @@ -1748,7 +1748,7 @@ def _init_library(self, path: Path, open_status: LibraryStatus): self.main_window.menu_bar.clear_thumb_cache_action.setEnabled(True) self.main_window.menu_bar.folders_to_tags_action.setEnabled(True) - self.main_window.preview_panel.update_widgets() + self.main_window.preview_panel.update_view(self.selected) # page (re)rendering, extract eventually self.update_browsing_state() diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py new file mode 100644 index 000000000..0bd672648 --- /dev/null +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -0,0 +1,203 @@ +import traceback +import typing +from pathlib import Path +from warnings import catch_warnings + +import structlog +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSplitter, QVBoxLayout, QWidget + +from tagstudio.core.enums import Theme +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.library.alchemy.models import Entry +from tagstudio.core.palette import ColorType, UiColor, get_ui_color +from tagstudio.qt.modals.add_field import AddFieldModal +from tagstudio.qt.modals.tag_search import TagSearchPanel +from tagstudio.qt.translations import Translations +from tagstudio.qt.widgets.preview.field_containers import FieldContainers +from tagstudio.qt.widgets.preview.file_attributes import FileAttributes +from tagstudio.qt.widgets.preview.preview_thumb import PreviewThumb + +if typing.TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + +BUTTON_STYLE = ( + f"QPushButton{{" + f"background-color:{Theme.COLOR_BG.value};" + "border-radius:6px;" + "font-weight: 500;" + "text-align: center;" + f"}}" + f"QPushButton::hover{{" + f"background-color:{Theme.COLOR_HOVER.value};" + f"border-color:{get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};" + f"border-style:solid;" + f"border-width: 2px;" + f"}}" + f"QPushButton::pressed{{" + f"background-color:{Theme.COLOR_PRESSED.value};" + f"border-color:{get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)};" + f"border-style:solid;" + f"border-width: 2px;" + f"}}" + f"QPushButton::disabled{{" + f"background-color:{Theme.COLOR_DISABLED_BG.value};" + f"}}" +) + + +class PreviewPanelView(QWidget): + lib: Library + + def __init__(self, library: Library, driver: "QtDriver"): + super().__init__() + self.lib = library + + self.__thumb = PreviewThumb(self.lib, driver) + self.__file_attrs = FileAttributes(self.lib, driver) + self._fields = FieldContainers(self.lib, driver) + + self._tag_search_panel = TagSearchPanel(self.lib, is_tag_chooser=True) + + self.add_field_modal = AddFieldModal(self.lib) + + preview_section = QWidget() + preview_layout = QVBoxLayout(preview_section) + preview_layout.setContentsMargins(0, 0, 0, 0) + preview_layout.setSpacing(6) + + info_section = QWidget() + info_layout = QVBoxLayout(info_section) + info_layout.setContentsMargins(0, 0, 0, 0) + info_layout.setSpacing(6) + + splitter = QSplitter() + splitter.setOrientation(Qt.Orientation.Vertical) + splitter.setHandleWidth(12) + + add_buttons_container = QWidget() + add_buttons_layout = QHBoxLayout(add_buttons_container) + add_buttons_layout.setContentsMargins(0, 0, 0, 0) + add_buttons_layout.setSpacing(6) + + self.__add_tag_button = QPushButton(Translations["tag.add"]) + self.__add_tag_button.setEnabled(False) + self.__add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.__add_tag_button.setMinimumHeight(28) + self.__add_tag_button.setStyleSheet(BUTTON_STYLE) + + self.__add_field_button = QPushButton(Translations["library.field.add"]) + self.__add_field_button.setEnabled(False) + self.__add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.__add_field_button.setMinimumHeight(28) + self.__add_field_button.setStyleSheet(BUTTON_STYLE) + + add_buttons_layout.addWidget(self.__add_tag_button) + add_buttons_layout.addWidget(self.__add_field_button) + + preview_layout.addWidget(self.__thumb) + info_layout.addWidget(self.__file_attrs) + info_layout.addWidget(self._fields) + + splitter.addWidget(preview_section) + splitter.addWidget(info_section) + splitter.setStretchFactor(1, 2) + + root_layout = QVBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.addWidget(splitter) + root_layout.addWidget(add_buttons_container) + + self.__connect_callbacks() + + def __connect_callbacks(self): + self.__add_tag_button.clicked.connect(self._add_tag_button_callback) + + def thumb_media_player_stop(self): + self.__thumb.media_player.stop() + + def _add_tag_button_callback(self): + raise NotImplementedError() + + def update_view(self, selected: list[int], update_preview: bool = True): + """Render the panel widgets with the newest data from the Library. + + Args: + selected (list[int]): List of the IDs of the selected entries. + update_preview (bool): Should the file preview be updated? + (Only works with one or more items selected) + """ + # No Items Selected + try: + if len(selected) == 0: + self.__thumb.hide_preview() + self.__file_attrs.update_stats() + self.__file_attrs.update_date_label() + self._fields.hide_containers() + + self.__add_tag_button.setEnabled(False) + self.__add_field_button.setEnabled(False) + + # One Item Selected + elif len(selected) == 1: + entry_id = selected[0] + entry: Entry | None = self.lib.get_entry(entry_id) + assert entry is not None + + assert self.lib.library_dir is not None + filepath: Path = self.lib.library_dir / entry.path + + if update_preview: + stats: dict = self.__thumb.update_preview(filepath) + self.__file_attrs.update_stats(filepath, stats) + self.__file_attrs.update_date_label(filepath) + self._fields.update_from_entry(entry_id) + self.__update_add_tag_button(entry_id) + self.__update_add_field_button(entry_id) + + self.__add_tag_button.setEnabled(True) + self.__add_field_button.setEnabled(True) + + # Multiple Selected Items + elif len(selected) > 1: + # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected] + self.__thumb.hide_preview() # TODO: Render mixed selection + self.__file_attrs.update_multi_selection(len(selected)) + self.__file_attrs.update_date_label() + self._fields.hide_containers() # TODO: Allow for mixed editing + self.__update_add_tag_button() + self.__update_add_field_button() + + self.__add_tag_button.setEnabled(True) + self.__add_field_button.setEnabled(True) + + except Exception as e: + logger.error("[Preview Panel] Error updating selection", error=e) + traceback.print_exc() + + def __update_add_field_button(self, entry_id: int | None = None): + with catch_warnings(record=True): + self.add_field_modal.done.disconnect() + self.__add_field_button.clicked.disconnect() + + self.add_field_modal.done.connect( + lambda f: ( + self._fields.add_field_to_selected(f), + (self._fields.update_from_entry(entry_id) if entry_id else ()), + ) + ) + self.__add_field_button.clicked.connect(self.add_field_modal.show) + + def __update_add_tag_button(self, entry_id: int | None = None): + with catch_warnings(record=True): + self._tag_search_panel.tag_chosen.disconnect() + self.__add_tag_button.clicked.disconnect() + + self._tag_search_panel.tag_chosen.connect( + lambda t: ( + self._fields.add_tags_to_selected(t), + (self._fields.update_from_entry(entry_id) if entry_id else ()), + ) + ) diff --git a/src/tagstudio/qt/widgets/item_thumb.py b/src/tagstudio/qt/widgets/item_thumb.py index f4d9fd685..68ea9b77b 100644 --- a/src/tagstudio/qt/widgets/item_thumb.py +++ b/src/tagstudio/qt/widgets/item_thumb.py @@ -498,9 +498,9 @@ def toggle_item_tag( toggle_value: bool, tag_id: int, ): - if entry_id in self.driver.selected and self.driver.main_window.preview_panel.is_open: + if entry_id in self.driver.selected: if len(self.driver.selected) == 1: - self.driver.main_window.preview_panel.fields.update_toggled_tag( + self.driver.main_window.preview_panel._fields.update_toggled_tag( tag_id, toggle_value ) else: diff --git a/src/tagstudio/qt/widgets/preview_panel.py b/src/tagstudio/qt/widgets/preview_panel.py deleted file mode 100644 index ef4676e89..000000000 --- a/src/tagstudio/qt/widgets/preview_panel.py +++ /dev/null @@ -1,204 +0,0 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - - -import traceback -import typing -from pathlib import Path -from warnings import catch_warnings - -import structlog -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSplitter, QVBoxLayout, QWidget - -from tagstudio.core.enums import Theme -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.alchemy.models import Entry -from tagstudio.core.palette import ColorType, UiColor, get_ui_color -from tagstudio.qt.modals.add_field import AddFieldModal -from tagstudio.qt.modals.tag_search import TagSearchPanel -from tagstudio.qt.translations import Translations -from tagstudio.qt.widgets.panel import PanelModal -from tagstudio.qt.widgets.preview.field_containers import FieldContainers -from tagstudio.qt.widgets.preview.file_attributes import FileAttributes -from tagstudio.qt.widgets.preview.preview_thumb import PreviewThumb - -if typing.TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - -logger = structlog.get_logger(__name__) - - -class PreviewPanel(QWidget): - """The Preview Panel Widget.""" - - # TODO: There should be a global button theme somewhere. - button_style = ( - f"QPushButton{{" - f"background-color:{Theme.COLOR_BG.value};" - "border-radius:6px;" - "font-weight: 500;" - "text-align: center;" - f"}}" - f"QPushButton::hover{{" - f"background-color:{Theme.COLOR_HOVER.value};" - f"border-color:{get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QPushButton::pressed{{" - f"background-color:{Theme.COLOR_PRESSED.value};" - f"border-color:{get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)};" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QPushButton::disabled{{" - f"background-color:{Theme.COLOR_DISABLED_BG.value};" - f"}}" - ) - - def __init__(self, library: Library, driver: "QtDriver"): - super().__init__() - self.lib = library - self.driver: QtDriver = driver - self.initialized = False - self.is_open: bool = True - - self.thumb = PreviewThumb(library, driver) - self.file_attrs = FileAttributes(library, driver) - self.fields = FieldContainers(library, driver) - - self.tag_search_panel = TagSearchPanel(self.driver.lib, is_tag_chooser=True) - self.add_tag_modal = PanelModal(self.tag_search_panel, Translations["tag.add.plural"]) - self.add_tag_modal.setWindowTitle(Translations["tag.add.plural"]) - - self.add_field_modal = AddFieldModal(self.lib) - - preview_section = QWidget() - preview_layout = QVBoxLayout(preview_section) - preview_layout.setContentsMargins(0, 0, 0, 0) - preview_layout.setSpacing(6) - - info_section = QWidget() - info_layout = QVBoxLayout(info_section) - info_layout.setContentsMargins(0, 0, 0, 0) - info_layout.setSpacing(6) - - splitter = QSplitter() - splitter.setOrientation(Qt.Orientation.Vertical) - splitter.setHandleWidth(12) - - add_buttons_container = QWidget() - add_buttons_layout = QHBoxLayout(add_buttons_container) - add_buttons_layout.setContentsMargins(0, 0, 0, 0) - add_buttons_layout.setSpacing(6) - - self.add_tag_button = QPushButton(Translations["tag.add"]) - self.add_tag_button.setEnabled(False) - self.add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.add_tag_button.setMinimumHeight(28) - self.add_tag_button.setStyleSheet(PreviewPanel.button_style) - - self.add_field_button = QPushButton(Translations["library.field.add"]) - self.add_field_button.setEnabled(False) - self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.add_field_button.setMinimumHeight(28) - self.add_field_button.setStyleSheet(PreviewPanel.button_style) - - add_buttons_layout.addWidget(self.add_tag_button) - add_buttons_layout.addWidget(self.add_field_button) - - preview_layout.addWidget(self.thumb) - info_layout.addWidget(self.file_attrs) - info_layout.addWidget(self.fields) - - splitter.addWidget(preview_section) - splitter.addWidget(info_section) - splitter.setStretchFactor(1, 2) - - root_layout = QVBoxLayout(self) - root_layout.setContentsMargins(0, 0, 0, 0) - root_layout.addWidget(splitter) - root_layout.addWidget(add_buttons_container) - - def update_widgets(self, update_preview: bool = True) -> bool: - """Render the panel widgets with the newest data from the Library. - - Args: - update_preview(bool): Should the file preview be updated? - (Only works with one or more items selected) - """ - # No Items Selected - try: - if len(self.driver.selected) == 0: - self.thumb.hide_preview() - self.file_attrs.update_stats() - self.file_attrs.update_date_label() - self.fields.hide_containers() - - self.add_tag_button.setEnabled(False) - self.add_field_button.setEnabled(False) - - # One Item Selected - elif len(self.driver.selected) == 1: - entry: Entry = self.lib.get_entry(self.driver.selected[0]) - entry_id = self.driver.selected[0] - filepath: Path = self.lib.library_dir / entry.path - - if update_preview: - stats: dict = self.thumb.update_preview(filepath) - self.file_attrs.update_stats(filepath, stats) - self.file_attrs.update_date_label(filepath) - self.fields.update_from_entry(entry_id) - self.update_add_tag_button(entry_id) - self.update_add_field_button(entry_id) - - self.add_tag_button.setEnabled(True) - self.add_field_button.setEnabled(True) - - # Multiple Selected Items - elif len(self.driver.selected) > 1: - # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected] - self.thumb.hide_preview() # TODO: Render mixed selection - self.file_attrs.update_multi_selection(len(self.driver.selected)) - self.file_attrs.update_date_label() - self.fields.hide_containers() # TODO: Allow for mixed editing - self.update_add_tag_button() - self.update_add_field_button() - - self.add_tag_button.setEnabled(True) - self.add_field_button.setEnabled(True) - - return True - except Exception as e: - logger.error("[Preview Panel] Error updating selection", error=e) - traceback.print_exc() - return False - - def update_add_field_button(self, entry_id: int | None = None): - with catch_warnings(record=True): - self.add_field_modal.done.disconnect() - self.add_field_button.clicked.disconnect() - - self.add_field_modal.done.connect( - lambda f: ( - self.fields.add_field_to_selected(f), - (self.fields.update_from_entry(entry_id) if entry_id else ()), - ) - ) - self.add_field_button.clicked.connect(self.add_field_modal.show) - - def update_add_tag_button(self, entry_id: int = None): - with catch_warnings(record=True): - self.tag_search_panel.tag_chosen.disconnect() - self.add_tag_button.clicked.disconnect() - - self.tag_search_panel.tag_chosen.connect( - lambda t: ( - self.fields.add_tags_to_selected(t), - (self.fields.update_from_entry(entry_id) if entry_id else ()), - ) - ) - - self.add_tag_button.clicked.connect(self.add_tag_modal.show) diff --git a/src/tagstudio/qt/widgets/tag_box.py b/src/tagstudio/qt/widgets/tag_box.py index e437c8800..042b5ba98 100644 --- a/src/tagstudio/qt/widgets/tag_box.py +++ b/src/tagstudio/qt/widgets/tag_box.py @@ -57,9 +57,9 @@ def set_tags(self, tags: typing.Iterable[Tag]): tag_widget.on_click.connect(lambda t=tag: self.edit_tag(t)) tag_widget.on_remove.connect( - lambda tag_id=tag.id: ( + lambda tag_id=tag.id, s=self.driver.selected: ( self.remove_tag(tag_id), - self.driver.main_window.preview_panel.update_widgets(update_preview=False), + self.driver.main_window.preview_panel.update_view(s, update_preview=False), ) ) tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t)) @@ -81,8 +81,8 @@ def edit_tag(self, tag: Tag): build_tag_panel, self.driver.lib.tag_display_name(tag.id), "Edit Tag", - done_callback=lambda: self.driver.main_window.preview_panel.update_widgets( - update_preview=False + done_callback=lambda s=self.driver.selected: self.driver.main_window.preview_panel.update_view( # noqa: E501 + s, update_preview=False ), has_save=True, ) From caa2a32ddcd1463021603edd0a05c6e9e495ccf9 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sat, 7 Jun 2025 21:46:50 +0200 Subject: [PATCH 02/31] fix: renaming and usage test didn't work for the tests --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c173364f8..1ccb444e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,8 @@ qt_api = "pyside6" [tool.pyright] ignore = [".venv/**"] -include = ["src/tagstudio/**"] +include = ["src/tagstudio", "tests"] +extraPaths = ["src/tagstudio", "tests"] reportAny = false reportIgnoreCommentWithoutRule = false reportImplicitStringConcatenation = false From f3cd9384e35495f61770748705f454eb8fd09eca Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sat, 7 Jun 2025 21:47:00 +0200 Subject: [PATCH 03/31] fix: tests --- src/tagstudio/qt/modals/tag_color_manager.py | 4 +- src/tagstudio/qt/ts_qt.py | 4 +- .../qt/view/widgets/preview_panel_view.py | 36 +++++++++++++----- src/tagstudio/qt/widgets/item_thumb.py | 2 +- tests/qt/test_field_containers.py | 38 +++++++++---------- tests/qt/test_file_path_options.py | 8 ++-- tests/qt/test_preview_panel.py | 20 +++++----- 7 files changed, 65 insertions(+), 47 deletions(-) diff --git a/src/tagstudio/qt/modals/tag_color_manager.py b/src/tagstudio/qt/modals/tag_color_manager.py index ea46d94fa..ed1b918ed 100644 --- a/src/tagstudio/qt/modals/tag_color_manager.py +++ b/src/tagstudio/qt/modals/tag_color_manager.py @@ -124,7 +124,7 @@ def setup_color_groups(self): self.setup_color_groups(), () if len(self.driver.selected) < 1 - else self.driver.main_window.preview_panel._fields.update_from_entry( + else self.driver.main_window.preview_panel._field_containers_widget.update_from_entry( # noqa: E501 self.driver.selected[0], update_badges=False ), ) @@ -141,7 +141,7 @@ def setup_color_groups(self): self.setup_color_groups(), () if len(self.driver.selected) < 1 - else self.driver.main_window.preview_panel._fields.update_from_entry( + else self.driver.main_window.preview_panel._field_containers_widget.update_from_entry( # noqa: E501 self.driver.selected[0], update_badges=False ), ), diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 52888c559..e72646f39 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -541,12 +541,12 @@ def create_about_modal(): self.main_window.search_field.textChanged.connect(self.update_completions_list) - self.main_window.preview_panel._fields.archived_updated.connect( + self.main_window.preview_panel._field_containers_widget.archived_updated.connect( lambda hidden: self.update_badges( {BadgeType.ARCHIVED: hidden}, origin_id=0, add_tags=False ) ) - self.main_window.preview_panel._fields.favorite_updated.connect( + self.main_window.preview_panel._field_containers_widget.favorite_updated.connect( lambda hidden: self.update_badges( {BadgeType.FAVORITE: hidden}, origin_id=0, add_tags=False ) diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py index 0bd672648..cc64d4e30 100644 --- a/src/tagstudio/qt/view/widgets/preview_panel_view.py +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -57,7 +57,7 @@ def __init__(self, library: Library, driver: "QtDriver"): self.__thumb = PreviewThumb(self.lib, driver) self.__file_attrs = FileAttributes(self.lib, driver) - self._fields = FieldContainers(self.lib, driver) + self.__fields = FieldContainers(self.lib, driver) self._tag_search_panel = TagSearchPanel(self.lib, is_tag_chooser=True) @@ -99,7 +99,7 @@ def __init__(self, library: Library, driver: "QtDriver"): preview_layout.addWidget(self.__thumb) info_layout.addWidget(self.__file_attrs) - info_layout.addWidget(self._fields) + info_layout.addWidget(self.__fields) splitter.addWidget(preview_section) splitter.addWidget(info_section) @@ -115,6 +115,24 @@ def __init__(self, library: Library, driver: "QtDriver"): def __connect_callbacks(self): self.__add_tag_button.clicked.connect(self._add_tag_button_callback) + def add_tag_button_enabled(self) -> bool: # needed for the tests + """Returns whether the 'Add Tag' Button is enabled.""" + return self.__add_tag_button.isEnabled() + + def add_field_button_enabled(self) -> bool: # needed for the tests + """Returns whether the 'Add Field' Button is enabled.""" + return self.__add_field_button.isEnabled() + + @property + def _file_attributes_widget(self) -> FileAttributes: # needed for the tests + """Getter for the file attributes widget.""" + return self.__file_attrs + + @property + def _field_containers_widget(self) -> FieldContainers: # needed for the tests + """Getter for the field containers widget.""" + return self.__fields # TODO: try to remove non-test uses of this + def thumb_media_player_stop(self): self.__thumb.media_player.stop() @@ -135,7 +153,7 @@ def update_view(self, selected: list[int], update_preview: bool = True): self.__thumb.hide_preview() self.__file_attrs.update_stats() self.__file_attrs.update_date_label() - self._fields.hide_containers() + self.__fields.hide_containers() self.__add_tag_button.setEnabled(False) self.__add_field_button.setEnabled(False) @@ -153,7 +171,7 @@ def update_view(self, selected: list[int], update_preview: bool = True): stats: dict = self.__thumb.update_preview(filepath) self.__file_attrs.update_stats(filepath, stats) self.__file_attrs.update_date_label(filepath) - self._fields.update_from_entry(entry_id) + self.__fields.update_from_entry(entry_id) self.__update_add_tag_button(entry_id) self.__update_add_field_button(entry_id) @@ -166,7 +184,7 @@ def update_view(self, selected: list[int], update_preview: bool = True): self.__thumb.hide_preview() # TODO: Render mixed selection self.__file_attrs.update_multi_selection(len(selected)) self.__file_attrs.update_date_label() - self._fields.hide_containers() # TODO: Allow for mixed editing + self.__fields.hide_containers() # TODO: Allow for mixed editing self.__update_add_tag_button() self.__update_add_field_button() @@ -184,8 +202,8 @@ def __update_add_field_button(self, entry_id: int | None = None): self.add_field_modal.done.connect( lambda f: ( - self._fields.add_field_to_selected(f), - (self._fields.update_from_entry(entry_id) if entry_id else ()), + self.__fields.add_field_to_selected(f), + (self.__fields.update_from_entry(entry_id) if entry_id else ()), ) ) self.__add_field_button.clicked.connect(self.add_field_modal.show) @@ -197,7 +215,7 @@ def __update_add_tag_button(self, entry_id: int | None = None): self._tag_search_panel.tag_chosen.connect( lambda t: ( - self._fields.add_tags_to_selected(t), - (self._fields.update_from_entry(entry_id) if entry_id else ()), + self.__fields.add_tags_to_selected(t), + (self.__fields.update_from_entry(entry_id) if entry_id else ()), ) ) diff --git a/src/tagstudio/qt/widgets/item_thumb.py b/src/tagstudio/qt/widgets/item_thumb.py index 68ea9b77b..342fa8e74 100644 --- a/src/tagstudio/qt/widgets/item_thumb.py +++ b/src/tagstudio/qt/widgets/item_thumb.py @@ -500,7 +500,7 @@ def toggle_item_tag( ): if entry_id in self.driver.selected: if len(self.driver.selected) == 1: - self.driver.main_window.preview_panel._fields.update_toggled_tag( + self.driver.main_window.preview_panel._field_containers_widget.update_toggled_tag( tag_id, toggle_value ) else: diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 5e583c1a4..7a7429f0d 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -1,4 +1,4 @@ -from tagstudio.qt.widgets.preview_panel import PreviewPanel +from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel def test_update_selection_empty(qt_driver, library): @@ -7,10 +7,10 @@ def test_update_selection_empty(qt_driver, library): # Clear the library selection (selecting 1 then unselecting 1) qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(1, append=True, bridge=False) - panel.update_widgets() + panel.update_view(qt_driver.selected) # FieldContainer should hide all containers - for container in panel.fields.containers: + for container in panel._field_containers_widget.containers: assert container.isHidden() @@ -19,10 +19,10 @@ def test_update_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_widgets() + panel.update_view(qt_driver.selected) # FieldContainer should show all applicable tags and field containers - for container in panel.fields.containers: + for container in panel._field_containers_widget.containers: assert not container.isHidden() @@ -34,10 +34,10 @@ def test_update_selection_multiple(qt_driver, library): # Select the multiple entries qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(2, append=True, bridge=False) - panel.update_widgets() + panel.update_view(qt_driver.selected) # FieldContainer should show mixed field editing - for container in panel.fields.containers: + for container in panel._field_containers_widget.containers: assert container.isHidden() @@ -48,10 +48,10 @@ def test_add_tag_to_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_widgets() + panel.update_view(qt_driver.selected) # Add new tag - panel.fields.add_tags_to_selected(2000) + panel._field_containers_widget.add_tags_to_selected(2000) # Then reload entry refreshed_entry = next(library.get_entries(with_joins=True)) @@ -65,10 +65,10 @@ def test_add_same_tag_to_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_widgets() + panel.update_view(qt_driver.selected) # Add an existing tag - panel.fields.add_tags_to_selected(1000) + panel._field_containers_widget.add_tags_to_selected(1000) # Then reload entry refreshed_entry = next(library.get_entries(with_joins=True)) @@ -95,10 +95,10 @@ def test_add_tag_to_selection_multiple(qt_driver, library): # Select the multiple entries for i, e in enumerate(library.get_entries(with_joins=True), start=0): qt_driver.toggle_item_selection(e.id, append=(True if i == 0 else False), bridge=False) # noqa: SIM210 - panel.update_widgets() + panel.update_view(qt_driver.selected) # Add new tag - panel.fields.add_tags_to_selected(1000) + panel._field_containers_widget.add_tags_to_selected(1000) # Then reload all entries and recheck the presence of tag 1000 refreshed_entries = library.get_entries(with_joins=True) @@ -123,11 +123,11 @@ def test_meta_tag_category(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_widgets() + panel.update_view(qt_driver.selected) # FieldContainer should hide all containers - assert len(panel.fields.containers) == 3 - for i, container in enumerate(panel.fields.containers): + assert len(panel._field_containers_widget.containers) == 3 + for i, container in enumerate(panel._field_containers_widget.containers): match i: case 0: # Check if the container is the Meta Tags category @@ -155,11 +155,11 @@ def test_custom_tag_category(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_widgets() + panel.update_view(qt_driver.selected) # FieldContainer should hide all containers - assert len(panel.fields.containers) == 3 - for i, container in enumerate(panel.fields.containers): + assert len(panel._field_containers_widget.containers) == 3 + for i, container in enumerate(panel._field_containers_widget.containers): match i: case 0: # Check if the container is the Meta Tags category diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index bd59f5375..f39c4a60c 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -12,9 +12,9 @@ from tagstudio.core.enums import ShowFilepathOption from tagstudio.core.library.alchemy.library import Library, LibraryStatus from tagstudio.core.library.alchemy.models import Entry +from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel from tagstudio.qt.modals.settings_panel import SettingsPanel from tagstudio.qt.ts_qt import QtDriver -from tagstudio.qt.widgets.preview_panel import PreviewPanel # Tests to see if the file path setting is applied correctly @@ -59,7 +59,7 @@ def test_file_path_display( # Select 2 qt_driver.toggle_item_selection(2, append=False, bridge=False) - panel.update_widgets() + panel.update_view(qt_driver.selected) qt_driver.settings.show_filepath = filepath_option @@ -68,7 +68,7 @@ def test_file_path_display( assert isinstance(entry, Entry) filename = entry.path assert library.library_dir is not None - panel.file_attrs.update_stats(filepath=library.library_dir / filename) + panel._file_attributes_widget.update_stats(filepath=library.library_dir / filename) # Generate the expected file string. # This is copied directly from the file_attributes.py file @@ -86,7 +86,7 @@ def test_file_path_display( file_str += f"{'\u200b'.join(part_)}" # Assert the file path is displayed correctly - assert panel.file_attrs.file_label.text() == file_str + assert panel._file_attributes_widget.file_label.text() == file_str @pytest.mark.parametrize( diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py index 461c735c3..88f655705 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -1,4 +1,4 @@ -from tagstudio.qt.widgets.preview_panel import PreviewPanel +from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel def test_update_selection_empty(qt_driver, library): @@ -7,11 +7,11 @@ def test_update_selection_empty(qt_driver, library): # Clear the library selection (selecting 1 then unselecting 1) qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(1, append=True, bridge=False) - panel.update_widgets() + panel.update_view(qt_driver.selected) # Panel should disable UI that allows for entry modification - assert not panel.add_tag_button.isEnabled() - assert not panel.add_field_button.isEnabled() + assert not panel.add_tag_button_enabled() + assert not panel.add_field_button_enabled() def test_update_selection_single(qt_driver, library, entry_full): @@ -19,11 +19,11 @@ def test_update_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_widgets() + panel.update_view(qt_driver.selected) # Panel should enable UI that allows for entry modification - assert panel.add_tag_button.isEnabled() - assert panel.add_field_button.isEnabled() + assert panel.add_tag_button_enabled() + assert panel.add_field_button_enabled() def test_update_selection_multiple(qt_driver, library): @@ -32,8 +32,8 @@ def test_update_selection_multiple(qt_driver, library): # Select the multiple entries qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(2, append=True, bridge=False) - panel.update_widgets() + panel.update_view(qt_driver.selected) # Panel should enable UI that allows for entry modification - assert panel.add_tag_button.isEnabled() - assert panel.add_field_button.isEnabled() + assert panel.add_tag_button_enabled() + assert panel.add_field_button_enabled() From b115a6a295b43d49eea5b00d14b76387209328f2 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sat, 7 Jun 2025 22:19:21 +0200 Subject: [PATCH 04/31] refactor: restructuring --- .../widgets/preview_panel_controller.py | 8 ++-- src/tagstudio/qt/modals/folders_to_tags.py | 4 +- src/tagstudio/qt/modals/mirror_entities.py | 2 +- src/tagstudio/qt/modals/settings_panel.py | 2 +- src/tagstudio/qt/ts_qt.py | 26 ++++++------- .../qt/view/widgets/preview_panel_view.py | 39 ++++++++++--------- src/tagstudio/qt/widgets/tag_box.py | 4 +- tests/qt/test_field_containers.py | 16 ++++---- tests/qt/test_file_path_options.py | 2 +- tests/qt/test_preview_panel.py | 15 +++---- 10 files changed, 58 insertions(+), 60 deletions(-) diff --git a/src/tagstudio/qt/controller/widgets/preview_panel_controller.py b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py index 0337f54a1..8ecb93aa4 100644 --- a/src/tagstudio/qt/controller/widgets/preview_panel_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py @@ -19,12 +19,12 @@ def __init__(self, library: Library, driver: "QtDriver"): def _add_tag_button_callback(self): self.__add_tag_modal.show() - def update_view(self, selected, update_preview=True): + def update_view(self, driver: "QtDriver", update_preview=True): """Render the panel widgets with the newest data from the Library. Args: - selected (list[int]): List of the IDs of the selected entries. + driver (QtDriver): List of the IDs of the selected entries. update_preview (bool): Should the file preview be updated? - (Only works with one or more items selected) + (Only works with one or more items selected) """ - return super().update_view(selected, update_preview) + self._set_selection(driver.selected, update_preview) diff --git a/src/tagstudio/qt/modals/folders_to_tags.py b/src/tagstudio/qt/modals/folders_to_tags.py index 8a63de82f..4e04746c7 100644 --- a/src/tagstudio/qt/modals/folders_to_tags.py +++ b/src/tagstudio/qt/modals/folders_to_tags.py @@ -227,9 +227,7 @@ def __init__(self, library: "Library", driver: "QtDriver"): def on_apply(self, event): folders_to_tags(self.library) self.close() - self.driver.main_window.preview_panel.update_view( - self.driver.selected, update_preview=False - ) + self.driver.main_window.preview_panel.update_view(self.driver, update_preview=False) def on_open(self, event): for i in reversed(range(self.scroll_layout.count())): diff --git a/src/tagstudio/qt/modals/mirror_entities.py b/src/tagstudio/qt/modals/mirror_entities.py index 33311b50b..f1e506f4d 100644 --- a/src/tagstudio/qt/modals/mirror_entities.py +++ b/src/tagstudio/qt/modals/mirror_entities.py @@ -87,7 +87,7 @@ def displayed_text(x): pw.from_iterable_function( self.mirror_entries_runnable, displayed_text, - lambda s=self.driver.selected: self.driver.main_window.preview_panel.update_view(s), + lambda s=self.driver: self.driver.main_window.preview_panel.update_view(s), self.done.emit, ) diff --git a/src/tagstudio/qt/modals/settings_panel.py b/src/tagstudio/qt/modals/settings_panel.py index aa4d85d73..18d276b4a 100644 --- a/src/tagstudio/qt/modals/settings_panel.py +++ b/src/tagstudio/qt/modals/settings_panel.py @@ -230,7 +230,7 @@ def update_settings(self, driver: "QtDriver"): # Apply changes # Show File Path driver.update_recent_lib_menu() - driver.main_window.preview_panel.update_view(self.driver.selected) + driver.main_window.preview_panel.update_view(self.driver) library_directory = driver.lib.library_dir if settings["show_filepath"] == ShowFilepathOption.SHOW_FULL_PATHS: display_path = library_directory or "" diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index e72646f39..8dd27eba0 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -366,8 +366,8 @@ def start(self) -> None: # Initialize the Tag Manager panel self.tag_manager_panel = PanelModal( widget=TagDatabasePanel(self, self.lib), - done_callback=lambda s=self.selected: self.main_window.preview_panel.update_view( - s, update_preview=False + done_callback=lambda: self.main_window.preview_panel.update_view( + self, update_preview=False ), has_save=False, ) @@ -386,9 +386,9 @@ def start(self) -> None: window_title=Translations["tag.add.plural"], ) self.tag_search_panel.tag_chosen.connect( - lambda t, s=self.selected: ( + lambda t: ( self.add_tags_to_selected_callback(t), - self.main_window.preview_panel.update_view(s), + self.main_window.preview_panel.update_view(self), ) ) @@ -715,7 +715,7 @@ def close_library(self, is_shutdown: bool = False): self.cached_values.sync() # Reset library state - self.main_window.preview_panel.update_view(self.selected) + self.main_window.preview_panel.update_view(self) self.main_window.search_field.setText("") scrollbar: QScrollArea = self.main_window.entry_scroll_area scrollbar.verticalScrollBar().setValue(0) @@ -739,7 +739,7 @@ def close_library(self, is_shutdown: bool = False): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_view(self.selected) + self.main_window.preview_panel.update_view(self) self.main_window.toggle_landing_page(enabled=True) self.main_window.pagination.setHidden(True) try: @@ -817,7 +817,7 @@ def select_all_action_callback(self): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_view(self.selected, update_preview=False) + self.main_window.preview_panel.update_view(self, update_preview=False) def select_inverse_action_callback(self): """Invert the selection of all visible items.""" @@ -836,7 +836,7 @@ def select_inverse_action_callback(self): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_view(self.selected, update_preview=False) + self.main_window.preview_panel.update_view(self, update_preview=False) def clear_select_action_callback(self): self.selected.clear() @@ -845,7 +845,7 @@ def clear_select_action_callback(self): item.thumb_button.set_selected(False) self.set_clipboard_menu_viability() - self.main_window.preview_panel.update_view(self.selected) + self.main_window.preview_panel.update_view(self) def add_tags_to_selected_callback(self, tag_ids: list[int]): self.lib.add_tags_to_entries(self.selected, tag_ids) @@ -905,7 +905,7 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = if deleted_count > 0: self.update_browsing_state() - self.main_window.preview_panel.update_view(self.selected) + self.main_window.preview_panel.update_view(self) if len(self.selected) <= 1 and deleted_count == 0: self.main_window.status_bar.showMessage(Translations["status.deleted_none"]) @@ -1230,7 +1230,7 @@ def paste_fields_action_callback(self): if TAG_FAVORITE in self.copy_buffer["tags"]: self.update_badges({BadgeType.FAVORITE: True}, origin_id=0, add_tags=False) else: - self.main_window.preview_panel.update_view(self.selected) + self.main_window.preview_panel.update_view(self) def toggle_item_selection(self, item_id: int, append: bool, bridge: bool): """Toggle the selection of an item in the Thumbnail Grid. @@ -1304,7 +1304,7 @@ def toggle_item_selection(self, item_id: int, append: bool, bridge: bool): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_view(self.selected) + self.main_window.preview_panel.update_view(self) def set_clipboard_menu_viability(self): if len(self.selected) == 1: @@ -1748,7 +1748,7 @@ def _init_library(self, path: Path, open_status: LibraryStatus): self.main_window.menu_bar.clear_thumb_cache_action.setEnabled(True) self.main_window.menu_bar.folders_to_tags_action.setEnabled(True) - self.main_window.preview_panel.update_view(self.selected) + self.main_window.preview_panel.update_view(self) # page (re)rendering, extract eventually self.update_browsing_state() diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py index cc64d4e30..e02c3db15 100644 --- a/src/tagstudio/qt/view/widgets/preview_panel_view.py +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -115,13 +115,23 @@ def __init__(self, library: Library, driver: "QtDriver"): def __connect_callbacks(self): self.__add_tag_button.clicked.connect(self._add_tag_button_callback) - def add_tag_button_enabled(self) -> bool: # needed for the tests - """Returns whether the 'Add Tag' Button is enabled.""" - return self.__add_tag_button.isEnabled() + def _add_tag_button_callback(self): + raise NotImplementedError() + + def thumb_media_player_stop(self): + self.__thumb.media_player.stop() + + @property + def add_buttons_enabled(self) -> bool: # needed for the tests + field = self.__add_field_button.isEnabled() + tag = self.__add_tag_button.isEnabled() + assert field == tag + return field - def add_field_button_enabled(self) -> bool: # needed for the tests - """Returns whether the 'Add Field' Button is enabled.""" - return self.__add_field_button.isEnabled() + @add_buttons_enabled.setter + def add_buttons_enabled(self, enabled: bool): + self.__add_field_button.setEnabled(enabled) + self.__add_tag_button.setEnabled(enabled) @property def _file_attributes_widget(self) -> FileAttributes: # needed for the tests @@ -133,13 +143,9 @@ def _field_containers_widget(self) -> FieldContainers: # needed for the tests """Getter for the field containers widget.""" return self.__fields # TODO: try to remove non-test uses of this - def thumb_media_player_stop(self): - self.__thumb.media_player.stop() - - def _add_tag_button_callback(self): - raise NotImplementedError() + # \/ to be refactored \/ # - def update_view(self, selected: list[int], update_preview: bool = True): + def _set_selection(self, selected: list[int], update_preview: bool = True): """Render the panel widgets with the newest data from the Library. Args: @@ -155,8 +161,7 @@ def update_view(self, selected: list[int], update_preview: bool = True): self.__file_attrs.update_date_label() self.__fields.hide_containers() - self.__add_tag_button.setEnabled(False) - self.__add_field_button.setEnabled(False) + self.add_buttons_enabled = False # One Item Selected elif len(selected) == 1: @@ -175,8 +180,7 @@ def update_view(self, selected: list[int], update_preview: bool = True): self.__update_add_tag_button(entry_id) self.__update_add_field_button(entry_id) - self.__add_tag_button.setEnabled(True) - self.__add_field_button.setEnabled(True) + self.add_buttons_enabled = True # Multiple Selected Items elif len(selected) > 1: @@ -188,8 +192,7 @@ def update_view(self, selected: list[int], update_preview: bool = True): self.__update_add_tag_button() self.__update_add_field_button() - self.__add_tag_button.setEnabled(True) - self.__add_field_button.setEnabled(True) + self.add_buttons_enabled = True except Exception as e: logger.error("[Preview Panel] Error updating selection", error=e) diff --git a/src/tagstudio/qt/widgets/tag_box.py b/src/tagstudio/qt/widgets/tag_box.py index 042b5ba98..1298a07f4 100644 --- a/src/tagstudio/qt/widgets/tag_box.py +++ b/src/tagstudio/qt/widgets/tag_box.py @@ -57,7 +57,7 @@ def set_tags(self, tags: typing.Iterable[Tag]): tag_widget.on_click.connect(lambda t=tag: self.edit_tag(t)) tag_widget.on_remove.connect( - lambda tag_id=tag.id, s=self.driver.selected: ( + lambda tag_id=tag.id, s=self.driver: ( self.remove_tag(tag_id), self.driver.main_window.preview_panel.update_view(s, update_preview=False), ) @@ -81,7 +81,7 @@ def edit_tag(self, tag: Tag): build_tag_panel, self.driver.lib.tag_display_name(tag.id), "Edit Tag", - done_callback=lambda s=self.driver.selected: self.driver.main_window.preview_panel.update_view( # noqa: E501 + done_callback=lambda s=self.driver: self.driver.main_window.preview_panel.update_view( # noqa: E501 s, update_preview=False ), has_save=True, diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 7a7429f0d..257d16f3f 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -7,7 +7,7 @@ def test_update_selection_empty(qt_driver, library): # Clear the library selection (selecting 1 then unselecting 1) qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(1, append=True, bridge=False) - panel.update_view(qt_driver.selected) + panel.update_view(qt_driver) # FieldContainer should hide all containers for container in panel._field_containers_widget.containers: @@ -19,7 +19,7 @@ def test_update_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_view(qt_driver.selected) + panel.update_view(qt_driver) # FieldContainer should show all applicable tags and field containers for container in panel._field_containers_widget.containers: @@ -34,7 +34,7 @@ def test_update_selection_multiple(qt_driver, library): # Select the multiple entries qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(2, append=True, bridge=False) - panel.update_view(qt_driver.selected) + panel.update_view(qt_driver) # FieldContainer should show mixed field editing for container in panel._field_containers_widget.containers: @@ -48,7 +48,7 @@ def test_add_tag_to_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_view(qt_driver.selected) + panel.update_view(qt_driver) # Add new tag panel._field_containers_widget.add_tags_to_selected(2000) @@ -65,7 +65,7 @@ def test_add_same_tag_to_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_view(qt_driver.selected) + panel.update_view(qt_driver) # Add an existing tag panel._field_containers_widget.add_tags_to_selected(1000) @@ -95,7 +95,7 @@ def test_add_tag_to_selection_multiple(qt_driver, library): # Select the multiple entries for i, e in enumerate(library.get_entries(with_joins=True), start=0): qt_driver.toggle_item_selection(e.id, append=(True if i == 0 else False), bridge=False) # noqa: SIM210 - panel.update_view(qt_driver.selected) + panel.update_view(qt_driver) # Add new tag panel._field_containers_widget.add_tags_to_selected(1000) @@ -123,7 +123,7 @@ def test_meta_tag_category(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_view(qt_driver.selected) + panel.update_view(qt_driver) # FieldContainer should hide all containers assert len(panel._field_containers_widget.containers) == 3 @@ -155,7 +155,7 @@ def test_custom_tag_category(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_view(qt_driver.selected) + panel.update_view(qt_driver) # FieldContainer should hide all containers assert len(panel._field_containers_widget.containers) == 3 diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index f39c4a60c..3ef75da20 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -59,7 +59,7 @@ def test_file_path_display( # Select 2 qt_driver.toggle_item_selection(2, append=False, bridge=False) - panel.update_view(qt_driver.selected) + panel.update_view(qt_driver) qt_driver.settings.show_filepath = filepath_option diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py index 88f655705..c3a62882e 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -7,11 +7,10 @@ def test_update_selection_empty(qt_driver, library): # Clear the library selection (selecting 1 then unselecting 1) qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(1, append=True, bridge=False) - panel.update_view(qt_driver.selected) + panel.update_view(qt_driver) # Panel should disable UI that allows for entry modification - assert not panel.add_tag_button_enabled() - assert not panel.add_field_button_enabled() + assert not panel.add_buttons_enabled def test_update_selection_single(qt_driver, library, entry_full): @@ -19,11 +18,10 @@ def test_update_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_view(qt_driver.selected) + panel.update_view(qt_driver) # Panel should enable UI that allows for entry modification - assert panel.add_tag_button_enabled() - assert panel.add_field_button_enabled() + assert panel.add_buttons_enabled def test_update_selection_multiple(qt_driver, library): @@ -32,8 +30,7 @@ def test_update_selection_multiple(qt_driver, library): # Select the multiple entries qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(2, append=True, bridge=False) - panel.update_view(qt_driver.selected) + panel.update_view(qt_driver) # Panel should enable UI that allows for entry modification - assert panel.add_tag_button_enabled() - assert panel.add_field_button_enabled() + assert panel.add_buttons_enabled From 47a365e418b5c43cee1cc624d9cb2a617355e0d7 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sat, 7 Jun 2025 23:25:08 +0200 Subject: [PATCH 05/31] refactor: further separation and lots of related changes --- .../widgets/preview_panel_controller.py | 38 +++++++---- src/tagstudio/qt/modals/build_tag.py | 9 +-- src/tagstudio/qt/modals/folders_to_tags.py | 4 +- src/tagstudio/qt/modals/mirror_entities.py | 2 +- src/tagstudio/qt/modals/settings_panel.py | 2 +- src/tagstudio/qt/modals/tag_search.py | 41 +++++++++--- src/tagstudio/qt/ts_qt.py | 40 +++++------- .../qt/view/widgets/preview_panel_view.py | 63 ++++++------------- src/tagstudio/qt/widgets/tag_box.py | 6 +- tests/qt/test_field_containers.py | 16 ++--- tests/qt/test_file_path_options.py | 2 +- tests/qt/test_preview_panel.py | 6 +- 12 files changed, 117 insertions(+), 112 deletions(-) diff --git a/src/tagstudio/qt/controller/widgets/preview_panel_controller.py b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py index 8ecb93aa4..9cd1a7ae4 100644 --- a/src/tagstudio/qt/controller/widgets/preview_panel_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py @@ -1,9 +1,10 @@ import typing +from warnings import catch_warnings from tagstudio.core.library.alchemy.library import Library -from tagstudio.qt.translations import Translations +from tagstudio.qt.modals.add_field import AddFieldModal +from tagstudio.qt.modals.tag_search import TagSearchModal from tagstudio.qt.view.widgets.preview_panel_view import PreviewPanelView -from tagstudio.qt.widgets.panel import PanelModal if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -13,18 +14,29 @@ class PreviewPanel(PreviewPanelView): def __init__(self, library: Library, driver: "QtDriver"): super().__init__(library, driver) - self.__add_tag_modal = PanelModal(self._tag_search_panel, Translations["tag.add.plural"]) - self.__add_tag_modal.setWindowTitle(Translations["tag.add.plural"]) + self.__add_field_modal = AddFieldModal(self.lib) + self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True) + + def _add_field_button_callback(self): + self.__add_field_modal.show() def _add_tag_button_callback(self): self.__add_tag_modal.show() - def update_view(self, driver: "QtDriver", update_preview=True): - """Render the panel widgets with the newest data from the Library. - - Args: - driver (QtDriver): List of the IDs of the selected entries. - update_preview (bool): Should the file preview be updated? - (Only works with one or more items selected) - """ - self._set_selection(driver.selected, update_preview) + def _set_selection_callback(self, selected: list[int]): + with catch_warnings(record=True): + self.__add_field_modal.done.disconnect() + self.__add_tag_modal.tsp.tag_chosen.disconnect() + + self.__add_field_modal.done.connect( + lambda f: ( + self._fields.add_field_to_selected(f), + (self._fields.update_from_entry(selected[0]) if len(selected) == 1 else ()), + ) + ) + self.__add_tag_modal.tsp.tag_chosen.connect( + lambda t: ( + self._fields.add_tags_to_selected(t), + (self._fields.update_from_entry(selected[0]) if len(selected) == 1 else ()), + ) + ) diff --git a/src/tagstudio/qt/modals/build_tag.py b/src/tagstudio/qt/modals/build_tag.py index 42250ec7f..d1a99b137 100644 --- a/src/tagstudio/qt/modals/build_tag.py +++ b/src/tagstudio/qt/modals/build_tag.py @@ -30,7 +30,7 @@ from tagstudio.core.library.alchemy.models import Tag, TagColorGroup from tagstudio.core.palette import ColorType, UiColor, get_tag_color, get_ui_color from tagstudio.qt.modals.tag_color_selection import TagColorSelection -from tagstudio.qt.modals.tag_search import TagSearchPanel +from tagstudio.qt.modals.tag_search import TagSearchModal from tagstudio.qt.translations import Translations from tagstudio.qt.widgets.panel import PanelModal, PanelWidget from tagstudio.qt.widgets.tag import ( @@ -166,11 +166,8 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: if tag is not None: exclude_ids.append(tag.id) - tsp = TagSearchPanel(self.lib, exclude_ids) - tsp.tag_chosen.connect(lambda x: self.add_parent_tag_callback(x)) - self.add_tag_modal = PanelModal(tsp) - self.add_tag_modal.setTitle(Translations["tag.parent_tags.add"]) - self.add_tag_modal.setWindowTitle(Translations["tag.parent_tags.add"]) + self.add_tag_modal = TagSearchModal(self.lib, exclude_ids) + self.add_tag_modal.tsp.tag_chosen.connect(lambda x: self.add_parent_tag_callback(x)) self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show) # Color ---------------------------------------------------------------- diff --git a/src/tagstudio/qt/modals/folders_to_tags.py b/src/tagstudio/qt/modals/folders_to_tags.py index 4e04746c7..d73dc9b38 100644 --- a/src/tagstudio/qt/modals/folders_to_tags.py +++ b/src/tagstudio/qt/modals/folders_to_tags.py @@ -227,7 +227,9 @@ def __init__(self, library: "Library", driver: "QtDriver"): def on_apply(self, event): folders_to_tags(self.library) self.close() - self.driver.main_window.preview_panel.update_view(self.driver, update_preview=False) + self.driver.main_window.preview_panel.set_selection( + self.driver.selected, update_preview=False + ) def on_open(self, event): for i in reversed(range(self.scroll_layout.count())): diff --git a/src/tagstudio/qt/modals/mirror_entities.py b/src/tagstudio/qt/modals/mirror_entities.py index f1e506f4d..3114d4297 100644 --- a/src/tagstudio/qt/modals/mirror_entities.py +++ b/src/tagstudio/qt/modals/mirror_entities.py @@ -87,7 +87,7 @@ def displayed_text(x): pw.from_iterable_function( self.mirror_entries_runnable, displayed_text, - lambda s=self.driver: self.driver.main_window.preview_panel.update_view(s), + lambda s=self.driver.selected: self.driver.main_window.preview_panel.set_selection(s), self.done.emit, ) diff --git a/src/tagstudio/qt/modals/settings_panel.py b/src/tagstudio/qt/modals/settings_panel.py index 18d276b4a..618c39620 100644 --- a/src/tagstudio/qt/modals/settings_panel.py +++ b/src/tagstudio/qt/modals/settings_panel.py @@ -230,7 +230,7 @@ def update_settings(self, driver: "QtDriver"): # Apply changes # Show File Path driver.update_recent_lib_menu() - driver.main_window.preview_panel.update_view(self.driver) + driver.main_window.preview_panel.set_selection(self.driver.selected) library_directory = driver.lib.library_dir if settings["show_filepath"] == ShowFilepathOption.SHOW_FULL_PATHS: display_path = library_directory or "" diff --git a/src/tagstudio/qt/modals/tag_search.py b/src/tagstudio/qt/modals/tag_search.py index fbb45523a..585e24481 100644 --- a/src/tagstudio/qt/modals/tag_search.py +++ b/src/tagstudio/qt/modals/tag_search.py @@ -4,7 +4,7 @@ import contextlib -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union from warnings import catch_warnings import structlog @@ -39,10 +39,33 @@ from tagstudio.qt.ts_qt import QtDriver +class TagSearchModal(PanelModal): + tsp: "TagSearchPanel" + + def __init__( + self, + library: Library, + exclude: list[int] | None = None, + is_tag_chooser: bool = True, + done_callback=None, + save_callback=None, + has_save=False, + ): + self.tsp = TagSearchPanel(library, exclude, is_tag_chooser) + super().__init__( + self.tsp, + Translations["tag.add.plural"], + Translations["tag.add.plural"], + done_callback, + save_callback, + has_save, + ) + + class TagSearchPanel(PanelWidget): tag_chosen = Signal(int) lib: Library - driver: "QtDriver" + driver: Union["QtDriver", None] is_initialized: bool = False first_tag_id: int | None = None is_tag_chooser: bool @@ -56,7 +79,7 @@ class TagSearchPanel(PanelWidget): def __init__( self, library: Library, - exclude: list[int] = None, + exclude: list[int] | None = None, is_tag_chooser: bool = True, ): super().__init__() @@ -194,6 +217,7 @@ def update_tags(self, query: str | None = None): create_button: QPushButton | None = None if self.create_button_in_layout and self.scroll_layout.count(): create_button = self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget() # type: ignore + assert create_button is not None create_button.deleteLater() self.create_button_in_layout = False @@ -264,7 +288,8 @@ def set_tag_widget(self, tag: Tag | None, index: int): self.scroll_layout.addWidget(new_tw) # Assign the tag to the widget at the given index. - tag_widget: TagWidget = self.scroll_layout.itemAt(index).widget() + tag_widget: TagWidget = self.scroll_layout.itemAt(index).widget() # type: ignore + assert isinstance(tag_widget, TagWidget) tag_widget.set_tag(tag) # Set tag widget viability and potentially return early @@ -288,11 +313,11 @@ def set_tag_widget(self, tag: Tag | None, index: int): tag_widget.on_remove.connect(lambda t=tag: self.delete_tag(t)) tag_widget.bg_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id)) - if self.driver: + if self.driver is not None: tag_widget.search_for_tag_action.triggered.connect( - lambda checked=False, tag_id=tag.id: ( - self.driver.main_window.search_field.setText(f"tag_id:{tag_id}"), - self.driver.update_browsing_state(BrowsingState.from_tag_id(tag_id)), + lambda checked=False, tag_id=tag.id, driver=self.driver: ( + driver.main_window.search_field.setText(f"tag_id:{tag_id}"), + driver.update_browsing_state(BrowsingState.from_tag_id(tag_id)), ) ) tag_widget.search_for_tag_action.setEnabled(True) diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 8dd27eba0..e24d818fc 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -84,7 +84,7 @@ from tagstudio.qt.modals.settings_panel import SettingsPanel from tagstudio.qt.modals.tag_color_manager import TagColorManager from tagstudio.qt.modals.tag_database import TagDatabasePanel -from tagstudio.qt.modals.tag_search import TagSearchPanel +from tagstudio.qt.modals.tag_search import TagSearchModal from tagstudio.qt.platform_strings import trash_term from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.splash import Splash @@ -174,7 +174,6 @@ class QtDriver(DriverMixin, QObject): tag_manager_panel: PanelModal | None = None color_manager_panel: TagColorManager | None = None file_extension_panel: PanelModal | None = None - tag_search_panel: TagSearchPanel | None = None add_tag_modal: PanelModal | None = None folders_modal: FoldersToTagsModal about_modal: AboutModal @@ -366,8 +365,8 @@ def start(self) -> None: # Initialize the Tag Manager panel self.tag_manager_panel = PanelModal( widget=TagDatabasePanel(self, self.lib), - done_callback=lambda: self.main_window.preview_panel.update_view( - self, update_preview=False + done_callback=lambda s=self.selected: self.main_window.preview_panel.set_selection( + s, update_preview=False ), has_save=False, ) @@ -378,17 +377,12 @@ def start(self) -> None: self.color_manager_panel = TagColorManager(self) # Initialize the Tag Search panel - self.tag_search_panel = TagSearchPanel(self.lib, is_tag_chooser=True) - self.tag_search_panel.set_driver(self) - self.add_tag_modal = PanelModal( - widget=self.tag_search_panel, - title=Translations["tag.add.plural"], - window_title=Translations["tag.add.plural"], - ) - self.tag_search_panel.tag_chosen.connect( - lambda t: ( + self.add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True) + self.add_tag_modal.tsp.set_driver(self) + self.add_tag_modal.tsp.tag_chosen.connect( + lambda t, s=self.selected: ( self.add_tags_to_selected_callback(t), - self.main_window.preview_panel.update_view(self), + self.main_window.preview_panel.set_selection(s), ) ) @@ -715,7 +709,7 @@ def close_library(self, is_shutdown: bool = False): self.cached_values.sync() # Reset library state - self.main_window.preview_panel.update_view(self) + self.main_window.preview_panel.set_selection(self.selected) self.main_window.search_field.setText("") scrollbar: QScrollArea = self.main_window.entry_scroll_area scrollbar.verticalScrollBar().setValue(0) @@ -739,7 +733,7 @@ def close_library(self, is_shutdown: bool = False): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_view(self) + self.main_window.preview_panel.set_selection(self.selected) self.main_window.toggle_landing_page(enabled=True) self.main_window.pagination.setHidden(True) try: @@ -817,7 +811,7 @@ def select_all_action_callback(self): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_view(self, update_preview=False) + self.main_window.preview_panel.set_selection(self.selected, update_preview=False) def select_inverse_action_callback(self): """Invert the selection of all visible items.""" @@ -836,7 +830,7 @@ def select_inverse_action_callback(self): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_view(self, update_preview=False) + self.main_window.preview_panel.set_selection(self.selected, update_preview=False) def clear_select_action_callback(self): self.selected.clear() @@ -845,7 +839,7 @@ def clear_select_action_callback(self): item.thumb_button.set_selected(False) self.set_clipboard_menu_viability() - self.main_window.preview_panel.update_view(self) + self.main_window.preview_panel.set_selection(self.selected) def add_tags_to_selected_callback(self, tag_ids: list[int]): self.lib.add_tags_to_entries(self.selected, tag_ids) @@ -905,7 +899,7 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = if deleted_count > 0: self.update_browsing_state() - self.main_window.preview_panel.update_view(self) + self.main_window.preview_panel.set_selection(self.selected) if len(self.selected) <= 1 and deleted_count == 0: self.main_window.status_bar.showMessage(Translations["status.deleted_none"]) @@ -1230,7 +1224,7 @@ def paste_fields_action_callback(self): if TAG_FAVORITE in self.copy_buffer["tags"]: self.update_badges({BadgeType.FAVORITE: True}, origin_id=0, add_tags=False) else: - self.main_window.preview_panel.update_view(self) + self.main_window.preview_panel.set_selection(self.selected) def toggle_item_selection(self, item_id: int, append: bool, bridge: bool): """Toggle the selection of an item in the Thumbnail Grid. @@ -1304,7 +1298,7 @@ def toggle_item_selection(self, item_id: int, append: bool, bridge: bool): self.set_clipboard_menu_viability() self.set_select_actions_visibility() - self.main_window.preview_panel.update_view(self) + self.main_window.preview_panel.set_selection(self.selected) def set_clipboard_menu_viability(self): if len(self.selected) == 1: @@ -1748,7 +1742,7 @@ def _init_library(self, path: Path, open_status: LibraryStatus): self.main_window.menu_bar.clear_thumb_cache_action.setEnabled(True) self.main_window.menu_bar.folders_to_tags_action.setEnabled(True) - self.main_window.preview_panel.update_view(self) + self.main_window.preview_panel.set_selection(self.selected) # page (re)rendering, extract eventually self.update_browsing_state() diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py index e02c3db15..64cce8c4f 100644 --- a/src/tagstudio/qt/view/widgets/preview_panel_view.py +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -1,7 +1,6 @@ import traceback import typing from pathlib import Path -from warnings import catch_warnings import structlog from PySide6.QtCore import Qt @@ -11,8 +10,6 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.palette import ColorType, UiColor, get_ui_color -from tagstudio.qt.modals.add_field import AddFieldModal -from tagstudio.qt.modals.tag_search import TagSearchPanel from tagstudio.qt.translations import Translations from tagstudio.qt.widgets.preview.field_containers import FieldContainers from tagstudio.qt.widgets.preview.file_attributes import FileAttributes @@ -57,11 +54,7 @@ def __init__(self, library: Library, driver: "QtDriver"): self.__thumb = PreviewThumb(self.lib, driver) self.__file_attrs = FileAttributes(self.lib, driver) - self.__fields = FieldContainers(self.lib, driver) - - self._tag_search_panel = TagSearchPanel(self.lib, is_tag_chooser=True) - - self.add_field_modal = AddFieldModal(self.lib) + self._fields = FieldContainers(self.lib, driver) preview_section = QWidget() preview_layout = QVBoxLayout(preview_section) @@ -99,7 +92,7 @@ def __init__(self, library: Library, driver: "QtDriver"): preview_layout.addWidget(self.__thumb) info_layout.addWidget(self.__file_attrs) - info_layout.addWidget(self.__fields) + info_layout.addWidget(self._fields) splitter.addWidget(preview_section) splitter.addWidget(info_section) @@ -113,11 +106,18 @@ def __init__(self, library: Library, driver: "QtDriver"): self.__connect_callbacks() def __connect_callbacks(self): + self.__add_field_button.clicked.connect(self._add_field_button_callback) self.__add_tag_button.clicked.connect(self._add_tag_button_callback) + def _add_field_button_callback(self): + raise NotImplementedError() + def _add_tag_button_callback(self): raise NotImplementedError() + def _set_selection_callback(self, selected: list[int]): + raise NotImplementedError() + def thumb_media_player_stop(self): self.__thumb.media_player.stop() @@ -141,11 +141,11 @@ def _file_attributes_widget(self) -> FileAttributes: # needed for the tests @property def _field_containers_widget(self) -> FieldContainers: # needed for the tests """Getter for the field containers widget.""" - return self.__fields # TODO: try to remove non-test uses of this + return self._fields # TODO: try to remove non-test uses of this - # \/ to be refactored \/ # + # TODO: \/ to be refactored \/ # - def _set_selection(self, selected: list[int], update_preview: bool = True): + def set_selection(self, selected: list[int], update_preview: bool = True): """Render the panel widgets with the newest data from the Library. Args: @@ -159,7 +159,7 @@ def _set_selection(self, selected: list[int], update_preview: bool = True): self.__thumb.hide_preview() self.__file_attrs.update_stats() self.__file_attrs.update_date_label() - self.__fields.hide_containers() + self._fields.hide_containers() self.add_buttons_enabled = False @@ -176,9 +176,9 @@ def _set_selection(self, selected: list[int], update_preview: bool = True): stats: dict = self.__thumb.update_preview(filepath) self.__file_attrs.update_stats(filepath, stats) self.__file_attrs.update_date_label(filepath) - self.__fields.update_from_entry(entry_id) - self.__update_add_tag_button(entry_id) - self.__update_add_field_button(entry_id) + self._fields.update_from_entry(entry_id) + + self._set_selection_callback(selected) self.add_buttons_enabled = True @@ -188,37 +188,12 @@ def _set_selection(self, selected: list[int], update_preview: bool = True): self.__thumb.hide_preview() # TODO: Render mixed selection self.__file_attrs.update_multi_selection(len(selected)) self.__file_attrs.update_date_label() - self.__fields.hide_containers() # TODO: Allow for mixed editing - self.__update_add_tag_button() - self.__update_add_field_button() + self._fields.hide_containers() # TODO: Allow for mixed editing + + self._set_selection_callback(selected) self.add_buttons_enabled = True except Exception as e: logger.error("[Preview Panel] Error updating selection", error=e) traceback.print_exc() - - def __update_add_field_button(self, entry_id: int | None = None): - with catch_warnings(record=True): - self.add_field_modal.done.disconnect() - self.__add_field_button.clicked.disconnect() - - self.add_field_modal.done.connect( - lambda f: ( - self.__fields.add_field_to_selected(f), - (self.__fields.update_from_entry(entry_id) if entry_id else ()), - ) - ) - self.__add_field_button.clicked.connect(self.add_field_modal.show) - - def __update_add_tag_button(self, entry_id: int | None = None): - with catch_warnings(record=True): - self._tag_search_panel.tag_chosen.disconnect() - self.__add_tag_button.clicked.disconnect() - - self._tag_search_panel.tag_chosen.connect( - lambda t: ( - self.__fields.add_tags_to_selected(t), - (self.__fields.update_from_entry(entry_id) if entry_id else ()), - ) - ) diff --git a/src/tagstudio/qt/widgets/tag_box.py b/src/tagstudio/qt/widgets/tag_box.py index 1298a07f4..2ecafb152 100644 --- a/src/tagstudio/qt/widgets/tag_box.py +++ b/src/tagstudio/qt/widgets/tag_box.py @@ -57,9 +57,9 @@ def set_tags(self, tags: typing.Iterable[Tag]): tag_widget.on_click.connect(lambda t=tag: self.edit_tag(t)) tag_widget.on_remove.connect( - lambda tag_id=tag.id, s=self.driver: ( + lambda tag_id=tag.id, s=self.driver.selected: ( self.remove_tag(tag_id), - self.driver.main_window.preview_panel.update_view(s, update_preview=False), + self.driver.main_window.preview_panel.set_selection(s, update_preview=False), ) ) tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t)) @@ -81,7 +81,7 @@ def edit_tag(self, tag: Tag): build_tag_panel, self.driver.lib.tag_display_name(tag.id), "Edit Tag", - done_callback=lambda s=self.driver: self.driver.main_window.preview_panel.update_view( # noqa: E501 + done_callback=lambda s=self.driver.selected: self.driver.main_window.preview_panel.set_selection( # noqa: E501 s, update_preview=False ), has_save=True, diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 257d16f3f..6a5ce08ca 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -7,7 +7,7 @@ def test_update_selection_empty(qt_driver, library): # Clear the library selection (selecting 1 then unselecting 1) qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(1, append=True, bridge=False) - panel.update_view(qt_driver) + panel.set_selection(qt_driver.selected) # FieldContainer should hide all containers for container in panel._field_containers_widget.containers: @@ -19,7 +19,7 @@ def test_update_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_view(qt_driver) + panel.set_selection(qt_driver.selected) # FieldContainer should show all applicable tags and field containers for container in panel._field_containers_widget.containers: @@ -34,7 +34,7 @@ def test_update_selection_multiple(qt_driver, library): # Select the multiple entries qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(2, append=True, bridge=False) - panel.update_view(qt_driver) + panel.set_selection(qt_driver.selected) # FieldContainer should show mixed field editing for container in panel._field_containers_widget.containers: @@ -48,7 +48,7 @@ def test_add_tag_to_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_view(qt_driver) + panel.set_selection(qt_driver.selected) # Add new tag panel._field_containers_widget.add_tags_to_selected(2000) @@ -65,7 +65,7 @@ def test_add_same_tag_to_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_view(qt_driver) + panel.set_selection(qt_driver.selected) # Add an existing tag panel._field_containers_widget.add_tags_to_selected(1000) @@ -95,7 +95,7 @@ def test_add_tag_to_selection_multiple(qt_driver, library): # Select the multiple entries for i, e in enumerate(library.get_entries(with_joins=True), start=0): qt_driver.toggle_item_selection(e.id, append=(True if i == 0 else False), bridge=False) # noqa: SIM210 - panel.update_view(qt_driver) + panel.set_selection(qt_driver.selected) # Add new tag panel._field_containers_widget.add_tags_to_selected(1000) @@ -123,7 +123,7 @@ def test_meta_tag_category(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_view(qt_driver) + panel.set_selection(qt_driver.selected) # FieldContainer should hide all containers assert len(panel._field_containers_widget.containers) == 3 @@ -155,7 +155,7 @@ def test_custom_tag_category(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_view(qt_driver) + panel.set_selection(qt_driver.selected) # FieldContainer should hide all containers assert len(panel._field_containers_widget.containers) == 3 diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index 3ef75da20..e940451a0 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -59,7 +59,7 @@ def test_file_path_display( # Select 2 qt_driver.toggle_item_selection(2, append=False, bridge=False) - panel.update_view(qt_driver) + panel.qt_driver(qt_driver.selected) qt_driver.settings.show_filepath = filepath_option diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py index c3a62882e..ab8a7845a 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -7,7 +7,7 @@ def test_update_selection_empty(qt_driver, library): # Clear the library selection (selecting 1 then unselecting 1) qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(1, append=True, bridge=False) - panel.update_view(qt_driver) + panel.set_selection(qt_driver.selected) # Panel should disable UI that allows for entry modification assert not panel.add_buttons_enabled @@ -18,7 +18,7 @@ def test_update_selection_single(qt_driver, library, entry_full): # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) - panel.update_view(qt_driver) + panel.set_selection(qt_driver.selected) # Panel should enable UI that allows for entry modification assert panel.add_buttons_enabled @@ -30,7 +30,7 @@ def test_update_selection_multiple(qt_driver, library): # Select the multiple entries qt_driver.toggle_item_selection(1, append=False, bridge=False) qt_driver.toggle_item_selection(2, append=True, bridge=False) - panel.update_view(qt_driver) + panel.set_selection(qt_driver.selected) # Panel should enable UI that allows for entry modification assert panel.add_buttons_enabled From f0793127f66f83c51ca80bd7c00c03263929e5de Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sat, 7 Jun 2025 23:41:01 +0200 Subject: [PATCH 06/31] refactor: remove last reference to a widget from controller --- .../widgets/preview_panel_controller.py | 16 +--- .../qt/view/widgets/preview_panel_view.py | 86 +++++++++++-------- 2 files changed, 55 insertions(+), 47 deletions(-) diff --git a/src/tagstudio/qt/controller/widgets/preview_panel_controller.py b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py index 9cd1a7ae4..f844c8382 100644 --- a/src/tagstudio/qt/controller/widgets/preview_panel_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py @@ -23,20 +23,10 @@ def _add_field_button_callback(self): def _add_tag_button_callback(self): self.__add_tag_modal.show() - def _set_selection_callback(self, selected: list[int]): + def _set_selection_callback(self): with catch_warnings(record=True): self.__add_field_modal.done.disconnect() self.__add_tag_modal.tsp.tag_chosen.disconnect() - self.__add_field_modal.done.connect( - lambda f: ( - self._fields.add_field_to_selected(f), - (self._fields.update_from_entry(selected[0]) if len(selected) == 1 else ()), - ) - ) - self.__add_tag_modal.tsp.tag_chosen.connect( - lambda t: ( - self._fields.add_tags_to_selected(t), - (self._fields.update_from_entry(selected[0]) if len(selected) == 1 else ()), - ) - ) + self.__add_field_modal.done.connect(self._add_field_to_selected) + self.__add_tag_modal.tsp.tag_chosen.connect(self._add_tag_to_selected) diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py index 64cce8c4f..b1f6b96f4 100644 --- a/src/tagstudio/qt/view/widgets/preview_panel_view.py +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -4,7 +4,14 @@ import structlog from PySide6.QtCore import Qt -from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSplitter, QVBoxLayout, QWidget +from PySide6.QtWidgets import ( + QHBoxLayout, + QListWidgetItem, + QPushButton, + QSplitter, + QVBoxLayout, + QWidget, +) from tagstudio.core.enums import Theme from tagstudio.core.library.alchemy.library import Library @@ -48,13 +55,15 @@ class PreviewPanelView(QWidget): lib: Library + _selected: list[int] + def __init__(self, library: Library, driver: "QtDriver"): super().__init__() self.lib = library self.__thumb = PreviewThumb(self.lib, driver) self.__file_attrs = FileAttributes(self.lib, driver) - self._fields = FieldContainers(self.lib, driver) + self.__fields = FieldContainers(self.lib, driver) preview_section = QWidget() preview_layout = QVBoxLayout(preview_section) @@ -92,7 +101,7 @@ def __init__(self, library: Library, driver: "QtDriver"): preview_layout.addWidget(self.__thumb) info_layout.addWidget(self.__file_attrs) - info_layout.addWidget(self._fields) + info_layout.addWidget(self.__fields) splitter.addWidget(preview_section) splitter.addWidget(info_section) @@ -115,35 +124,21 @@ def _add_field_button_callback(self): def _add_tag_button_callback(self): raise NotImplementedError() - def _set_selection_callback(self, selected: list[int]): + def _set_selection_callback(self): raise NotImplementedError() - def thumb_media_player_stop(self): - self.__thumb.media_player.stop() - - @property - def add_buttons_enabled(self) -> bool: # needed for the tests - field = self.__add_field_button.isEnabled() - tag = self.__add_tag_button.isEnabled() - assert field == tag - return field - - @add_buttons_enabled.setter - def add_buttons_enabled(self, enabled: bool): - self.__add_field_button.setEnabled(enabled) - self.__add_tag_button.setEnabled(enabled) - - @property - def _file_attributes_widget(self) -> FileAttributes: # needed for the tests - """Getter for the file attributes widget.""" - return self.__file_attrs + def _add_field_to_selected(self, field_list: list[QListWidgetItem]): + self.__fields.add_field_to_selected(field_list) + if len(self._selected) == 1: + self.__fields.update_from_entry(self._selected[0]) - @property - def _field_containers_widget(self) -> FieldContainers: # needed for the tests - """Getter for the field containers widget.""" - return self._fields # TODO: try to remove non-test uses of this + def _add_tag_to_selected(self, tag_id: int): + self.__fields.add_tags_to_selected(tag_id) + if len(self._selected) == 1: + self.__fields.update_from_entry(self._selected[0]) - # TODO: \/ to be refactored \/ # + def thumb_media_player_stop(self): + self.__thumb.media_player.stop() def set_selection(self, selected: list[int], update_preview: bool = True): """Render the panel widgets with the newest data from the Library. @@ -153,13 +148,14 @@ def set_selection(self, selected: list[int], update_preview: bool = True): update_preview (bool): Should the file preview be updated? (Only works with one or more items selected) """ - # No Items Selected + self._selected = selected try: + # No Items Selected if len(selected) == 0: self.__thumb.hide_preview() self.__file_attrs.update_stats() self.__file_attrs.update_date_label() - self._fields.hide_containers() + self.__fields.hide_containers() self.add_buttons_enabled = False @@ -176,9 +172,9 @@ def set_selection(self, selected: list[int], update_preview: bool = True): stats: dict = self.__thumb.update_preview(filepath) self.__file_attrs.update_stats(filepath, stats) self.__file_attrs.update_date_label(filepath) - self._fields.update_from_entry(entry_id) + self.__fields.update_from_entry(entry_id) - self._set_selection_callback(selected) + self._set_selection_callback() self.add_buttons_enabled = True @@ -188,12 +184,34 @@ def set_selection(self, selected: list[int], update_preview: bool = True): self.__thumb.hide_preview() # TODO: Render mixed selection self.__file_attrs.update_multi_selection(len(selected)) self.__file_attrs.update_date_label() - self._fields.hide_containers() # TODO: Allow for mixed editing + self.__fields.hide_containers() # TODO: Allow for mixed editing - self._set_selection_callback(selected) + self._set_selection_callback() self.add_buttons_enabled = True except Exception as e: logger.error("[Preview Panel] Error updating selection", error=e) traceback.print_exc() + + @property + def add_buttons_enabled(self) -> bool: # needed for the tests + field = self.__add_field_button.isEnabled() + tag = self.__add_tag_button.isEnabled() + assert field == tag + return field + + @add_buttons_enabled.setter + def add_buttons_enabled(self, enabled: bool): + self.__add_field_button.setEnabled(enabled) + self.__add_tag_button.setEnabled(enabled) + + @property + def _file_attributes_widget(self) -> FileAttributes: # needed for the tests + """Getter for the file attributes widget.""" + return self.__file_attrs + + @property + def _field_containers_widget(self) -> FieldContainers: # needed for the tests + """Getter for the field containers widget.""" + return self.__fields # TODO: try to remove non-test uses of this From 1e2864c80210c8dc132d632b3d8f1e6bfcf4c63d Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sat, 7 Jun 2025 23:42:10 +0200 Subject: [PATCH 07/31] refactor: address todo --- src/tagstudio/qt/modals/tag_color_manager.py | 4 ++-- src/tagstudio/qt/ts_qt.py | 4 ++-- .../qt/view/widgets/preview_panel_view.py | 4 ++-- src/tagstudio/qt/widgets/item_thumb.py | 2 +- tests/qt/test_field_containers.py | 20 +++++++++---------- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/tagstudio/qt/modals/tag_color_manager.py b/src/tagstudio/qt/modals/tag_color_manager.py index ed1b918ed..a37dc15ee 100644 --- a/src/tagstudio/qt/modals/tag_color_manager.py +++ b/src/tagstudio/qt/modals/tag_color_manager.py @@ -124,7 +124,7 @@ def setup_color_groups(self): self.setup_color_groups(), () if len(self.driver.selected) < 1 - else self.driver.main_window.preview_panel._field_containers_widget.update_from_entry( # noqa: E501 + else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501 self.driver.selected[0], update_badges=False ), ) @@ -141,7 +141,7 @@ def setup_color_groups(self): self.setup_color_groups(), () if len(self.driver.selected) < 1 - else self.driver.main_window.preview_panel._field_containers_widget.update_from_entry( # noqa: E501 + else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501 self.driver.selected[0], update_badges=False ), ), diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index e24d818fc..b3f4a3511 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -535,12 +535,12 @@ def create_about_modal(): self.main_window.search_field.textChanged.connect(self.update_completions_list) - self.main_window.preview_panel._field_containers_widget.archived_updated.connect( + self.main_window.preview_panel.field_containers_widget.archived_updated.connect( lambda hidden: self.update_badges( {BadgeType.ARCHIVED: hidden}, origin_id=0, add_tags=False ) ) - self.main_window.preview_panel._field_containers_widget.favorite_updated.connect( + self.main_window.preview_panel.field_containers_widget.favorite_updated.connect( lambda hidden: self.update_badges( {BadgeType.FAVORITE: hidden}, origin_id=0, add_tags=False ) diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py index b1f6b96f4..866e6790a 100644 --- a/src/tagstudio/qt/view/widgets/preview_panel_view.py +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -212,6 +212,6 @@ def _file_attributes_widget(self) -> FileAttributes: # needed for the tests return self.__file_attrs @property - def _field_containers_widget(self) -> FieldContainers: # needed for the tests + def field_containers_widget(self) -> FieldContainers: # needed for the tests """Getter for the field containers widget.""" - return self.__fields # TODO: try to remove non-test uses of this + return self.__fields diff --git a/src/tagstudio/qt/widgets/item_thumb.py b/src/tagstudio/qt/widgets/item_thumb.py index 342fa8e74..d5399ad48 100644 --- a/src/tagstudio/qt/widgets/item_thumb.py +++ b/src/tagstudio/qt/widgets/item_thumb.py @@ -500,7 +500,7 @@ def toggle_item_tag( ): if entry_id in self.driver.selected: if len(self.driver.selected) == 1: - self.driver.main_window.preview_panel._field_containers_widget.update_toggled_tag( + self.driver.main_window.preview_panel.field_containers_widget.update_toggled_tag( tag_id, toggle_value ) else: diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 6a5ce08ca..d78df9afb 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -10,7 +10,7 @@ def test_update_selection_empty(qt_driver, library): panel.set_selection(qt_driver.selected) # FieldContainer should hide all containers - for container in panel._field_containers_widget.containers: + for container in panel.field_containers_widget.containers: assert container.isHidden() @@ -22,7 +22,7 @@ def test_update_selection_single(qt_driver, library, entry_full): panel.set_selection(qt_driver.selected) # FieldContainer should show all applicable tags and field containers - for container in panel._field_containers_widget.containers: + for container in panel.field_containers_widget.containers: assert not container.isHidden() @@ -37,7 +37,7 @@ def test_update_selection_multiple(qt_driver, library): panel.set_selection(qt_driver.selected) # FieldContainer should show mixed field editing - for container in panel._field_containers_widget.containers: + for container in panel.field_containers_widget.containers: assert container.isHidden() @@ -51,7 +51,7 @@ def test_add_tag_to_selection_single(qt_driver, library, entry_full): panel.set_selection(qt_driver.selected) # Add new tag - panel._field_containers_widget.add_tags_to_selected(2000) + panel.field_containers_widget.add_tags_to_selected(2000) # Then reload entry refreshed_entry = next(library.get_entries(with_joins=True)) @@ -68,7 +68,7 @@ def test_add_same_tag_to_selection_single(qt_driver, library, entry_full): panel.set_selection(qt_driver.selected) # Add an existing tag - panel._field_containers_widget.add_tags_to_selected(1000) + panel.field_containers_widget.add_tags_to_selected(1000) # Then reload entry refreshed_entry = next(library.get_entries(with_joins=True)) @@ -98,7 +98,7 @@ def test_add_tag_to_selection_multiple(qt_driver, library): panel.set_selection(qt_driver.selected) # Add new tag - panel._field_containers_widget.add_tags_to_selected(1000) + panel.field_containers_widget.add_tags_to_selected(1000) # Then reload all entries and recheck the presence of tag 1000 refreshed_entries = library.get_entries(with_joins=True) @@ -126,8 +126,8 @@ def test_meta_tag_category(qt_driver, library, entry_full): panel.set_selection(qt_driver.selected) # FieldContainer should hide all containers - assert len(panel._field_containers_widget.containers) == 3 - for i, container in enumerate(panel._field_containers_widget.containers): + assert len(panel.field_containers_widget.containers) == 3 + for i, container in enumerate(panel.field_containers_widget.containers): match i: case 0: # Check if the container is the Meta Tags category @@ -158,8 +158,8 @@ def test_custom_tag_category(qt_driver, library, entry_full): panel.set_selection(qt_driver.selected) # FieldContainer should hide all containers - assert len(panel._field_containers_widget.containers) == 3 - for i, container in enumerate(panel._field_containers_widget.containers): + assert len(panel.field_containers_widget.containers) == 3 + for i, container in enumerate(panel.field_containers_widget.containers): match i: case 0: # Check if the container is the Meta Tags category From 892eeabc87696ccc18eff8fced6e50a772a4f09a Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sat, 7 Jun 2025 23:47:29 +0200 Subject: [PATCH 08/31] fix: failing tests and mypy compaint --- src/tagstudio/core/library/alchemy/library.py | 2 +- src/tagstudio/qt/modals/tag_search.py | 2 +- tests/qt/test_file_path_options.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index c01c20b76..0726f9457 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -674,7 +674,7 @@ def get_entry_full( start_time = time.time() entry = session.scalar(entry_stmt) if with_tags: - tags = set(session.scalars(tag_stmt)) # pyright: ignore [reportPossiblyUnboundVariable] + tags = set(session.scalars(tag_stmt)) # pyright: ignore[reportPossiblyUnboundVariable] end_time = time.time() logger.info( f"[Library] Time it took to get entry: " diff --git a/src/tagstudio/qt/modals/tag_search.py b/src/tagstudio/qt/modals/tag_search.py index 585e24481..3a02b6fb9 100644 --- a/src/tagstudio/qt/modals/tag_search.py +++ b/src/tagstudio/qt/modals/tag_search.py @@ -288,7 +288,7 @@ def set_tag_widget(self, tag: Tag | None, index: int): self.scroll_layout.addWidget(new_tw) # Assign the tag to the widget at the given index. - tag_widget: TagWidget = self.scroll_layout.itemAt(index).widget() # type: ignore + tag_widget: TagWidget = self.scroll_layout.itemAt(index).widget() # pyright: ignore[reportAssignmentType] assert isinstance(tag_widget, TagWidget) tag_widget.set_tag(tag) diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index e940451a0..095a4bf55 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -59,7 +59,7 @@ def test_file_path_display( # Select 2 qt_driver.toggle_item_selection(2, append=False, bridge=False) - panel.qt_driver(qt_driver.selected) + panel.set_selection(qt_driver.selected) qt_driver.settings.show_filepath = filepath_option From 60bd9ac57ff0becaab56d2548359929ee181e982 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sat, 7 Jun 2025 23:55:38 +0200 Subject: [PATCH 09/31] refactor: move control logic to controller --- .../widgets/preview_panel_controller.py | 12 +++++++++ .../qt/view/widgets/preview_panel_view.py | 25 ++++++------------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/tagstudio/qt/controller/widgets/preview_panel_controller.py b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py index f844c8382..8984dcef3 100644 --- a/src/tagstudio/qt/controller/widgets/preview_panel_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py @@ -1,6 +1,8 @@ import typing from warnings import catch_warnings +from PySide6.QtWidgets import QListWidgetItem + from tagstudio.core.library.alchemy.library import Library from tagstudio.qt.modals.add_field import AddFieldModal from tagstudio.qt.modals.tag_search import TagSearchModal @@ -30,3 +32,13 @@ def _set_selection_callback(self): self.__add_field_modal.done.connect(self._add_field_to_selected) self.__add_tag_modal.tsp.tag_chosen.connect(self._add_tag_to_selected) + + def _add_field_to_selected(self, field_list: list[QListWidgetItem]): + self._fields.add_field_to_selected(field_list) + if len(self._selected) == 1: + self._fields.update_from_entry(self._selected[0]) + + def _add_tag_to_selected(self, tag_id: int): + self._fields.add_tags_to_selected(tag_id) + if len(self._selected) == 1: + self._fields.update_from_entry(self._selected[0]) diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py index 866e6790a..c21ad1538 100644 --- a/src/tagstudio/qt/view/widgets/preview_panel_view.py +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -6,7 +6,6 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QHBoxLayout, - QListWidgetItem, QPushButton, QSplitter, QVBoxLayout, @@ -63,7 +62,9 @@ def __init__(self, library: Library, driver: "QtDriver"): self.__thumb = PreviewThumb(self.lib, driver) self.__file_attrs = FileAttributes(self.lib, driver) - self.__fields = FieldContainers(self.lib, driver) + self._fields = FieldContainers( + self.lib, driver + ) # TODO: this should be name mangled, but is still needed on the controller side atm preview_section = QWidget() preview_layout = QVBoxLayout(preview_section) @@ -101,7 +102,7 @@ def __init__(self, library: Library, driver: "QtDriver"): preview_layout.addWidget(self.__thumb) info_layout.addWidget(self.__file_attrs) - info_layout.addWidget(self.__fields) + info_layout.addWidget(self._fields) splitter.addWidget(preview_section) splitter.addWidget(info_section) @@ -127,16 +128,6 @@ def _add_tag_button_callback(self): def _set_selection_callback(self): raise NotImplementedError() - def _add_field_to_selected(self, field_list: list[QListWidgetItem]): - self.__fields.add_field_to_selected(field_list) - if len(self._selected) == 1: - self.__fields.update_from_entry(self._selected[0]) - - def _add_tag_to_selected(self, tag_id: int): - self.__fields.add_tags_to_selected(tag_id) - if len(self._selected) == 1: - self.__fields.update_from_entry(self._selected[0]) - def thumb_media_player_stop(self): self.__thumb.media_player.stop() @@ -155,7 +146,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True): self.__thumb.hide_preview() self.__file_attrs.update_stats() self.__file_attrs.update_date_label() - self.__fields.hide_containers() + self._fields.hide_containers() self.add_buttons_enabled = False @@ -172,7 +163,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True): stats: dict = self.__thumb.update_preview(filepath) self.__file_attrs.update_stats(filepath, stats) self.__file_attrs.update_date_label(filepath) - self.__fields.update_from_entry(entry_id) + self._fields.update_from_entry(entry_id) self._set_selection_callback() @@ -184,7 +175,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True): self.__thumb.hide_preview() # TODO: Render mixed selection self.__file_attrs.update_multi_selection(len(selected)) self.__file_attrs.update_date_label() - self.__fields.hide_containers() # TODO: Allow for mixed editing + self._fields.hide_containers() # TODO: Allow for mixed editing self._set_selection_callback() @@ -214,4 +205,4 @@ def _file_attributes_widget(self) -> FileAttributes: # needed for the tests @property def field_containers_widget(self) -> FieldContainers: # needed for the tests """Getter for the field containers widget.""" - return self.__fields + return self._fields From 3bdb69bdd28a729db7ed13091e5adff7ab4ded07 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 8 Jun 2025 15:03:46 +0200 Subject: [PATCH 10/31] refactor: more readable button style --- .../qt/view/widgets/preview_panel_view.py | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py index c21ad1538..9d9fa6bd9 100644 --- a/src/tagstudio/qt/view/widgets/preview_panel_view.py +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -26,29 +26,29 @@ logger = structlog.get_logger(__name__) -BUTTON_STYLE = ( - f"QPushButton{{" - f"background-color:{Theme.COLOR_BG.value};" - "border-radius:6px;" - "font-weight: 500;" - "text-align: center;" - f"}}" - f"QPushButton::hover{{" - f"background-color:{Theme.COLOR_HOVER.value};" - f"border-color:{get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QPushButton::pressed{{" - f"background-color:{Theme.COLOR_PRESSED.value};" - f"border-color:{get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)};" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QPushButton::disabled{{" - f"background-color:{Theme.COLOR_DISABLED_BG.value};" - f"}}" -) +BUTTON_STYLE = f""" + QPushButton{{ + background-color: {Theme.COLOR_BG.value}; + border-radius: 6px; + font-weight: 500; + text-align: center; + }} + QPushButton::hover{{ + background-color: {Theme.COLOR_HOVER.value}; + border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; + border-style: solid; + border-width: 2px; + }} + QPushButton::pressed{{ + background-color: {Theme.COLOR_PRESSED.value}; + border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)}; + border-style: solid; + border-width: 2px; + }} + QPushButton::disabled{{ + background-color: {Theme.COLOR_DISABLED_BG.value}; + }} +""" class PreviewPanelView(QWidget): From 512001df6bd1139bbad8c708e8130e6e029c6b9b Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 14:14:33 +0200 Subject: [PATCH 11/31] refactor: move existing code to view --- .../widgets/preview/preview_thumb_view.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/tagstudio/qt/{widgets/preview/preview_thumb.py => view/widgets/preview/preview_thumb_view.py} (99%) diff --git a/src/tagstudio/qt/widgets/preview/preview_thumb.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py similarity index 99% rename from src/tagstudio/qt/widgets/preview/preview_thumb.py rename to src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index 53b94a223..cae4aee92 100644 --- a/src/tagstudio/qt/widgets/preview/preview_thumb.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -35,7 +35,7 @@ Image.MAX_IMAGE_PIXELS = None -class PreviewThumb(QWidget): +class PreviewThumbView(QWidget): """The Preview Panel Widget.""" def __init__(self, library: Library, driver: "QtDriver") -> None: From e879374f397177c07b4b7a053d32b9e1df647993 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 18:51:50 +0200 Subject: [PATCH 12/31] refactor: move existing code to controller --- .../widgets/preview/preview_thumb_controller.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/tagstudio/qt/{widgets/preview/preview_thumb.py => controller/widgets/preview/preview_thumb_controller.py} (100%) diff --git a/src/tagstudio/qt/widgets/preview/preview_thumb.py b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py similarity index 100% rename from src/tagstudio/qt/widgets/preview/preview_thumb.py rename to src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py From 68145ec5a7ebfc0ca8b3093b9d01aee5d07a0682 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 14:15:42 +0200 Subject: [PATCH 13/31] fix: imports --- .../preview/preview_thumb_controller.py | 461 +----------------- .../qt/view/widgets/preview_panel_view.py | 2 +- 2 files changed, 4 insertions(+), 459 deletions(-) diff --git a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py index cae4aee92..dcf53bc5e 100644 --- a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py @@ -1,460 +1,5 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +from tagstudio.qt.view.widgets.preview.preview_thumb_view import PreviewThumbView -import io -import time -from pathlib import Path -from typing import TYPE_CHECKING, override -from warnings import catch_warnings -import cv2 -import rawpy -import structlog -from PIL import Image, UnidentifiedImageError -from PIL.Image import DecompressionBombError -from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt -from PySide6.QtGui import QAction, QMovie, QResizeEvent -from PySide6.QtWidgets import QHBoxLayout, QLabel, QStackedLayout, QWidget - -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.media_types import MediaCategories, MediaType -from tagstudio.qt.helpers.file_opener import FileOpenerHelper, open_file -from tagstudio.qt.helpers.file_tester import is_readable_video -from tagstudio.qt.helpers.qbutton_wrapper import QPushButtonWrapper -from tagstudio.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle -from tagstudio.qt.platform_strings import open_file_str, trash_term -from tagstudio.qt.translations import Translations -from tagstudio.qt.widgets.media_player import MediaPlayer -from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer - -if TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - -logger = structlog.get_logger(__name__) -Image.MAX_IMAGE_PIXELS = None - - -class PreviewThumbView(QWidget): - """The Preview Panel Widget.""" - - def __init__(self, library: Library, driver: "QtDriver") -> None: - 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.image_layout = QStackedLayout(self) - self.image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) - self.image_layout.setContentsMargins(0, 0, 0, 0) - - self.opener: FileOpenerHelper | None = None - self.open_file_action = QAction(Translations["file.open_file"], self) - self.open_explorer_action = QAction(open_file_str(), self) - self.delete_action = QAction( - Translations.format("trash.context.ambiguous", trash_term=trash_term()), - 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_img.addAction(self.delete_action) - - # In testing, it didn't seem possible to center the widgets directly - # on the QStackedLayout. Adding sublayouts allows us to center the widgets. - self.preview_img_page = QWidget() - self._stacked_page_setup(self.preview_img_page, self.preview_img) - - 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.addAction(self.delete_action) - self.gif_buffer: QBuffer = QBuffer() - - self.preview_gif_page = QWidget() - self._stacked_page_setup(self.preview_gif_page, self.preview_gif) - - self.media_player = MediaPlayer(driver) - self.media_player.addAction(self.open_file_action) - self.media_player.addAction(self.open_explorer_action) - self.media_player.addAction(self.delete_action) - - # Need to watch for this to resize the player appropriately. - self.media_player.player.hasVideoChanged.connect(self._has_video_changed) - - self.mp_max_size = QSize(*self.img_button_size) - - self.media_player_page = QWidget() - self._stacked_page_setup(self.media_player_page, self.media_player) - - self.thumb_renderer = ThumbRenderer(self.lib) - self.thumb_renderer.updated.connect( - lambda ts, i, s: ( - self.preview_img.setIcon(i), - self._set_mp_max_size(i.size()), - ) - ) - self.thumb_renderer.updated_ratio.connect( - lambda ratio: ( - self.set_image_ratio(ratio), - self.update_image_size( - ( - self.size().width(), - self.size().height(), - ), - ratio, - ), - ) - ) - - self.image_layout.addWidget(self.preview_img_page) - self.image_layout.addWidget(self.preview_gif_page) - self.image_layout.addWidget(self.media_player_page) - - self.setMinimumSize(*self.img_button_size) - - self.hide_preview() - - def _set_mp_max_size(self, size: QSize) -> None: - self.mp_max_size = size - - def _has_video_changed(self, video: bool) -> None: - self.update_image_size((self.size().width(), self.size().height())) - - def _stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: - layout = QHBoxLayout(page) - layout.addWidget(widget) - layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter) - layout.setContentsMargins(0, 0, 0, 0) - page.setLayout(layout) - - def set_image_ratio(self, ratio: float) -> None: - self.image_ratio = ratio - - def update_image_size(self, size: tuple[int, int], ratio: float | None = None) -> 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_gif.setMaximumSize(adj_size) - self.preview_gif.setMinimumSize(adj_size) - - if not self.media_player.player.hasVideo(): - # ensure we do not exceed the thumbnail size - mp_width = ( - adj_size.width() - if adj_size.width() < self.mp_max_size.width() - else self.mp_max_size.width() - ) - mp_height = ( - adj_size.height() - if adj_size.height() < self.mp_max_size.height() - else self.mp_max_size.height() - ) - mp_size = QSize(mp_width, mp_height) - self.media_player.setMinimumSize(mp_size) - self.media_player.setMaximumSize(mp_size) - else: - # have video, so just resize as normal - self.media_player.setMaximumSize(adj_size) - self.media_player.setMinimumSize(adj_size) - - proxy_style = RoundedPixmapStyle(radius=8) - self.preview_gif.setStyle(proxy_style) - self.media_player.setStyle(proxy_style) - m = self.preview_gif.movie() - if m: - m.setScaledSize(adj_size) - - def get_preview_size(self) -> tuple[int, int]: - return ( - self.size().width(), - self.size().height(), - ) - - def switch_preview(self, preview: str) -> None: - if preview in ["audio", "video"]: - self.media_player.show() - self.image_layout.setCurrentWidget(self.media_player_page) - else: - self.media_player.stop() - self.media_player.hide() - - if preview in ["image", "audio"]: - self.preview_img.show() - self.image_layout.setCurrentWidget( - self.preview_img_page if preview == "image" else self.media_player_page - ) - else: - self.preview_img.hide() - - if preview == "animated": - self.preview_gif.show() - self.image_layout.setCurrentWidget(self.preview_gif_page) - else: - if self.preview_gif.movie(): - self.preview_gif.movie().stop() - self.gif_buffer.close() - self.preview_gif.hide() - - def _display_fallback_image(self, filepath: Path, ext: str) -> dict[str, int]: - """Renders the given file as an image, no matter its media type. - - Useful for fallback scenarios. - """ - self.switch_preview("image") - self.thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, - ) - return self._update_image(filepath) - - def _update_image(self, filepath: Path) -> dict[str, int]: - """Update the static image preview from a filepath.""" - stats: dict[str, int] = {} - ext = filepath.suffix.lower() - self.switch_preview("image") - - image: Image.Image | None = None - - if MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True - ): - try: - 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 - except ( - rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] - rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] - FileNotFoundError, - ): - pass - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_RASTER_TYPES, mime_fallback=True - ): - try: - image = Image.open(str(filepath)) - stats["width"] = image.width - stats["height"] = image.height - except ( - DecompressionBombError, - FileNotFoundError, - NotImplementedError, - UnidentifiedImageError, - ) as e: - logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e) - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True - ): - pass - - return stats - - def _update_animation(self, filepath: Path, ext: str) -> dict[str, int]: - """Update the animated image preview from a filepath.""" - stats: dict[str, int] = {} - - # Ensure that any movie and buffer from previous animations are cleared. - if self.preview_gif.movie(): - self.preview_gif.movie().stop() - self.gif_buffer.close() - - try: - image: Image.Image = Image.open(filepath) - stats["width"] = image.width - stats["height"] = image.height - - self.update_image_size((image.width, image.height), image.width / image.height) - if ext == ".apng": - image_bytes_io = io.BytesIO() - image.save( - image_bytes_io, - "GIF", - lossless=True, - save_all=True, - loop=0, - disposal=2, - ) - image.close() - image_bytes_io.seek(0) - self.gif_buffer.setData(image_bytes_io.read()) - else: - image.close() - with open(filepath, "rb") as f: - self.gif_buffer.setData(f.read()) - movie = QMovie(self.gif_buffer, QByteArray()) - self.preview_gif.setMovie(movie) - - # If the animation only has 1 frame, display it like a normal image. - if movie.frameCount() <= 1: - self._display_fallback_image(filepath, ext) - return stats - - # The animation has more than 1 frame, continue displaying it as an animation - self.switch_preview("animated") - self.resizeEvent( - QResizeEvent( - QSize(stats["width"], stats["height"]), - QSize(stats["width"], stats["height"]), - ) - ) - movie.start() - - stats["duration"] = movie.frameCount() // 60 - except (UnidentifiedImageError, FileNotFoundError) as e: - logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) - return self._display_fallback_image(filepath, ext) - - return stats - - def _get_video_res(self, filepath: str) -> tuple[bool, QSize]: - video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) - return (success, QSize(image.width, image.height)) - - def _update_media(self, filepath: Path, type: MediaType) -> dict[str, int]: - stats: dict[str, int] = {} - - self.media_player.play(filepath) - - if type == MediaType.VIDEO: - try: - success, size = self._get_video_res(str(filepath)) - if success: - self.update_image_size( - (size.width(), size.height()), size.width() / size.height() - ) - self.resizeEvent( - QResizeEvent( - QSize(size.width(), size.height()), - QSize(size.width(), size.height()), - ) - ) - - stats["width"] = size.width() - stats["height"] = size.height() - - except cv2.error as e: - logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) - - self.switch_preview("video" if type == MediaType.VIDEO else "audio") - stats["duration"] = self.media_player.player.duration() * 1000 - return stats - - def update_preview(self, filepath: Path) -> dict[str, int]: - """Render a single file preview.""" - ext = filepath.suffix.lower() - stats: dict[str, int] = {} - - # Video - if MediaCategories.is_ext_in_category( - ext, MediaCategories.VIDEO_TYPES, mime_fallback=True - ) and is_readable_video(filepath): - stats = self._update_media(filepath, MediaType.VIDEO) - - # Audio - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.AUDIO_TYPES, mime_fallback=True - ): - self._update_image(filepath) - stats = self._update_media(filepath, MediaType.AUDIO) - self.thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, - ) - - # Animated Images - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True - ): - stats = self._update_animation(filepath, ext) - - # Other Types (Including Images) - else: - # TODO: Get thumb renderer to return this stuff to pass on - stats = self._update_image(filepath) - - self.thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, - ) - - with catch_warnings(record=True): - self.preview_img.clicked.disconnect() - self.preview_img.clicked.connect(lambda checked=False, path=filepath: open_file(path)) - self.preview_img.is_connected = 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) - - with catch_warnings(record=True): - self.delete_action.triggered.disconnect() - - self.delete_action.setText( - Translations.format("trash.context.singular", trash_term=trash_term()) - ) - self.delete_action.triggered.connect( - lambda checked=False, f=filepath: self.driver.delete_files_callback(f) - ) - self.delete_action.setEnabled(bool(filepath)) - - return stats - - def hide_preview(self) -> None: - """Completely hide the file preview.""" - self.switch_preview("") - - @override - def resizeEvent(self, event: QResizeEvent) -> None: - self.update_image_size((self.size().width(), self.size().height())) - return super().resizeEvent(event) +class PreviewThumb(PreviewThumbView): + pass diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py index 9d9fa6bd9..556f20e31 100644 --- a/src/tagstudio/qt/view/widgets/preview_panel_view.py +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -11,6 +11,7 @@ QVBoxLayout, QWidget, ) +from qt.controller.widgets.preview.preview_thumb_controller import PreviewThumb from tagstudio.core.enums import Theme from tagstudio.core.library.alchemy.library import Library @@ -19,7 +20,6 @@ from tagstudio.qt.translations import Translations from tagstudio.qt.widgets.preview.field_containers import FieldContainers from tagstudio.qt.widgets.preview.file_attributes import FileAttributes -from tagstudio.qt.widgets.preview.preview_thumb import PreviewThumb if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver From b0dc497b38c45254b5b35bf04a762b7ef7c32967 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 14:28:54 +0200 Subject: [PATCH 14/31] refactor: make methods private by default --- .../preview/preview_thumb_controller.py | 9 ++- .../widgets/preview/preview_thumb_view.py | 80 +++++++++---------- 2 files changed, 45 insertions(+), 44 deletions(-) diff --git a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py index dcf53bc5e..b35221358 100644 --- a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py @@ -1,5 +1,12 @@ +from typing import TYPE_CHECKING + +from tagstudio.core.library.alchemy.library import Library from tagstudio.qt.view.widgets.preview.preview_thumb_view import PreviewThumbView +if TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + class PreviewThumb(PreviewThumbView): - pass + def __init__(self, library: Library, driver: "QtDriver"): + super().__init__(library, driver) diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index cae4aee92..4d3213245 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -72,7 +72,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: # In testing, it didn't seem possible to center the widgets directly # on the QStackedLayout. Adding sublayouts allows us to center the widgets. self.preview_img_page = QWidget() - self._stacked_page_setup(self.preview_img_page, self.preview_img) + self.__stacked_page_setup(self.preview_img_page, self.preview_img) self.preview_gif = QLabel() self.preview_gif.setMinimumSize(*self.img_button_size) @@ -84,7 +84,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.gif_buffer: QBuffer = QBuffer() self.preview_gif_page = QWidget() - self._stacked_page_setup(self.preview_gif_page, self.preview_gif) + self.__stacked_page_setup(self.preview_gif_page, self.preview_gif) self.media_player = MediaPlayer(driver) self.media_player.addAction(self.open_file_action) @@ -92,24 +92,24 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.media_player.addAction(self.delete_action) # Need to watch for this to resize the player appropriately. - self.media_player.player.hasVideoChanged.connect(self._has_video_changed) + self.media_player.player.hasVideoChanged.connect(self.__has_video_changed) self.mp_max_size = QSize(*self.img_button_size) self.media_player_page = QWidget() - self._stacked_page_setup(self.media_player_page, self.media_player) + self.__stacked_page_setup(self.media_player_page, self.media_player) self.thumb_renderer = ThumbRenderer(self.lib) self.thumb_renderer.updated.connect( lambda ts, i, s: ( self.preview_img.setIcon(i), - self._set_mp_max_size(i.size()), + self.__set_mp_max_size(i.size()), ) ) self.thumb_renderer.updated_ratio.connect( lambda ratio: ( - self.set_image_ratio(ratio), - self.update_image_size( + self.__set_image_ratio(ratio), + self.__update_image_size( ( self.size().width(), self.size().height(), @@ -127,25 +127,25 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.hide_preview() - def _set_mp_max_size(self, size: QSize) -> None: + def __set_mp_max_size(self, size: QSize) -> None: self.mp_max_size = size - def _has_video_changed(self, video: bool) -> None: - self.update_image_size((self.size().width(), self.size().height())) + def __has_video_changed(self, video: bool) -> None: + self.__update_image_size((self.size().width(), self.size().height())) - def _stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: + def __stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: layout = QHBoxLayout(page) layout.addWidget(widget) layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter) layout.setContentsMargins(0, 0, 0, 0) page.setLayout(layout) - def set_image_ratio(self, ratio: float) -> None: + def __set_image_ratio(self, ratio: float) -> None: self.image_ratio = ratio - def update_image_size(self, size: tuple[int, int], ratio: float | None = None) -> None: + def __update_image_size(self, size: tuple[int, int], ratio: float | None = None) -> None: if ratio: - self.set_image_ratio(ratio) + self.__set_image_ratio(ratio) adj_width: float = size[0] adj_height: float = size[1] @@ -198,13 +198,7 @@ def update_image_size(self, size: tuple[int, int], ratio: float | None = None) - if m: m.setScaledSize(adj_size) - def get_preview_size(self) -> tuple[int, int]: - return ( - self.size().width(), - self.size().height(), - ) - - def switch_preview(self, preview: str) -> None: + def __switch_preview(self, preview: str) -> None: if preview in ["audio", "video"]: self.media_player.show() self.image_layout.setCurrentWidget(self.media_player_page) @@ -229,12 +223,12 @@ def switch_preview(self, preview: str) -> None: self.gif_buffer.close() self.preview_gif.hide() - def _display_fallback_image(self, filepath: Path, ext: str) -> dict[str, int]: + def __display_fallback_image(self, filepath: Path, ext: str) -> dict[str, int]: """Renders the given file as an image, no matter its media type. Useful for fallback scenarios. """ - self.switch_preview("image") + self.__switch_preview("image") self.thumb_renderer.render( time.time(), filepath, @@ -242,13 +236,13 @@ def _display_fallback_image(self, filepath: Path, ext: str) -> dict[str, int]: self.devicePixelRatio(), update_on_ratio_change=True, ) - return self._update_image(filepath) + return self.__display_image(filepath) - def _update_image(self, filepath: Path) -> dict[str, int]: + def __display_image(self, filepath: Path) -> dict[str, int]: """Update the static image preview from a filepath.""" stats: dict[str, int] = {} ext = filepath.suffix.lower() - self.switch_preview("image") + self.__switch_preview("image") image: Image.Image | None = None @@ -288,7 +282,7 @@ def _update_image(self, filepath: Path) -> dict[str, int]: return stats - def _update_animation(self, filepath: Path, ext: str) -> dict[str, int]: + def __update_animation(self, filepath: Path, ext: str) -> dict[str, int]: """Update the animated image preview from a filepath.""" stats: dict[str, int] = {} @@ -302,7 +296,7 @@ def _update_animation(self, filepath: Path, ext: str) -> dict[str, int]: stats["width"] = image.width stats["height"] = image.height - self.update_image_size((image.width, image.height), image.width / image.height) + self.__update_image_size((image.width, image.height), image.width / image.height) if ext == ".apng": image_bytes_io = io.BytesIO() image.save( @@ -325,11 +319,11 @@ def _update_animation(self, filepath: Path, ext: str) -> dict[str, int]: # If the animation only has 1 frame, display it like a normal image. if movie.frameCount() <= 1: - self._display_fallback_image(filepath, ext) + self.__display_fallback_image(filepath, ext) return stats # The animation has more than 1 frame, continue displaying it as an animation - self.switch_preview("animated") + self.__switch_preview("animated") self.resizeEvent( QResizeEvent( QSize(stats["width"], stats["height"]), @@ -341,27 +335,27 @@ def _update_animation(self, filepath: Path, ext: str) -> dict[str, int]: stats["duration"] = movie.frameCount() // 60 except (UnidentifiedImageError, FileNotFoundError) as e: logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) - return self._display_fallback_image(filepath, ext) + return self.__display_fallback_image(filepath, ext) return stats - def _get_video_res(self, filepath: str) -> tuple[bool, QSize]: + def __get_video_res(self, filepath: str) -> tuple[bool, QSize]: video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG) success, frame = video.read() frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) image = Image.fromarray(frame) return (success, QSize(image.width, image.height)) - def _update_media(self, filepath: Path, type: MediaType) -> dict[str, int]: + def __update_media(self, filepath: Path, type: MediaType) -> dict[str, int]: stats: dict[str, int] = {} self.media_player.play(filepath) if type == MediaType.VIDEO: try: - success, size = self._get_video_res(str(filepath)) + success, size = self.__get_video_res(str(filepath)) if success: - self.update_image_size( + self.__update_image_size( (size.width(), size.height()), size.width() / size.height() ) self.resizeEvent( @@ -377,7 +371,7 @@ def _update_media(self, filepath: Path, type: MediaType) -> dict[str, int]: except cv2.error as e: logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) - self.switch_preview("video" if type == MediaType.VIDEO else "audio") + self.__switch_preview("video" if type == MediaType.VIDEO else "audio") stats["duration"] = self.media_player.player.duration() * 1000 return stats @@ -390,14 +384,14 @@ def update_preview(self, filepath: Path) -> dict[str, int]: if MediaCategories.is_ext_in_category( ext, MediaCategories.VIDEO_TYPES, mime_fallback=True ) and is_readable_video(filepath): - stats = self._update_media(filepath, MediaType.VIDEO) + stats = self.__update_media(filepath, MediaType.VIDEO) # Audio elif MediaCategories.is_ext_in_category( ext, MediaCategories.AUDIO_TYPES, mime_fallback=True ): - self._update_image(filepath) - stats = self._update_media(filepath, MediaType.AUDIO) + self.__display_image(filepath) + stats = self.__update_media(filepath, MediaType.AUDIO) self.thumb_renderer.render( time.time(), filepath, @@ -410,12 +404,12 @@ def update_preview(self, filepath: Path) -> dict[str, int]: elif MediaCategories.is_ext_in_category( ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True ): - stats = self._update_animation(filepath, ext) + stats = self.__update_animation(filepath, ext) # Other Types (Including Images) else: # TODO: Get thumb renderer to return this stuff to pass on - stats = self._update_image(filepath) + stats = self.__display_image(filepath) self.thumb_renderer.render( time.time(), @@ -452,9 +446,9 @@ def update_preview(self, filepath: Path) -> dict[str, int]: def hide_preview(self) -> None: """Completely hide the file preview.""" - self.switch_preview("") + self.__switch_preview("") @override def resizeEvent(self, event: QResizeEvent) -> None: - self.update_image_size((self.size().width(), self.size().height())) + self.__update_image_size((self.size().width(), self.size().height())) return super().resizeEvent(event) From 1307a0960a94fa997228413c1b533a2624147bbc Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 14:47:15 +0200 Subject: [PATCH 15/31] refactor: privatise fields --- src/tagstudio/qt/ts_qt.py | 2 +- .../widgets/preview/preview_thumb_view.py | 226 +++++++++--------- .../qt/view/widgets/preview_panel_view.py | 7 +- 3 files changed, 120 insertions(+), 115 deletions(-) diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 1861df27e..2479a3959 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -879,7 +879,7 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = for i, tup in enumerate(pending): e_id, f = tup if (origin_path == f) or (not origin_path): - self.main_window.preview_panel.thumb_media_player_stop() + self.main_window.preview_panel.preview_thumb.media_player.stop() if delete_file(self.lib.library_dir / f): self.main_window.status_bar.showMessage( Translations.format( diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index 4d3213245..c8f5c72aa 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -41,72 +41,71 @@ class PreviewThumbView(QWidget): def __init__(self, library: Library, driver: "QtDriver") -> None: 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.__img_button_size: tuple[int, int] = (266, 266) + self.__image_ratio: float = 1.0 - self.image_layout = QStackedLayout(self) - self.image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) - self.image_layout.setContentsMargins(0, 0, 0, 0) + self.__image_layout = QStackedLayout(self) + self.__image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.__image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) + self.__image_layout.setContentsMargins(0, 0, 0, 0) - self.opener: FileOpenerHelper | None = None - self.open_file_action = QAction(Translations["file.open_file"], self) - self.open_explorer_action = QAction(open_file_str(), self) - self.delete_action = QAction( + self.__opener: FileOpenerHelper | None = None + self.__open_file_action = QAction(Translations["file.open_file"], self) + self.__open_explorer_action = QAction(open_file_str(), self) + self.__delete_action = QAction( Translations.format("trash.context.ambiguous", trash_term=trash_term()), 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_img.addAction(self.delete_action) + self.__button_wrapper = QPushButtonWrapper() + self.__button_wrapper.setMinimumSize(*self.__img_button_size) + self.__button_wrapper.setFlat(True) + self.__button_wrapper.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.__button_wrapper.addAction(self.__open_file_action) + self.__button_wrapper.addAction(self.__open_explorer_action) + self.__button_wrapper.addAction(self.__delete_action) # In testing, it didn't seem possible to center the widgets directly # on the QStackedLayout. Adding sublayouts allows us to center the widgets. self.preview_img_page = QWidget() - self.__stacked_page_setup(self.preview_img_page, self.preview_img) + self.__stacked_page_setup(self.preview_img_page, self.__button_wrapper) - 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.addAction(self.delete_action) - self.gif_buffer: QBuffer = QBuffer() + 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.addAction(self.__delete_action) + self.__gif_buffer: QBuffer = QBuffer() - self.preview_gif_page = QWidget() - self.__stacked_page_setup(self.preview_gif_page, self.preview_gif) + self.__preview_gif_page = QWidget() + self.__stacked_page_setup(self.__preview_gif_page, self.__preview_gif) - self.media_player = MediaPlayer(driver) - self.media_player.addAction(self.open_file_action) - self.media_player.addAction(self.open_explorer_action) - self.media_player.addAction(self.delete_action) + self.__media_player = MediaPlayer(driver) + self.__media_player.addAction(self.__open_file_action) + self.__media_player.addAction(self.__open_explorer_action) + self.__media_player.addAction(self.__delete_action) # Need to watch for this to resize the player appropriately. - self.media_player.player.hasVideoChanged.connect(self.__has_video_changed) + self.__media_player.player.hasVideoChanged.connect(self.__has_video_changed) - self.mp_max_size = QSize(*self.img_button_size) + self.__mp_max_size = QSize(*self.__img_button_size) - self.media_player_page = QWidget() - self.__stacked_page_setup(self.media_player_page, self.media_player) + self.__media_player_page = QWidget() + self.__stacked_page_setup(self.__media_player_page, self.__media_player) - self.thumb_renderer = ThumbRenderer(self.lib) - self.thumb_renderer.updated.connect( + self.__thumb_renderer = ThumbRenderer(self.lib) + self.__thumb_renderer.updated.connect( lambda ts, i, s: ( - self.preview_img.setIcon(i), + self.__button_wrapper.setIcon(i), self.__set_mp_max_size(i.size()), ) ) - self.thumb_renderer.updated_ratio.connect( + self.__thumb_renderer.updated_ratio.connect( lambda ratio: ( self.__set_image_ratio(ratio), self.__update_image_size( @@ -119,16 +118,16 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: ) ) - self.image_layout.addWidget(self.preview_img_page) - self.image_layout.addWidget(self.preview_gif_page) - self.image_layout.addWidget(self.media_player_page) + self.__image_layout.addWidget(self.preview_img_page) + self.__image_layout.addWidget(self.__preview_gif_page) + self.__image_layout.addWidget(self.__media_player_page) - self.setMinimumSize(*self.img_button_size) + self.setMinimumSize(*self.__img_button_size) self.hide_preview() def __set_mp_max_size(self, size: QSize) -> None: - self.mp_max_size = size + self.__mp_max_size = size def __has_video_changed(self, video: bool) -> None: self.__update_image_size((self.size().width(), self.size().height())) @@ -141,7 +140,7 @@ def __stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: page.setLayout(layout) def __set_image_ratio(self, ratio: float) -> None: - self.image_ratio = ratio + self.__image_ratio = ratio def __update_image_size(self, size: tuple[int, int], ratio: float | None = None) -> None: if ratio: @@ -150,11 +149,11 @@ def __update_image_size(self, size: tuple[int, int], ratio: float | None = None) adj_width: float = size[0] adj_height: float = size[1] # Landscape - if self.image_ratio > 1: - adj_height = size[0] * (1 / self.image_ratio) + 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 + 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) @@ -165,63 +164,63 @@ def __update_image_size(self, size: tuple[int, int], ratio: float | None = None) 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_gif.setMaximumSize(adj_size) - self.preview_gif.setMinimumSize(adj_size) + self.__img_button_size = (int(adj_width), int(adj_height)) + self.__button_wrapper.setMaximumSize(adj_size) + self.__button_wrapper.setIconSize(adj_size) + self.__preview_gif.setMaximumSize(adj_size) + self.__preview_gif.setMinimumSize(adj_size) - if not self.media_player.player.hasVideo(): + if not self.__media_player.player.hasVideo(): # ensure we do not exceed the thumbnail size mp_width = ( adj_size.width() - if adj_size.width() < self.mp_max_size.width() - else self.mp_max_size.width() + if adj_size.width() < self.__mp_max_size.width() + else self.__mp_max_size.width() ) mp_height = ( adj_size.height() - if adj_size.height() < self.mp_max_size.height() - else self.mp_max_size.height() + if adj_size.height() < self.__mp_max_size.height() + else self.__mp_max_size.height() ) mp_size = QSize(mp_width, mp_height) - self.media_player.setMinimumSize(mp_size) - self.media_player.setMaximumSize(mp_size) + self.__media_player.setMinimumSize(mp_size) + self.__media_player.setMaximumSize(mp_size) else: # have video, so just resize as normal - self.media_player.setMaximumSize(adj_size) - self.media_player.setMinimumSize(adj_size) + self.__media_player.setMaximumSize(adj_size) + self.__media_player.setMinimumSize(adj_size) proxy_style = RoundedPixmapStyle(radius=8) - self.preview_gif.setStyle(proxy_style) - self.media_player.setStyle(proxy_style) - m = self.preview_gif.movie() + self.__preview_gif.setStyle(proxy_style) + self.__media_player.setStyle(proxy_style) + m = self.__preview_gif.movie() if m: m.setScaledSize(adj_size) def __switch_preview(self, preview: str) -> None: if preview in ["audio", "video"]: - self.media_player.show() - self.image_layout.setCurrentWidget(self.media_player_page) + self.__media_player.show() + self.__image_layout.setCurrentWidget(self.__media_player_page) else: - self.media_player.stop() - self.media_player.hide() + self.__media_player.stop() + self.__media_player.hide() if preview in ["image", "audio"]: - self.preview_img.show() - self.image_layout.setCurrentWidget( - self.preview_img_page if preview == "image" else self.media_player_page + self.__button_wrapper.show() + self.__image_layout.setCurrentWidget( + self.preview_img_page if preview == "image" else self.__media_player_page ) else: - self.preview_img.hide() + self.__button_wrapper.hide() if preview == "animated": - self.preview_gif.show() - self.image_layout.setCurrentWidget(self.preview_gif_page) + self.__preview_gif.show() + self.__image_layout.setCurrentWidget(self.__preview_gif_page) else: - if self.preview_gif.movie(): - self.preview_gif.movie().stop() - self.gif_buffer.close() - self.preview_gif.hide() + if self.__preview_gif.movie(): + self.__preview_gif.movie().stop() + self.__gif_buffer.close() + self.__preview_gif.hide() def __display_fallback_image(self, filepath: Path, ext: str) -> dict[str, int]: """Renders the given file as an image, no matter its media type. @@ -229,7 +228,7 @@ def __display_fallback_image(self, filepath: Path, ext: str) -> dict[str, int]: Useful for fallback scenarios. """ self.__switch_preview("image") - self.thumb_renderer.render( + self.__thumb_renderer.render( time.time(), filepath, (512, 512), @@ -282,14 +281,14 @@ def __display_image(self, filepath: Path) -> dict[str, int]: return stats - def __update_animation(self, filepath: Path, ext: str) -> dict[str, int]: + def __display_animated_image(self, filepath: Path, ext: str) -> dict[str, int]: """Update the animated image preview from a filepath.""" stats: dict[str, int] = {} # Ensure that any movie and buffer from previous animations are cleared. - if self.preview_gif.movie(): - self.preview_gif.movie().stop() - self.gif_buffer.close() + if self.__preview_gif.movie(): + self.__preview_gif.movie().stop() + self.__gif_buffer.close() try: image: Image.Image = Image.open(filepath) @@ -309,13 +308,13 @@ def __update_animation(self, filepath: Path, ext: str) -> dict[str, int]: ) image.close() image_bytes_io.seek(0) - self.gif_buffer.setData(image_bytes_io.read()) + self.__gif_buffer.setData(image_bytes_io.read()) else: image.close() with open(filepath, "rb") as f: - self.gif_buffer.setData(f.read()) - movie = QMovie(self.gif_buffer, QByteArray()) - self.preview_gif.setMovie(movie) + self.__gif_buffer.setData(f.read()) + movie = QMovie(self.__gif_buffer, QByteArray()) + self.__preview_gif.setMovie(movie) # If the animation only has 1 frame, display it like a normal image. if movie.frameCount() <= 1: @@ -346,10 +345,11 @@ def __get_video_res(self, filepath: str) -> tuple[bool, QSize]: image = Image.fromarray(frame) return (success, QSize(image.width, image.height)) - def __update_media(self, filepath: Path, type: MediaType) -> dict[str, int]: + def __display_av_media(self, filepath: Path, type: MediaType) -> dict[str, int]: + """Display either audio or video.""" stats: dict[str, int] = {} - self.media_player.play(filepath) + self.__media_player.play(filepath) if type == MediaType.VIDEO: try: @@ -372,7 +372,7 @@ def __update_media(self, filepath: Path, type: MediaType) -> dict[str, int]: logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) self.__switch_preview("video" if type == MediaType.VIDEO else "audio") - stats["duration"] = self.media_player.player.duration() * 1000 + stats["duration"] = self.__media_player.player.duration() * 1000 return stats def update_preview(self, filepath: Path) -> dict[str, int]: @@ -384,15 +384,15 @@ def update_preview(self, filepath: Path) -> dict[str, int]: if MediaCategories.is_ext_in_category( ext, MediaCategories.VIDEO_TYPES, mime_fallback=True ) and is_readable_video(filepath): - stats = self.__update_media(filepath, MediaType.VIDEO) + stats = self.__display_av_media(filepath, MediaType.VIDEO) # Audio elif MediaCategories.is_ext_in_category( ext, MediaCategories.AUDIO_TYPES, mime_fallback=True ): self.__display_image(filepath) - stats = self.__update_media(filepath, MediaType.AUDIO) - self.thumb_renderer.render( + stats = self.__display_av_media(filepath, MediaType.AUDIO) + self.__thumb_renderer.render( time.time(), filepath, (512, 512), @@ -404,14 +404,14 @@ def update_preview(self, filepath: Path) -> dict[str, int]: elif MediaCategories.is_ext_in_category( ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True ): - stats = self.__update_animation(filepath, ext) + stats = self.__display_animated_image(filepath, ext) # Other Types (Including Images) else: # TODO: Get thumb renderer to return this stuff to pass on stats = self.__display_image(filepath) - self.thumb_renderer.render( + self.__thumb_renderer.render( time.time(), filepath, (512, 512), @@ -420,27 +420,27 @@ def update_preview(self, filepath: Path) -> dict[str, int]: ) with catch_warnings(record=True): - self.preview_img.clicked.disconnect() - self.preview_img.clicked.connect(lambda checked=False, path=filepath: open_file(path)) - self.preview_img.is_connected = True + self.__button_wrapper.clicked.disconnect() + self.__button_wrapper.clicked.connect(lambda checked=False, path=filepath: open_file(path)) + self.__button_wrapper.is_connected = True - self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) + self.__button_wrapper.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.__button_wrapper.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) + self.__opener = FileOpenerHelper(filepath) + self.__open_file_action.triggered.connect(self.__opener.open_file) + self.__open_explorer_action.triggered.connect(self.__opener.open_explorer) with catch_warnings(record=True): - self.delete_action.triggered.disconnect() + self.__delete_action.triggered.disconnect() - self.delete_action.setText( + self.__delete_action.setText( Translations.format("trash.context.singular", trash_term=trash_term()) ) - self.delete_action.triggered.connect( + self.__delete_action.triggered.connect( lambda checked=False, f=filepath: self.driver.delete_files_callback(f) ) - self.delete_action.setEnabled(bool(filepath)) + self.__delete_action.setEnabled(bool(filepath)) return stats @@ -452,3 +452,7 @@ def hide_preview(self) -> None: def resizeEvent(self, event: QResizeEvent) -> None: self.__update_image_size((self.size().width(), self.size().height())) return super().resizeEvent(event) + + @property + def media_player(self) -> MediaPlayer: + return self.__media_player diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py index 556f20e31..cf81458f9 100644 --- a/src/tagstudio/qt/view/widgets/preview_panel_view.py +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -128,9 +128,6 @@ def _add_tag_button_callback(self): def _set_selection_callback(self): raise NotImplementedError() - def thumb_media_player_stop(self): - self.__thumb.media_player.stop() - def set_selection(self, selected: list[int], update_preview: bool = True): """Render the panel widgets with the newest data from the Library. @@ -206,3 +203,7 @@ def _file_attributes_widget(self) -> FileAttributes: # needed for the tests def field_containers_widget(self) -> FieldContainers: # needed for the tests """Getter for the field containers widget.""" return self._fields + + @property + def preview_thumb(self) -> PreviewThumb: + return self.__thumb From b4d8a98f1bc433ea50b98ae7898a3a83eac083fa Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 15:06:33 +0200 Subject: [PATCH 16/31] refactor: reduce code duplication --- src/tagstudio/core/media_types.py | 32 +++++++++++-------- .../widgets/preview/preview_thumb_view.py | 26 +++++---------- 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index f13ce2a0d..ade4e8588 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -80,6 +80,21 @@ class MediaCategory: name: str is_iana: bool = False + def contains(self, ext: str, mime_fallback: bool = False) -> bool: + """Check if an extension is a member of this MediaCategory. + + Args: + ext (str): File extension with a leading "." and in all lowercase. + mime_fallback (bool): Flag to guess MIME type if no set matches are made. + """ + if ext in self.extensions: + return True + elif mime_fallback and self.is_iana: + mime_type: str | None = mimetypes.guess_type(Path("x" + ext), strict=False)[0] + if mime_type is not None and mime_type.startswith(self.media_type.value): + return True + return False + class MediaCategories: """Contain pre-made MediaCategory objects as well as methods to interact with them.""" @@ -635,16 +650,11 @@ def get_types(ext: str, mime_fallback: bool = False) -> set[MediaType]: mime_fallback (bool): Flag to guess MIME type if no set matches are made. """ media_types: set[MediaType] = set() - # mime_guess: bool = False for cat in MediaCategories.ALL_CATEGORIES: - if ext in cat.extensions: + if cat.contains(ext, mime_fallback): media_types.add(cat.media_type) - elif mime_fallback and cat.is_iana: - mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0] - if mime_type and mime_type.startswith(cat.media_type.value): - media_types.add(cat.media_type) - # mime_guess = True + return media_types @staticmethod @@ -656,10 +666,4 @@ def is_ext_in_category(ext: str, media_cat: MediaCategory, mime_fallback: bool = media_cat (MediaCategory): The MediaCategory to to check for extension membership. mime_fallback (bool): Flag to guess MIME type if no set matches are made. """ - if ext in media_cat.extensions: - return True - elif mime_fallback and media_cat.is_iana: - mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0] - if mime_type and mime_type.startswith(media_cat.media_type.value): - return True - return False + return media_cat.contains(ext, mime_fallback) diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index c8f5c72aa..89d8ceb27 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -245,9 +245,7 @@ def __display_image(self, filepath: Path) -> dict[str, int]: image: Image.Image | None = None - if MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True - ): + if MediaCategories.IMAGE_RAW_TYPES.contains(ext, mime_fallback=True): try: with rawpy.imread(str(filepath)) as raw: rgb = raw.postprocess() @@ -260,9 +258,7 @@ def __display_image(self, filepath: Path) -> dict[str, int]: FileNotFoundError, ): pass - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_RASTER_TYPES, mime_fallback=True - ): + elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True): try: image = Image.open(str(filepath)) stats["width"] = image.width @@ -274,9 +270,7 @@ def __display_image(self, filepath: Path) -> dict[str, int]: UnidentifiedImageError, ) as e: logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e) - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True - ): + elif MediaCategories.IMAGE_VECTOR_TYPES.contains(ext, mime_fallback=True): pass return stats @@ -381,15 +375,13 @@ def update_preview(self, filepath: Path) -> dict[str, int]: stats: dict[str, int] = {} # Video - if MediaCategories.is_ext_in_category( - ext, MediaCategories.VIDEO_TYPES, mime_fallback=True - ) and is_readable_video(filepath): + if MediaCategories.VIDEO_TYPES.contains(ext, mime_fallback=True) and is_readable_video( + filepath + ): stats = self.__display_av_media(filepath, MediaType.VIDEO) # Audio - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.AUDIO_TYPES, mime_fallback=True - ): + elif MediaCategories.AUDIO_TYPES.contains(ext, mime_fallback=True): self.__display_image(filepath) stats = self.__display_av_media(filepath, MediaType.AUDIO) self.__thumb_renderer.render( @@ -401,9 +393,7 @@ def update_preview(self, filepath: Path) -> dict[str, int]: ) # Animated Images - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True - ): + elif MediaCategories.IMAGE_ANIMATED_TYPES.contains(ext, mime_fallback=True): stats = self.__display_animated_image(filepath, ext) # Other Types (Including Images) From e4e78a87eb6f56f0b07f8c6549c259815dfbceee Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 15:30:37 +0200 Subject: [PATCH 17/31] refactor: consolidate and sort display methods --- .../widgets/preview/preview_thumb_view.py | 143 ++++++++---------- .../qt/view/widgets/preview_panel_view.py | 2 +- 2 files changed, 68 insertions(+), 77 deletions(-) diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index 89d8ceb27..8fa6e4cca 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -222,28 +222,10 @@ def __switch_preview(self, preview: str) -> None: self.__gif_buffer.close() self.__preview_gif.hide() - def __display_fallback_image(self, filepath: Path, ext: str) -> dict[str, int]: - """Renders the given file as an image, no matter its media type. - - Useful for fallback scenarios. - """ - self.__switch_preview("image") - self.__thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, - ) - return self.__display_image(filepath) - - def __display_image(self, filepath: Path) -> dict[str, int]: - """Update the static image preview from a filepath.""" + def __get_image_stats(self, filepath: Path) -> dict[str, int]: + """Get width and height of an image as dict.""" stats: dict[str, int] = {} ext = filepath.suffix.lower() - self.__switch_preview("image") - - image: Image.Image | None = None if MediaCategories.IMAGE_RAW_TYPES.contains(ext, mime_fallback=True): try: @@ -271,10 +253,56 @@ def __display_image(self, filepath: Path) -> dict[str, int]: ) as e: logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e) elif MediaCategories.IMAGE_VECTOR_TYPES.contains(ext, mime_fallback=True): - pass + pass # TODO return stats + def __get_video_res(self, filepath: str) -> tuple[bool, QSize]: + video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG) + success, frame = video.read() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + image = Image.fromarray(frame) + return (success, QSize(image.width, image.height)) + + def __display_av_media(self, filepath: Path, type: MediaType) -> dict[str, int]: + """Display either audio or video.""" + stats: dict[str, int] = {} + + self.__media_player.play(filepath) + + if type == MediaType.VIDEO: + try: + success, size = self.__get_video_res(str(filepath)) + if success: + self.__update_image_size( + (size.width(), size.height()), size.width() / size.height() + ) + self.resizeEvent( + QResizeEvent( + QSize(size.width(), size.height()), + QSize(size.width(), size.height()), + ) + ) + + stats["width"] = size.width() + stats["height"] = size.height() + + except cv2.error as e: + logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) + self.__switch_preview("video") + else: + self.__switch_preview("audio") + self.__thumb_renderer.render( + time.time(), + filepath, + (512, 512), + self.devicePixelRatio(), + update_on_ratio_change=True, + ) + + stats["duration"] = self.__media_player.player.duration() * 1000 + return stats + def __display_animated_image(self, filepath: Path, ext: str) -> dict[str, int]: """Update the animated image preview from a filepath.""" stats: dict[str, int] = {} @@ -312,7 +340,7 @@ def __display_animated_image(self, filepath: Path, ext: str) -> dict[str, int]: # If the animation only has 1 frame, display it like a normal image. if movie.frameCount() <= 1: - self.__display_fallback_image(filepath, ext) + self.__display_image(filepath) return stats # The animation has more than 1 frame, continue displaying it as an animation @@ -328,48 +356,28 @@ def __display_animated_image(self, filepath: Path, ext: str) -> dict[str, int]: stats["duration"] = movie.frameCount() // 60 except (UnidentifiedImageError, FileNotFoundError) as e: logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) - return self.__display_fallback_image(filepath, ext) + return self.__display_image(filepath) return stats - def __get_video_res(self, filepath: str) -> tuple[bool, QSize]: - video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) - return (success, QSize(image.width, image.height)) - - def __display_av_media(self, filepath: Path, type: MediaType) -> dict[str, int]: - """Display either audio or video.""" - stats: dict[str, int] = {} - - self.__media_player.play(filepath) - - if type == MediaType.VIDEO: - try: - success, size = self.__get_video_res(str(filepath)) - if success: - self.__update_image_size( - (size.width(), size.height()), size.width() / size.height() - ) - self.resizeEvent( - QResizeEvent( - QSize(size.width(), size.height()), - QSize(size.width(), size.height()), - ) - ) - - stats["width"] = size.width() - stats["height"] = size.height() - - except cv2.error as e: - logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) + def __display_image(self, filepath: Path) -> dict[str, int]: + """Renders the given file as an image, no matter its media type. - self.__switch_preview("video" if type == MediaType.VIDEO else "audio") - stats["duration"] = self.__media_player.player.duration() * 1000 - return stats + Useful for fallback scenarios. + """ + self.__switch_preview("image") + self.__thumb_renderer.render( + time.time(), + filepath, + (512, 512), + self.devicePixelRatio(), + update_on_ratio_change=True, + ) + return self.__get_image_stats( + filepath + ) # TODO: Get thumb renderer to return this stuff to pass on - def update_preview(self, filepath: Path) -> dict[str, int]: + def display_file(self, filepath: Path) -> dict[str, int]: """Render a single file preview.""" ext = filepath.suffix.lower() stats: dict[str, int] = {} @@ -382,15 +390,7 @@ def update_preview(self, filepath: Path) -> dict[str, int]: # Audio elif MediaCategories.AUDIO_TYPES.contains(ext, mime_fallback=True): - self.__display_image(filepath) stats = self.__display_av_media(filepath, MediaType.AUDIO) - self.__thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, - ) # Animated Images elif MediaCategories.IMAGE_ANIMATED_TYPES.contains(ext, mime_fallback=True): @@ -398,17 +398,8 @@ def update_preview(self, filepath: Path) -> dict[str, int]: # Other Types (Including Images) else: - # TODO: Get thumb renderer to return this stuff to pass on stats = self.__display_image(filepath) - self.__thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, - ) - with catch_warnings(record=True): self.__button_wrapper.clicked.disconnect() self.__button_wrapper.clicked.connect(lambda checked=False, path=filepath: open_file(path)) diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py index cf81458f9..3568935f0 100644 --- a/src/tagstudio/qt/view/widgets/preview_panel_view.py +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -157,7 +157,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True): filepath: Path = self.lib.library_dir / entry.path if update_preview: - stats: dict = self.__thumb.update_preview(filepath) + stats: dict = self.__thumb.display_file(filepath) self.__file_attrs.update_stats(filepath, stats) self.__file_attrs.update_date_label(filepath) self._fields.update_from_entry(entry_id) From 214e517219ff0bcf2d3e4971acb129e437b56b7b Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 15:35:31 +0200 Subject: [PATCH 18/31] refactor: remove needless setting of delete action text --- src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index 8fa6e4cca..a366d2fea 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -56,7 +56,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__open_file_action = QAction(Translations["file.open_file"], self) self.__open_explorer_action = QAction(open_file_str(), self) self.__delete_action = QAction( - Translations.format("trash.context.ambiguous", trash_term=trash_term()), + Translations.format("trash.context.singular", trash_term=trash_term()), self, ) @@ -415,9 +415,6 @@ def display_file(self, filepath: Path) -> dict[str, int]: with catch_warnings(record=True): self.__delete_action.triggered.disconnect() - self.__delete_action.setText( - Translations.format("trash.context.singular", trash_term=trash_term()) - ) self.__delete_action.triggered.connect( lambda checked=False, f=filepath: self.driver.delete_files_callback(f) ) From 1f533a2155f0d93bc966917239de957fbe42ba75 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 16:10:33 +0200 Subject: [PATCH 19/31] refactor: extract control logic from _display_file --- .../preview/preview_thumb_controller.py | 44 ++++++++++ src/tagstudio/qt/helpers/qbutton_wrapper.py | 3 + .../widgets/preview/preview_thumb_view.py | 82 ++++++++----------- 3 files changed, 81 insertions(+), 48 deletions(-) diff --git a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py index b35221358..4e56c2fd7 100644 --- a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py @@ -1,12 +1,56 @@ +from pathlib import Path from typing import TYPE_CHECKING +import structlog + from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.media_types import MediaCategories, MediaType +from tagstudio.qt.helpers.file_opener import open_file +from tagstudio.qt.helpers.file_tester import is_readable_video from tagstudio.qt.view.widgets.preview.preview_thumb_view import PreviewThumbView if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver +logger = structlog.get_logger(__name__) + class PreviewThumb(PreviewThumbView): + __current_file: Path + def __init__(self, library: Library, driver: "QtDriver"): super().__init__(library, driver) + + def display_file(self, filepath: Path) -> dict[str, int]: + """Render a single file preview.""" + self.__current_file = filepath + + ext = filepath.suffix.lower() + + # Video + if MediaCategories.VIDEO_TYPES.contains(ext, mime_fallback=True) and is_readable_video( + filepath + ): + return self._display_file(filepath, MediaType.VIDEO) + # Audio + elif MediaCategories.AUDIO_TYPES.contains(ext, mime_fallback=True): + return self._display_file(filepath, MediaType.AUDIO) + # Animated Images + elif MediaCategories.IMAGE_ANIMATED_TYPES.contains(ext, mime_fallback=True): + return self._display_file(filepath, MediaType.IMAGE_ANIMATED) + # Other Types (Including Images) + else: + return self._display_file(filepath, MediaType.IMAGE) + + def _open_file_action_callback(self): + open_file(self.__current_file) + + def _open_explorer_action_callback(self): + open_file(self.__current_file, file_manager=True) + + def _delete_action_callback(self): + if bool(self.__current_file): + self.driver.delete_files_callback(self.__current_file) + + def _button_wrapper_callback(self): + open_file(self.__current_file) diff --git a/src/tagstudio/qt/helpers/qbutton_wrapper.py b/src/tagstudio/qt/helpers/qbutton_wrapper.py index 96b9b6d32..6ff3a720f 100644 --- a/src/tagstudio/qt/helpers/qbutton_wrapper.py +++ b/src/tagstudio/qt/helpers/qbutton_wrapper.py @@ -13,6 +13,9 @@ class QPushButtonWrapper(QPushButton): the warning that is triggered by disconnecting a signal that is not currently connected. """ + # TODO: this is rarely set and used even more rarely -> remove once completely unneeded + is_connected: bool + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.is_connected = False diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index a366d2fea..49df21178 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -6,7 +6,6 @@ import time from pathlib import Path from typing import TYPE_CHECKING, override -from warnings import catch_warnings import cv2 import rawpy @@ -19,8 +18,6 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.media_types import MediaCategories, MediaType -from tagstudio.qt.helpers.file_opener import FileOpenerHelper, open_file -from tagstudio.qt.helpers.file_tester import is_readable_video from tagstudio.qt.helpers.qbutton_wrapper import QPushButtonWrapper from tagstudio.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle from tagstudio.qt.platform_strings import open_file_str, trash_term @@ -52,13 +49,15 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) self.__image_layout.setContentsMargins(0, 0, 0, 0) - self.__opener: FileOpenerHelper | None = None self.__open_file_action = QAction(Translations["file.open_file"], self) + self.__open_file_action.triggered.connect(self._open_file_action_callback) self.__open_explorer_action = QAction(open_file_str(), self) + self.__open_explorer_action.triggered.connect(self._open_explorer_action_callback) self.__delete_action = QAction( Translations.format("trash.context.singular", trash_term=trash_term()), self, ) + self.__delete_action.triggered.connect(self._delete_action_callback) self.__button_wrapper = QPushButtonWrapper() self.__button_wrapper.setMinimumSize(*self.__img_button_size) @@ -67,6 +66,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__button_wrapper.addAction(self.__open_file_action) self.__button_wrapper.addAction(self.__open_explorer_action) self.__button_wrapper.addAction(self.__delete_action) + self.__button_wrapper.clicked.connect(self._button_wrapper_callback) # In testing, it didn't seem possible to center the widgets directly # on the QStackedLayout. Adding sublayouts allows us to center the widgets. @@ -126,6 +126,18 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.hide_preview() + def _open_file_action_callback(self): + raise NotImplementedError + + def _open_explorer_action_callback(self): + raise NotImplementedError + + def _delete_action_callback(self): + raise NotImplementedError + + def _button_wrapper_callback(self): + raise NotImplementedError + def __set_mp_max_size(self, size: QSize) -> None: self.__mp_max_size = size @@ -264,7 +276,7 @@ def __get_video_res(self, filepath: str) -> tuple[bool, QSize]: image = Image.fromarray(frame) return (success, QSize(image.width, image.height)) - def __display_av_media(self, filepath: Path, type: MediaType) -> dict[str, int]: + def _display_av_media(self, filepath: Path, type: MediaType) -> dict[str, int]: """Display either audio or video.""" stats: dict[str, int] = {} @@ -303,8 +315,9 @@ def __display_av_media(self, filepath: Path, type: MediaType) -> dict[str, int]: stats["duration"] = self.__media_player.player.duration() * 1000 return stats - def __display_animated_image(self, filepath: Path, ext: str) -> dict[str, int]: + def _display_animated_image(self, filepath: Path) -> dict[str, int]: """Update the animated image preview from a filepath.""" + ext = filepath.suffix.lower() stats: dict[str, int] = {} # Ensure that any movie and buffer from previous animations are cleared. @@ -340,7 +353,7 @@ def __display_animated_image(self, filepath: Path, ext: str) -> dict[str, int]: # If the animation only has 1 frame, display it like a normal image. if movie.frameCount() <= 1: - self.__display_image(filepath) + self._display_image(filepath) return stats # The animation has more than 1 frame, continue displaying it as an animation @@ -356,11 +369,11 @@ def __display_animated_image(self, filepath: Path, ext: str) -> dict[str, int]: stats["duration"] = movie.frameCount() // 60 except (UnidentifiedImageError, FileNotFoundError) as e: logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) - return self.__display_image(filepath) + return self._display_image(filepath) return stats - def __display_image(self, filepath: Path) -> dict[str, int]: + def _display_image(self, filepath: Path) -> dict[str, int]: """Renders the given file as an image, no matter its media type. Useful for fallback scenarios. @@ -377,48 +390,21 @@ def __display_image(self, filepath: Path) -> dict[str, int]: filepath ) # TODO: Get thumb renderer to return this stuff to pass on - def display_file(self, filepath: Path) -> dict[str, int]: + def _display_file(self, filepath: Path, media_type: MediaType) -> dict[str, int]: """Render a single file preview.""" - ext = filepath.suffix.lower() stats: dict[str, int] = {} - # Video - if MediaCategories.VIDEO_TYPES.contains(ext, mime_fallback=True) and is_readable_video( - filepath - ): - stats = self.__display_av_media(filepath, MediaType.VIDEO) - - # Audio - elif MediaCategories.AUDIO_TYPES.contains(ext, mime_fallback=True): - stats = self.__display_av_media(filepath, MediaType.AUDIO) - - # Animated Images - elif MediaCategories.IMAGE_ANIMATED_TYPES.contains(ext, mime_fallback=True): - stats = self.__display_animated_image(filepath, ext) - - # Other Types (Including Images) - else: - stats = self.__display_image(filepath) - - with catch_warnings(record=True): - self.__button_wrapper.clicked.disconnect() - self.__button_wrapper.clicked.connect(lambda checked=False, path=filepath: open_file(path)) - self.__button_wrapper.is_connected = True - - self.__button_wrapper.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.__button_wrapper.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) - - with catch_warnings(record=True): - self.__delete_action.triggered.disconnect() - - self.__delete_action.triggered.connect( - lambda checked=False, f=filepath: self.driver.delete_files_callback(f) - ) - self.__delete_action.setEnabled(bool(filepath)) + match media_type: + case MediaType.VIDEO: + stats = self._display_av_media(filepath, MediaType.VIDEO) + case MediaType.AUDIO: + stats = self._display_av_media(filepath, MediaType.AUDIO) + case MediaType.IMAGE_ANIMATED: + stats = self._display_animated_image(filepath) + case MediaType.IMAGE: + stats = self._display_image(filepath) + case _: + raise NotImplementedError return stats From 88c7dbeb457676679865c1977a415b857a76e745 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 16:23:27 +0200 Subject: [PATCH 20/31] refactor: use MediaType for __switch_preview --- .../preview/preview_thumb_controller.py | 3 +++ .../widgets/preview_panel_controller.py | 3 +++ .../widgets/preview/preview_thumb_view.py | 20 +++++++++---------- .../qt/view/widgets/preview_panel_view.py | 3 +++ 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py index 4e56c2fd7..e68f986d0 100644 --- a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py @@ -1,3 +1,6 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + from pathlib import Path from typing import TYPE_CHECKING diff --git a/src/tagstudio/qt/controller/widgets/preview_panel_controller.py b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py index 8984dcef3..57a941f69 100644 --- a/src/tagstudio/qt/controller/widgets/preview_panel_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview_panel_controller.py @@ -1,3 +1,6 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + import typing from warnings import catch_warnings diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index 49df21178..5abc4c2cf 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -1,4 +1,3 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio @@ -209,23 +208,23 @@ def __update_image_size(self, size: tuple[int, int], ratio: float | None = None) if m: m.setScaledSize(adj_size) - def __switch_preview(self, preview: str) -> None: - if preview in ["audio", "video"]: + def __switch_preview(self, preview: MediaType | None) -> None: + if preview in [MediaType.AUDIO, MediaType.VIDEO]: self.__media_player.show() self.__image_layout.setCurrentWidget(self.__media_player_page) else: self.__media_player.stop() self.__media_player.hide() - if preview in ["image", "audio"]: + if preview in [MediaType.IMAGE, MediaType.AUDIO]: self.__button_wrapper.show() self.__image_layout.setCurrentWidget( - self.preview_img_page if preview == "image" else self.__media_player_page + self.preview_img_page if preview == MediaType.IMAGE else self.__media_player_page ) else: self.__button_wrapper.hide() - if preview == "animated": + if preview == MediaType.IMAGE_ANIMATED: self.__preview_gif.show() self.__image_layout.setCurrentWidget(self.__preview_gif_page) else: @@ -301,9 +300,7 @@ def _display_av_media(self, filepath: Path, type: MediaType) -> dict[str, int]: except cv2.error as e: logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) - self.__switch_preview("video") else: - self.__switch_preview("audio") self.__thumb_renderer.render( time.time(), filepath, @@ -312,6 +309,7 @@ def _display_av_media(self, filepath: Path, type: MediaType) -> dict[str, int]: update_on_ratio_change=True, ) + self.__switch_preview(type) stats["duration"] = self.__media_player.player.duration() * 1000 return stats @@ -357,7 +355,7 @@ def _display_animated_image(self, filepath: Path) -> dict[str, int]: return stats # The animation has more than 1 frame, continue displaying it as an animation - self.__switch_preview("animated") + self.__switch_preview(MediaType.IMAGE_ANIMATED) self.resizeEvent( QResizeEvent( QSize(stats["width"], stats["height"]), @@ -378,7 +376,7 @@ def _display_image(self, filepath: Path) -> dict[str, int]: Useful for fallback scenarios. """ - self.__switch_preview("image") + self.__switch_preview(MediaType.IMAGE) self.__thumb_renderer.render( time.time(), filepath, @@ -410,7 +408,7 @@ def _display_file(self, filepath: Path, media_type: MediaType) -> dict[str, int] def hide_preview(self) -> None: """Completely hide the file preview.""" - self.__switch_preview("") + self.__switch_preview(None) @override def resizeEvent(self, event: QResizeEvent) -> None: diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py index 3568935f0..4e13c9496 100644 --- a/src/tagstudio/qt/view/widgets/preview_panel_view.py +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -1,3 +1,6 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + import traceback import typing from pathlib import Path From 24bcb87fb718473c11fe0a1c1c1c8153f4e05685 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 16:26:36 +0200 Subject: [PATCH 21/31] fix: import in preview_panel_view.py --- src/tagstudio/qt/view/widgets/preview_panel_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py index 4e13c9496..13cef1fe4 100644 --- a/src/tagstudio/qt/view/widgets/preview_panel_view.py +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -14,12 +14,12 @@ QVBoxLayout, QWidget, ) -from qt.controller.widgets.preview.preview_thumb_controller import PreviewThumb from tagstudio.core.enums import Theme from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.palette import ColorType, UiColor, get_ui_color +from tagstudio.qt.controller.widgets.preview.preview_thumb_controller import PreviewThumb from tagstudio.qt.translations import Translations from tagstudio.qt.widgets.preview.field_containers import FieldContainers from tagstudio.qt.widgets.preview.file_attributes import FileAttributes From 9b84bf1f0e07b0a2877b4b163e4f4a29ef9e83eb Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 16:35:43 +0200 Subject: [PATCH 22/31] refactor: remove unnecessary wrapper on view side --- .../preview/preview_thumb_controller.py | 10 +++---- .../widgets/preview/preview_thumb_view.py | 26 +++++-------------- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py index e68f986d0..7684e4563 100644 --- a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py @@ -7,7 +7,7 @@ import structlog from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.media_types import MediaCategories, MediaType +from tagstudio.core.media_types import MediaCategories from tagstudio.qt.helpers.file_opener import open_file from tagstudio.qt.helpers.file_tester import is_readable_video from tagstudio.qt.view.widgets.preview.preview_thumb_view import PreviewThumbView @@ -34,16 +34,16 @@ def display_file(self, filepath: Path) -> dict[str, int]: if MediaCategories.VIDEO_TYPES.contains(ext, mime_fallback=True) and is_readable_video( filepath ): - return self._display_file(filepath, MediaType.VIDEO) + return self._display_video(filepath) # Audio elif MediaCategories.AUDIO_TYPES.contains(ext, mime_fallback=True): - return self._display_file(filepath, MediaType.AUDIO) + return self._display_audio(filepath) # Animated Images elif MediaCategories.IMAGE_ANIMATED_TYPES.contains(ext, mime_fallback=True): - return self._display_file(filepath, MediaType.IMAGE_ANIMATED) + return self._display_animated_image(filepath) # Other Types (Including Images) else: - return self._display_file(filepath, MediaType.IMAGE) + return self._display_image(filepath) def _open_file_action_callback(self): open_file(self.__current_file) diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index 5abc4c2cf..d00612d34 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -275,7 +275,7 @@ def __get_video_res(self, filepath: str) -> tuple[bool, QSize]: image = Image.fromarray(frame) return (success, QSize(image.width, image.height)) - def _display_av_media(self, filepath: Path, type: MediaType) -> dict[str, int]: + def __display_av_media(self, filepath: Path, type: MediaType) -> dict[str, int]: """Display either audio or video.""" stats: dict[str, int] = {} @@ -313,6 +313,12 @@ def _display_av_media(self, filepath: Path, type: MediaType) -> dict[str, int]: stats["duration"] = self.__media_player.player.duration() * 1000 return stats + def _display_video(self, filepath: Path) -> dict[str, int]: + return self.__display_av_media(filepath, MediaType.VIDEO) + + def _display_audio(self, filepath: Path) -> dict[str, int]: + return self.__display_av_media(filepath, MediaType.AUDIO) + def _display_animated_image(self, filepath: Path) -> dict[str, int]: """Update the animated image preview from a filepath.""" ext = filepath.suffix.lower() @@ -388,24 +394,6 @@ def _display_image(self, filepath: Path) -> dict[str, int]: filepath ) # TODO: Get thumb renderer to return this stuff to pass on - def _display_file(self, filepath: Path, media_type: MediaType) -> dict[str, int]: - """Render a single file preview.""" - stats: dict[str, int] = {} - - match media_type: - case MediaType.VIDEO: - stats = self._display_av_media(filepath, MediaType.VIDEO) - case MediaType.AUDIO: - stats = self._display_av_media(filepath, MediaType.AUDIO) - case MediaType.IMAGE_ANIMATED: - stats = self._display_animated_image(filepath) - case MediaType.IMAGE: - stats = self._display_image(filepath) - case _: - raise NotImplementedError - - return stats - def hide_preview(self) -> None: """Completely hide the file preview.""" self.__switch_preview(None) From 306c54cb7dd6aeb73d881f5d005bbf305a7d0635 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 16:41:39 +0200 Subject: [PATCH 23/31] refactor: move image data retrieval to control side --- .../preview/preview_thumb_controller.py | 47 +++++++++++++++- .../widgets/preview/preview_thumb_view.py | 53 ++----------------- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py index 7684e4563..e6c92f570 100644 --- a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py @@ -4,7 +4,10 @@ from pathlib import Path from typing import TYPE_CHECKING +import rawpy import structlog +from PIL import Image, UnidentifiedImageError +from PIL.Image import DecompressionBombError from tagstudio.core.library.alchemy.library import Library from tagstudio.core.media_types import MediaCategories @@ -24,6 +27,41 @@ class PreviewThumb(PreviewThumbView): def __init__(self, library: Library, driver: "QtDriver"): super().__init__(library, driver) + def __get_image_stats(self, filepath: Path) -> dict[str, int]: + """Get width and height of an image as dict.""" + stats: dict[str, int] = {} + ext = filepath.suffix.lower() + + if MediaCategories.IMAGE_RAW_TYPES.contains(ext, mime_fallback=True): + try: + 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 + except ( + rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] + rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] + FileNotFoundError, + ): + pass + elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True): + try: + image = Image.open(str(filepath)) + stats["width"] = image.width + stats["height"] = image.height + except ( + DecompressionBombError, + FileNotFoundError, + NotImplementedError, + UnidentifiedImageError, + ) as e: + logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e) + elif MediaCategories.IMAGE_VECTOR_TYPES.contains(ext, mime_fallback=True): + pass # TODO + + return stats + def display_file(self, filepath: Path) -> dict[str, int]: """Render a single file preview.""" self.__current_file = filepath @@ -40,10 +78,15 @@ def display_file(self, filepath: Path) -> dict[str, int]: return self._display_audio(filepath) # Animated Images elif MediaCategories.IMAGE_ANIMATED_TYPES.contains(ext, mime_fallback=True): - return self._display_animated_image(filepath) + if (stats := self._display_animated_image(filepath)) is not None: + return stats + else: + self._display_image(filepath) + return self.__get_image_stats(filepath) # Other Types (Including Images) else: - return self._display_image(filepath) + self._display_image(filepath) + return self.__get_image_stats(filepath) def _open_file_action_callback(self): open_file(self.__current_file) diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index d00612d34..fb7a22edc 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -7,16 +7,14 @@ from typing import TYPE_CHECKING, override import cv2 -import rawpy import structlog from PIL import Image, UnidentifiedImageError -from PIL.Image import DecompressionBombError from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt from PySide6.QtGui import QAction, QMovie, QResizeEvent from PySide6.QtWidgets import QHBoxLayout, QLabel, QStackedLayout, QWidget from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.media_types import MediaCategories, MediaType +from tagstudio.core.media_types import MediaType from tagstudio.qt.helpers.qbutton_wrapper import QPushButtonWrapper from tagstudio.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle from tagstudio.qt.platform_strings import open_file_str, trash_term @@ -233,41 +231,6 @@ def __switch_preview(self, preview: MediaType | None) -> None: self.__gif_buffer.close() self.__preview_gif.hide() - def __get_image_stats(self, filepath: Path) -> dict[str, int]: - """Get width and height of an image as dict.""" - stats: dict[str, int] = {} - ext = filepath.suffix.lower() - - if MediaCategories.IMAGE_RAW_TYPES.contains(ext, mime_fallback=True): - try: - 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 - except ( - rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] - rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] - FileNotFoundError, - ): - pass - elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True): - try: - image = Image.open(str(filepath)) - stats["width"] = image.width - stats["height"] = image.height - except ( - DecompressionBombError, - FileNotFoundError, - NotImplementedError, - UnidentifiedImageError, - ) as e: - logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e) - elif MediaCategories.IMAGE_VECTOR_TYPES.contains(ext, mime_fallback=True): - pass # TODO - - return stats - def __get_video_res(self, filepath: str) -> tuple[bool, QSize]: video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG) success, frame = video.read() @@ -319,7 +282,7 @@ def _display_video(self, filepath: Path) -> dict[str, int]: def _display_audio(self, filepath: Path) -> dict[str, int]: return self.__display_av_media(filepath, MediaType.AUDIO) - def _display_animated_image(self, filepath: Path) -> dict[str, int]: + def _display_animated_image(self, filepath: Path) -> dict[str, int] | None: """Update the animated image preview from a filepath.""" ext = filepath.suffix.lower() stats: dict[str, int] = {} @@ -373,15 +336,12 @@ def _display_animated_image(self, filepath: Path) -> dict[str, int]: stats["duration"] = movie.frameCount() // 60 except (UnidentifiedImageError, FileNotFoundError) as e: logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) - return self._display_image(filepath) + return None return stats - def _display_image(self, filepath: Path) -> dict[str, int]: - """Renders the given file as an image, no matter its media type. - - Useful for fallback scenarios. - """ + def _display_image(self, filepath: Path): + """Renders the given file as an image, no matter its media type.""" self.__switch_preview(MediaType.IMAGE) self.__thumb_renderer.render( time.time(), @@ -390,9 +350,6 @@ def _display_image(self, filepath: Path) -> dict[str, int]: self.devicePixelRatio(), update_on_ratio_change=True, ) - return self.__get_image_stats( - filepath - ) # TODO: Get thumb renderer to return this stuff to pass on def hide_preview(self) -> None: """Completely hide the file preview.""" From 88bf66cc046a3d364b0e54cb463fa1699a599b87 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 17:09:24 +0200 Subject: [PATCH 24/31] refactor: move audio / video specific code to the respective method --- .../widgets/preview/preview_thumb_view.py | 67 +++++++++---------- 1 file changed, 33 insertions(+), 34 deletions(-) diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index fb7a22edc..6d32bac62 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -238,49 +238,48 @@ def __get_video_res(self, filepath: str) -> tuple[bool, QSize]: image = Image.fromarray(frame) return (success, QSize(image.width, image.height)) - def __display_av_media(self, filepath: Path, type: MediaType) -> dict[str, int]: - """Display either audio or video.""" - stats: dict[str, int] = {} + def __update_media_player(self, filepath: Path) -> int: + """Display either audio or video. + Returns the duration of the audio / video. + """ self.__media_player.play(filepath) + return self.__media_player.player.duration() * 1000 - if type == MediaType.VIDEO: - try: - success, size = self.__get_video_res(str(filepath)) - if success: - self.__update_image_size( - (size.width(), size.height()), size.width() / size.height() - ) - self.resizeEvent( - QResizeEvent( - QSize(size.width(), size.height()), - QSize(size.width(), size.height()), - ) - ) + def _display_video(self, filepath: Path) -> dict[str, int]: + self.__switch_preview(MediaType.VIDEO) + stats = {"duration": self.__update_media_player(filepath)} - stats["width"] = size.width() - stats["height"] = size.height() + try: + success, size = self.__get_video_res(str(filepath)) + if success: + self.__update_image_size( + (size.width(), size.height()), size.width() / size.height() + ) + self.resizeEvent( + QResizeEvent( + QSize(size.width(), size.height()), + QSize(size.width(), size.height()), + ) + ) - except cv2.error as e: - logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) - else: - self.__thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, - ) + stats["width"] = size.width() + stats["height"] = size.height() + except cv2.error as e: + logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) - self.__switch_preview(type) - stats["duration"] = self.__media_player.player.duration() * 1000 return stats - def _display_video(self, filepath: Path) -> dict[str, int]: - return self.__display_av_media(filepath, MediaType.VIDEO) - def _display_audio(self, filepath: Path) -> dict[str, int]: - return self.__display_av_media(filepath, MediaType.AUDIO) + self.__switch_preview(MediaType.AUDIO) + self.__thumb_renderer.render( + time.time(), + filepath, + (512, 512), + self.devicePixelRatio(), + update_on_ratio_change=True, + ) + return {"duration": self.__update_media_player(filepath)} def _display_animated_image(self, filepath: Path) -> dict[str, int] | None: """Update the animated image preview from a filepath.""" From 6dbda487c8f849feed455ac19dd40a6415ca0a9f Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 17:45:05 +0200 Subject: [PATCH 25/31] refactor: remove superfluos methods --- src/tagstudio/qt/helpers/qbutton_wrapper.py | 1 - .../widgets/preview/preview_thumb_view.py | 65 ++++++++----------- 2 files changed, 27 insertions(+), 39 deletions(-) diff --git a/src/tagstudio/qt/helpers/qbutton_wrapper.py b/src/tagstudio/qt/helpers/qbutton_wrapper.py index 6ff3a720f..5539dc7cf 100644 --- a/src/tagstudio/qt/helpers/qbutton_wrapper.py +++ b/src/tagstudio/qt/helpers/qbutton_wrapper.py @@ -13,7 +13,6 @@ class QPushButtonWrapper(QPushButton): the warning that is triggered by disconnecting a signal that is not currently connected. """ - # TODO: this is rarely set and used even more rarely -> remove once completely unneeded is_connected: bool def __init__(self, *args, **kwargs): diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index 6d32bac62..97b76732c 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -10,12 +10,11 @@ import structlog from PIL import Image, UnidentifiedImageError from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt -from PySide6.QtGui import QAction, QMovie, QResizeEvent -from PySide6.QtWidgets import QHBoxLayout, QLabel, QStackedLayout, QWidget +from PySide6.QtGui import QAction, QMovie, QPixmap, QResizeEvent +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStackedLayout, QWidget from tagstudio.core.library.alchemy.library import Library from tagstudio.core.media_types import MediaType -from tagstudio.qt.helpers.qbutton_wrapper import QPushButtonWrapper from tagstudio.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle from tagstudio.qt.platform_strings import open_file_str, trash_term from tagstudio.qt.translations import Translations @@ -56,7 +55,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: ) self.__delete_action.triggered.connect(self._delete_action_callback) - self.__button_wrapper = QPushButtonWrapper() + self.__button_wrapper = QPushButton() self.__button_wrapper.setMinimumSize(*self.__img_button_size) self.__button_wrapper.setFlat(True) self.__button_wrapper.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) @@ -88,7 +87,9 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__media_player.addAction(self.__delete_action) # Need to watch for this to resize the player appropriately. - self.__media_player.player.hasVideoChanged.connect(self.__has_video_changed) + self.__media_player.player.hasVideoChanged.connect( + self.__media_player_video_changed_callback + ) self.__mp_max_size = QSize(*self.__img_button_size) @@ -96,24 +97,8 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__stacked_page_setup(self.__media_player_page, self.__media_player) self.__thumb_renderer = ThumbRenderer(self.lib) - self.__thumb_renderer.updated.connect( - lambda ts, i, s: ( - self.__button_wrapper.setIcon(i), - self.__set_mp_max_size(i.size()), - ) - ) - self.__thumb_renderer.updated_ratio.connect( - lambda ratio: ( - self.__set_image_ratio(ratio), - self.__update_image_size( - ( - self.size().width(), - self.size().height(), - ), - ratio, - ), - ) - ) + self.__thumb_renderer.updated.connect(self.__thumb_renderer_updated_callback) + self.__thumb_renderer.updated_ratio.connect(self.__thumb_renderer_updated_ratio_callback) self.__image_layout.addWidget(self.preview_img_page) self.__image_layout.addWidget(self.__preview_gif_page) @@ -135,12 +120,24 @@ def _delete_action_callback(self): def _button_wrapper_callback(self): raise NotImplementedError - def __set_mp_max_size(self, size: QSize) -> None: - self.__mp_max_size = size - - def __has_video_changed(self, video: bool) -> None: + def __media_player_video_changed_callback(self, video: bool) -> None: self.__update_image_size((self.size().width(), self.size().height())) + def __thumb_renderer_updated_callback( + self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path + ) -> None: + self.__button_wrapper.setIcon(img) + self.__mp_max_size = img.size() + + def __thumb_renderer_updated_ratio_callback(self, ratio: float) -> None: + self.__image_ratio = ratio + self.__update_image_size( + ( + self.size().width(), + self.size().height(), + ) + ) + def __stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: layout = QHBoxLayout(page) layout.addWidget(widget) @@ -148,13 +145,7 @@ def __stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: layout.setContentsMargins(0, 0, 0, 0) page.setLayout(layout) - def __set_image_ratio(self, ratio: float) -> None: - self.__image_ratio = ratio - - def __update_image_size(self, size: tuple[int, int], ratio: float | None = None) -> None: - if ratio: - self.__set_image_ratio(ratio) - + def __update_image_size(self, size: tuple[int, int]) -> None: adj_width: float = size[0] adj_height: float = size[1] # Landscape @@ -253,9 +244,7 @@ def _display_video(self, filepath: Path) -> dict[str, int]: try: success, size = self.__get_video_res(str(filepath)) if success: - self.__update_image_size( - (size.width(), size.height()), size.width() / size.height() - ) + self.__image_ratio = size.width() / size.height() self.resizeEvent( QResizeEvent( QSize(size.width(), size.height()), @@ -296,7 +285,7 @@ def _display_animated_image(self, filepath: Path) -> dict[str, int] | None: stats["width"] = image.width stats["height"] = image.height - self.__update_image_size((image.width, image.height), image.width / image.height) + self.__image_ratio = image.width / image.height if ext == ".apng": image_bytes_io = io.BytesIO() image.save( From 6a1be929676e0c63189d8987c990b8d89c61664b Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 17:52:56 +0200 Subject: [PATCH 26/31] refactor: this and that --- .../widgets/preview/preview_thumb_view.py | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index 97b76732c..86d131d33 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -31,60 +31,63 @@ class PreviewThumbView(QWidget): """The Preview Panel Widget.""" + __img_button_size: tuple[int, int] + __image_ratio: float + def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() self.lib = library self.driver: QtDriver = driver - self.__img_button_size: tuple[int, int] = (266, 266) - self.__image_ratio: float = 1.0 + self.__img_button_size = (266, 266) + self.__image_ratio = 1.0 self.__image_layout = QStackedLayout(self) self.__image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) self.__image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) self.__image_layout.setContentsMargins(0, 0, 0, 0) - self.__open_file_action = QAction(Translations["file.open_file"], self) - self.__open_file_action.triggered.connect(self._open_file_action_callback) - self.__open_explorer_action = QAction(open_file_str(), self) - self.__open_explorer_action.triggered.connect(self._open_explorer_action_callback) - self.__delete_action = QAction( + open_file_action = QAction(Translations["file.open_file"], self) + open_file_action.triggered.connect(self._open_file_action_callback) + open_explorer_action = QAction(open_file_str(), self) + open_explorer_action.triggered.connect(self._open_explorer_action_callback) + delete_action = QAction( Translations.format("trash.context.singular", trash_term=trash_term()), self, ) - self.__delete_action.triggered.connect(self._delete_action_callback) + delete_action.triggered.connect(self._delete_action_callback) self.__button_wrapper = QPushButton() self.__button_wrapper.setMinimumSize(*self.__img_button_size) self.__button_wrapper.setFlat(True) self.__button_wrapper.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.__button_wrapper.addAction(self.__open_file_action) - self.__button_wrapper.addAction(self.__open_explorer_action) - self.__button_wrapper.addAction(self.__delete_action) + self.__button_wrapper.addAction(open_file_action) + self.__button_wrapper.addAction(open_explorer_action) + self.__button_wrapper.addAction(delete_action) self.__button_wrapper.clicked.connect(self._button_wrapper_callback) # In testing, it didn't seem possible to center the widgets directly # on the QStackedLayout. Adding sublayouts allows us to center the widgets. - self.preview_img_page = QWidget() - self.__stacked_page_setup(self.preview_img_page, self.__button_wrapper) + self.__preview_img_page = QWidget() + self.__stacked_page_setup(self.__preview_img_page, self.__button_wrapper) 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.addAction(self.__delete_action) + self.__preview_gif.addAction(open_file_action) + self.__preview_gif.addAction(open_explorer_action) + self.__preview_gif.addAction(delete_action) self.__gif_buffer: QBuffer = QBuffer() self.__preview_gif_page = QWidget() self.__stacked_page_setup(self.__preview_gif_page, self.__preview_gif) self.__media_player = MediaPlayer(driver) - self.__media_player.addAction(self.__open_file_action) - self.__media_player.addAction(self.__open_explorer_action) - self.__media_player.addAction(self.__delete_action) + self.__media_player.addAction(open_file_action) + self.__media_player.addAction(open_explorer_action) + self.__media_player.addAction(delete_action) # Need to watch for this to resize the player appropriately. self.__media_player.player.hasVideoChanged.connect( @@ -100,7 +103,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__thumb_renderer.updated.connect(self.__thumb_renderer_updated_callback) self.__thumb_renderer.updated_ratio.connect(self.__thumb_renderer_updated_ratio_callback) - self.__image_layout.addWidget(self.preview_img_page) + self.__image_layout.addWidget(self.__preview_img_page) self.__image_layout.addWidget(self.__preview_gif_page) self.__image_layout.addWidget(self.__media_player_page) @@ -208,7 +211,7 @@ def __switch_preview(self, preview: MediaType | None) -> None: if preview in [MediaType.IMAGE, MediaType.AUDIO]: self.__button_wrapper.show() self.__image_layout.setCurrentWidget( - self.preview_img_page if preview == MediaType.IMAGE else self.__media_player_page + self.__preview_img_page if preview == MediaType.IMAGE else self.__media_player_page ) else: self.__button_wrapper.hide() @@ -222,6 +225,15 @@ def __switch_preview(self, preview: MediaType | None) -> None: self.__gif_buffer.close() self.__preview_gif.hide() + def __render_thumb(self, filepath: Path) -> None: + self.__thumb_renderer.render( + time.time(), + filepath, + (512, 512), + self.devicePixelRatio(), + update_on_ratio_change=True, + ) + def __get_video_res(self, filepath: str) -> tuple[bool, QSize]: video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG) success, frame = video.read() @@ -261,13 +273,7 @@ def _display_video(self, filepath: Path) -> dict[str, int]: def _display_audio(self, filepath: Path) -> dict[str, int]: self.__switch_preview(MediaType.AUDIO) - self.__thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, - ) + self.__render_thumb(filepath) return {"duration": self.__update_media_player(filepath)} def _display_animated_image(self, filepath: Path) -> dict[str, int] | None: @@ -331,13 +337,7 @@ def _display_animated_image(self, filepath: Path) -> dict[str, int] | None: def _display_image(self, filepath: Path): """Renders the given file as an image, no matter its media type.""" self.__switch_preview(MediaType.IMAGE) - self.__thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, - ) + self.__render_thumb(filepath) def hide_preview(self) -> None: """Completely hide the file preview.""" From 17e03f4a26045e42aeb09fffd8c4904dcba334c2 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 18:13:55 +0200 Subject: [PATCH 27/31] refactor: use proper type instead of dict for file stats --- .../preview/preview_thumb_controller.py | 15 ++++---- .../widgets/preview/preview_thumb_view.py | 35 ++++++++++--------- .../qt/view/widgets/preview_panel_view.py | 4 +-- .../qt/widgets/preview/file_attributes.py | 27 +++++++------- 4 files changed, 42 insertions(+), 39 deletions(-) diff --git a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py index e6c92f570..a2b00c9ba 100644 --- a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py @@ -14,6 +14,7 @@ from tagstudio.qt.helpers.file_opener import open_file from tagstudio.qt.helpers.file_tester import is_readable_video from tagstudio.qt.view.widgets.preview.preview_thumb_view import PreviewThumbView +from tagstudio.qt.widgets.preview.file_attributes import FileAttributeData if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -27,9 +28,9 @@ class PreviewThumb(PreviewThumbView): def __init__(self, library: Library, driver: "QtDriver"): super().__init__(library, driver) - def __get_image_stats(self, filepath: Path) -> dict[str, int]: + def __get_image_stats(self, filepath: Path) -> FileAttributeData: """Get width and height of an image as dict.""" - stats: dict[str, int] = {} + stats = FileAttributeData() ext = filepath.suffix.lower() if MediaCategories.IMAGE_RAW_TYPES.contains(ext, mime_fallback=True): @@ -37,8 +38,8 @@ def __get_image_stats(self, filepath: Path) -> dict[str, int]: 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 + stats.width = image.width + stats.height = image.height except ( rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] @@ -48,8 +49,8 @@ def __get_image_stats(self, filepath: Path) -> dict[str, int]: elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True): try: image = Image.open(str(filepath)) - stats["width"] = image.width - stats["height"] = image.height + stats.width = image.width + stats.height = image.height except ( DecompressionBombError, FileNotFoundError, @@ -62,7 +63,7 @@ def __get_image_stats(self, filepath: Path) -> dict[str, int]: return stats - def display_file(self, filepath: Path) -> dict[str, int]: + def display_file(self, filepath: Path) -> FileAttributeData: """Render a single file preview.""" self.__current_file = filepath diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index 86d131d33..b8ef87e92 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -19,6 +19,7 @@ from tagstudio.qt.platform_strings import open_file_str, trash_term from tagstudio.qt.translations import Translations from tagstudio.qt.widgets.media_player import MediaPlayer +from tagstudio.qt.widgets.preview.file_attributes import FileAttributeData from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer if TYPE_CHECKING: @@ -249,37 +250,37 @@ def __update_media_player(self, filepath: Path) -> int: self.__media_player.play(filepath) return self.__media_player.player.duration() * 1000 - def _display_video(self, filepath: Path) -> dict[str, int]: + def _display_video(self, filepath: Path) -> FileAttributeData: self.__switch_preview(MediaType.VIDEO) - stats = {"duration": self.__update_media_player(filepath)} + stats = FileAttributeData(duration=self.__update_media_player(filepath)) try: success, size = self.__get_video_res(str(filepath)) if success: - self.__image_ratio = size.width() / size.height() + stats.width = size.width() + stats.height = size.height() + + self.__image_ratio = stats.width / stats.height self.resizeEvent( QResizeEvent( - QSize(size.width(), size.height()), - QSize(size.width(), size.height()), + QSize(stats.width, stats.height), + QSize(stats.width, stats.height), ) ) - - stats["width"] = size.width() - stats["height"] = size.height() except cv2.error as e: logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) return stats - def _display_audio(self, filepath: Path) -> dict[str, int]: + def _display_audio(self, filepath: Path) -> FileAttributeData: self.__switch_preview(MediaType.AUDIO) self.__render_thumb(filepath) - return {"duration": self.__update_media_player(filepath)} + return FileAttributeData(duration=self.__update_media_player(filepath)) - def _display_animated_image(self, filepath: Path) -> dict[str, int] | None: + def _display_animated_image(self, filepath: Path) -> FileAttributeData | None: """Update the animated image preview from a filepath.""" ext = filepath.suffix.lower() - stats: dict[str, int] = {} + stats = FileAttributeData() # Ensure that any movie and buffer from previous animations are cleared. if self.__preview_gif.movie(): @@ -288,8 +289,8 @@ def _display_animated_image(self, filepath: Path) -> dict[str, int] | None: try: image: Image.Image = Image.open(filepath) - stats["width"] = image.width - stats["height"] = image.height + stats.width = image.width + stats.height = image.height self.__image_ratio = image.width / image.height if ext == ".apng": @@ -321,13 +322,13 @@ def _display_animated_image(self, filepath: Path) -> dict[str, int] | None: self.__switch_preview(MediaType.IMAGE_ANIMATED) self.resizeEvent( QResizeEvent( - QSize(stats["width"], stats["height"]), - QSize(stats["width"], stats["height"]), + QSize(stats.width, stats.height), + QSize(stats.width, stats.height), ) ) movie.start() - stats["duration"] = movie.frameCount() // 60 + stats.duration = movie.frameCount() // 60 except (UnidentifiedImageError, FileNotFoundError) as e: logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) return None diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py index 13cef1fe4..8bb819d44 100644 --- a/src/tagstudio/qt/view/widgets/preview_panel_view.py +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -22,7 +22,7 @@ from tagstudio.qt.controller.widgets.preview.preview_thumb_controller import PreviewThumb from tagstudio.qt.translations import Translations from tagstudio.qt.widgets.preview.field_containers import FieldContainers -from tagstudio.qt.widgets.preview.file_attributes import FileAttributes +from tagstudio.qt.widgets.preview.file_attributes import FileAttributeData, FileAttributes if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -160,7 +160,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True): filepath: Path = self.lib.library_dir / entry.path if update_preview: - stats: dict = self.__thumb.display_file(filepath) + stats: FileAttributeData = self.__thumb.display_file(filepath) self.__file_attrs.update_stats(filepath, stats) self.__file_attrs.update_date_label(filepath) self._fields.update_from_entry(entry_id) diff --git a/src/tagstudio/qt/widgets/preview/file_attributes.py b/src/tagstudio/qt/widgets/preview/file_attributes.py index 694ef91b1..842386dd6 100644 --- a/src/tagstudio/qt/widgets/preview/file_attributes.py +++ b/src/tagstudio/qt/widgets/preview/file_attributes.py @@ -6,6 +6,7 @@ import os import platform import typing +from dataclasses import dataclass from datetime import datetime as dt from datetime import timedelta from pathlib import Path @@ -29,6 +30,13 @@ logger = structlog.get_logger(__name__) +@dataclass +class FileAttributeData: + width: int | None = None + height: int | None = None + duration: int | None = None + + class FileAttributes(QWidget): """The Preview Panel Widget.""" @@ -131,10 +139,10 @@ def update_date_label(self, filepath: Path | None = None) -> None: self.date_created_label.setHidden(True) self.date_modified_label.setHidden(True) - def update_stats(self, filepath: Path | None = None, stats: dict | None = None): + def update_stats(self, filepath: Path | None = None, stats: FileAttributeData | None = None): """Render the panel widgets with the newest data from the Library.""" if not stats: - stats = {} + stats = FileAttributeData() if not filepath: self.layout().setSpacing(0) @@ -179,16 +187,9 @@ def update_stats(self, filepath: Path | None = None, stats: dict | None = None): stats_label_text = "" ext_display: str = "" file_size: str = "" - width_px_text: str = "" - height_px_text: str = "" - duration_text: str = "" font_family: str = "" # Attempt to populate the stat variables - width_px_text = stats.get("width", "") - height_px_text = stats.get("height", "") - duration_text = stats.get("duration", "") - font_family = stats.get("font_family", "") ext_display = ext.upper()[1:] or filepath.stem.upper() if filepath: try: @@ -217,14 +218,14 @@ def add_newline(stats_label_text: str) -> str: elif file_size: stats_label_text += file_size - if width_px_text and height_px_text: + if stats.width is not None and stats.height is not None: stats_label_text = add_newline(stats_label_text) - stats_label_text += f"{width_px_text} x {height_px_text} px" + stats_label_text += f"{stats.width} x {stats.height} px" - if duration_text: + if stats.duration is not None: stats_label_text = add_newline(stats_label_text) try: - dur_str = str(timedelta(seconds=float(duration_text)))[:-7] + dur_str = str(timedelta(seconds=float(stats.duration)))[:-7] if dur_str.startswith("0:"): dur_str = dur_str[2:] if dur_str.startswith("0"): From 95b5a4ae63835c11014c6aa1dda0b1667a6d9dee Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 18:35:42 +0200 Subject: [PATCH 28/31] refactor: extract gif parsing to controller --- .../preview/preview_thumb_controller.py | 34 ++++++++- .../widgets/preview/preview_thumb_view.py | 71 +++++++------------ 2 files changed, 57 insertions(+), 48 deletions(-) diff --git a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py index a2b00c9ba..7b52af3ff 100644 --- a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py @@ -1,6 +1,7 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import io from pathlib import Path from typing import TYPE_CHECKING @@ -63,6 +64,35 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: return stats + def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None: + """Loads an animated image and returns gif data and size, if successful.""" + ext = filepath.suffix.lower() + + try: + image: Image.Image = Image.open(filepath) + + if ext == ".apng": + image_bytes_io = io.BytesIO() + image.save( + image_bytes_io, + "GIF", + lossless=True, + save_all=True, + loop=0, + disposal=2, + ) + image.close() + image_bytes_io.seek(0) + return (image_bytes_io.read(), (image.width, image.height)) + else: + image.close() + with open(filepath, "rb") as f: + return (f.read(), (image.width, image.height)) + + except (UnidentifiedImageError, FileNotFoundError) as e: + logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) + return None + def display_file(self, filepath: Path) -> FileAttributeData: """Render a single file preview.""" self.__current_file = filepath @@ -79,7 +109,9 @@ def display_file(self, filepath: Path) -> FileAttributeData: return self._display_audio(filepath) # Animated Images elif MediaCategories.IMAGE_ANIMATED_TYPES.contains(ext, mime_fallback=True): - if (stats := self._display_animated_image(filepath)) is not None: + if (ret := self.__get_gif_data(filepath)) and ( + stats := self._display_gif(ret[0], ret[1]) + ) is not None: return stats else: self._display_image(filepath) diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index b8ef87e92..f355bb7cb 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -1,14 +1,13 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import io import time from pathlib import Path from typing import TYPE_CHECKING, override import cv2 import structlog -from PIL import Image, UnidentifiedImageError +from PIL import Image from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt from PySide6.QtGui import QAction, QMovie, QPixmap, QResizeEvent from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStackedLayout, QWidget @@ -277,9 +276,8 @@ def _display_audio(self, filepath: Path) -> FileAttributeData: self.__render_thumb(filepath) return FileAttributeData(duration=self.__update_media_player(filepath)) - def _display_animated_image(self, filepath: Path) -> FileAttributeData | None: + def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None: """Update the animated image preview from a filepath.""" - ext = filepath.suffix.lower() stats = FileAttributeData() # Ensure that any movie and buffer from previous animations are cleared. @@ -287,52 +285,31 @@ def _display_animated_image(self, filepath: Path) -> FileAttributeData | None: self.__preview_gif.movie().stop() self.__gif_buffer.close() - try: - image: Image.Image = Image.open(filepath) - stats.width = image.width - stats.height = image.height - - self.__image_ratio = image.width / image.height - if ext == ".apng": - image_bytes_io = io.BytesIO() - image.save( - image_bytes_io, - "GIF", - lossless=True, - save_all=True, - loop=0, - disposal=2, - ) - image.close() - image_bytes_io.seek(0) - self.__gif_buffer.setData(image_bytes_io.read()) - else: - image.close() - with open(filepath, "rb") as f: - self.__gif_buffer.setData(f.read()) - movie = QMovie(self.__gif_buffer, QByteArray()) - self.__preview_gif.setMovie(movie) - - # If the animation only has 1 frame, display it like a normal image. - if movie.frameCount() <= 1: - self._display_image(filepath) - return stats - - # The animation has more than 1 frame, continue displaying it as an animation - self.__switch_preview(MediaType.IMAGE_ANIMATED) - self.resizeEvent( - QResizeEvent( - QSize(stats.width, stats.height), - QSize(stats.width, stats.height), - ) - ) - movie.start() + stats.width = size[0] + stats.height = size[1] + + self.__image_ratio = stats.width / stats.height - stats.duration = movie.frameCount() // 60 - except (UnidentifiedImageError, FileNotFoundError) as e: - logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) + self.__gif_buffer.setData(gif_data) + movie = QMovie(self.__gif_buffer, QByteArray()) + self.__preview_gif.setMovie(movie) + + # If the animation only has 1 frame, it isn't animated and shouldn't be treated as such + if movie.frameCount() <= 1: return None + # The animation has more than 1 frame, continue displaying it as an animation + self.__switch_preview(MediaType.IMAGE_ANIMATED) + self.resizeEvent( + QResizeEvent( + QSize(stats.width, stats.height), + QSize(stats.width, stats.height), + ) + ) + movie.start() + + stats.duration = movie.frameCount() // 60 + return stats def _display_image(self, filepath: Path): From 2b6d5ae935870dcb517227077d63c3da9a8685dd Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 18:43:40 +0200 Subject: [PATCH 29/31] refactor: extract video size extraction to controller --- .../preview/preview_thumb_controller.py | 20 ++++++++++- .../widgets/preview/preview_thumb_view.py | 36 ++++++------------- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py index 7b52af3ff..a70381da0 100644 --- a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py @@ -5,10 +5,12 @@ from pathlib import Path from typing import TYPE_CHECKING +import cv2 import rawpy import structlog from PIL import Image, UnidentifiedImageError from PIL.Image import DecompressionBombError +from PySide6.QtCore import QSize from tagstudio.core.library.alchemy.library import Library from tagstudio.core.media_types import MediaCategories @@ -21,6 +23,7 @@ from tagstudio.qt.ts_qt import QtDriver logger = structlog.get_logger(__name__) +Image.MAX_IMAGE_PIXELS = None class PreviewThumb(PreviewThumbView): @@ -93,6 +96,13 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) return None + def __get_video_res(self, filepath: str) -> tuple[bool, QSize]: + video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG) + success, frame = video.read() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + image = Image.fromarray(frame) + return (success, QSize(image.width, image.height)) + def display_file(self, filepath: Path) -> FileAttributeData: """Render a single file preview.""" self.__current_file = filepath @@ -103,7 +113,15 @@ def display_file(self, filepath: Path) -> FileAttributeData: if MediaCategories.VIDEO_TYPES.contains(ext, mime_fallback=True) and is_readable_video( filepath ): - return self._display_video(filepath) + size: QSize | None = None + try: + success, size = self.__get_video_res(str(filepath)) + if not success: + size = None + except cv2.error as e: + logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) + + return self._display_video(filepath, size) # Audio elif MediaCategories.AUDIO_TYPES.contains(ext, mime_fallback=True): return self._display_audio(filepath) diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index f355bb7cb..98e615a27 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -5,9 +5,7 @@ from pathlib import Path from typing import TYPE_CHECKING, override -import cv2 import structlog -from PIL import Image from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt from PySide6.QtGui import QAction, QMovie, QPixmap, QResizeEvent from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStackedLayout, QWidget @@ -25,7 +23,6 @@ from tagstudio.qt.ts_qt import QtDriver logger = structlog.get_logger(__name__) -Image.MAX_IMAGE_PIXELS = None class PreviewThumbView(QWidget): @@ -234,13 +231,6 @@ def __render_thumb(self, filepath: Path) -> None: update_on_ratio_change=True, ) - def __get_video_res(self, filepath: str) -> tuple[bool, QSize]: - video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) - return (success, QSize(image.width, image.height)) - def __update_media_player(self, filepath: Path) -> int: """Display either audio or video. @@ -249,25 +239,21 @@ def __update_media_player(self, filepath: Path) -> int: self.__media_player.play(filepath) return self.__media_player.player.duration() * 1000 - def _display_video(self, filepath: Path) -> FileAttributeData: + def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData: self.__switch_preview(MediaType.VIDEO) stats = FileAttributeData(duration=self.__update_media_player(filepath)) - try: - success, size = self.__get_video_res(str(filepath)) - if success: - stats.width = size.width() - stats.height = size.height() - - self.__image_ratio = stats.width / stats.height - self.resizeEvent( - QResizeEvent( - QSize(stats.width, stats.height), - QSize(stats.width, stats.height), - ) + if size is not None: + stats.width = size.width() + stats.height = size.height() + + self.__image_ratio = stats.width / stats.height + self.resizeEvent( + QResizeEvent( + QSize(stats.width, stats.height), + QSize(stats.width, stats.height), ) - except cv2.error as e: - logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e) + ) return stats From c3cf7966f64bf37153c8c77a317cc267f2ac0daa Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 19:21:41 +0200 Subject: [PATCH 30/31] doc: add rule of thumb to Qt MVC Style Guide --- STYLE.md | 2 ++ .../controller/widgets/preview/preview_thumb_controller.py | 4 +++- src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py | 5 +---- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/STYLE.md b/STYLE.md index 29e770e1f..364b92031 100644 --- a/STYLE.md +++ b/STYLE.md @@ -78,5 +78,7 @@ Observe the following key aspects of this example: - Defines the interface the callbacks - Enforces that UI events be handled +> [!NOTE] +> A good (non-exhaustive) rule of thumb is: If it requires a non-UI import, then it doesn't belong in the `*_view.py` file. [^1]: For an explanation of the Model-View-Controller (MVC) Model, checkout this article: [MVC Framework Introduction](https://www.geeksforgeeks.org/mvc-framework-introduction/). diff --git a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py index a70381da0..17b1592f9 100644 --- a/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py +++ b/src/tagstudio/qt/controller/widgets/preview/preview_thumb_controller.py @@ -32,6 +32,8 @@ class PreviewThumb(PreviewThumbView): def __init__(self, library: Library, driver: "QtDriver"): super().__init__(library, driver) + self.__driver: QtDriver = driver + def __get_image_stats(self, filepath: Path) -> FileAttributeData: """Get width and height of an image as dict.""" stats = FileAttributeData() @@ -147,7 +149,7 @@ def _open_explorer_action_callback(self): def _delete_action_callback(self): if bool(self.__current_file): - self.driver.delete_files_callback(self.__current_file) + self.__driver.delete_files_callback(self.__current_file) def _button_wrapper_callback(self): open_file(self.__current_file) diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index 98e615a27..ea2739f08 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -34,9 +34,6 @@ class PreviewThumbView(QWidget): def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() - self.lib = library - self.driver: QtDriver = driver - self.__img_button_size = (266, 266) self.__image_ratio = 1.0 @@ -96,7 +93,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__media_player_page = QWidget() self.__stacked_page_setup(self.__media_player_page, self.__media_player) - self.__thumb_renderer = ThumbRenderer(self.lib) + self.__thumb_renderer = ThumbRenderer(library) self.__thumb_renderer.updated.connect(self.__thumb_renderer_updated_callback) self.__thumb_renderer.updated_ratio.connect(self.__thumb_renderer_updated_ratio_callback) From c70ed9b8c5873d73ebbbbd5f10d93359b7d92c23 Mon Sep 17 00:00:00 2001 From: Jann Stute Date: Sun, 6 Jul 2025 19:22:47 +0200 Subject: [PATCH 31/31] doc: change rule of thumb from note to tip --- STYLE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/STYLE.md b/STYLE.md index 364b92031..81e0cf54f 100644 --- a/STYLE.md +++ b/STYLE.md @@ -78,7 +78,7 @@ Observe the following key aspects of this example: - Defines the interface the callbacks - Enforces that UI events be handled -> [!NOTE] +> [!TIP] > A good (non-exhaustive) rule of thumb is: If it requires a non-UI import, then it doesn't belong in the `*_view.py` file. [^1]: For an explanation of the Model-View-Controller (MVC) Model, checkout this article: [MVC Framework Introduction](https://www.geeksforgeeks.org/mvc-framework-introduction/).