diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 0cf666198..ba62c1fad 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -38,10 +38,8 @@ def _set_selection_callback(self): 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]) + self.refresh_selection(update_preview=False) 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]) + self.refresh_selection(update_preview=False) diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 2a5865d8b..70bfc6162 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -25,6 +25,7 @@ class TagBoxWidget(TagBoxWidgetView): on_update = Signal() __entries: list[int] = [] + __mixed_only: bool = False def __init__(self, title: str, driver: "QtDriver"): super().__init__(title, driver) @@ -33,6 +34,28 @@ def __init__(self, title: str, driver: "QtDriver"): def set_entries(self, entries: list[int]) -> None: self.__entries = entries + def set_mixed_only(self, value: bool) -> None: + """If True, all tags in this widget are treated as partial-selection tags.""" + self.__mixed_only = value + + def set_tags(self, tags): # type: ignore[override] + """Render tags; visually dim those that are not shared across entries.""" + tags_ = list(tags) + + # When mixed_only is set, all tags in this widget are considered partial. + partial_tag_ids: set[int] = set() + if not self.__mixed_only and self.__entries: + tag_ids = [t.id for t in tags_] + tag_entries = self.__driver.lib.get_tag_entries(tag_ids, self.__entries) + required = set(self.__entries) + for tag_id, entries in tag_entries.items(): + if set(entries) < required: + partial_tag_ids.add(tag_id) + elif self.__mixed_only: + partial_tag_ids = {tag.id for tag in tags_} + + super().set_tags(tags_, partial_tag_ids=partial_tag_ids) + @override def _on_click(self, tag: Tag) -> None: # type: ignore[misc] match self.__driver.settings.tag_click_action: diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index ae8df9107..febc3c8e2 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -14,6 +14,7 @@ from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import ( QFrame, + QGraphicsOpacityEffect, QHBoxLayout, QMessageBox, QScrollArea, @@ -105,15 +106,117 @@ def __init__(self, library: Library, driver: "QtDriver"): def update_from_entry(self, entry_id: int, update_badges: bool = True): """Update tags and fields from a single Entry source.""" - logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id) + self.update_from_selection(entry_id, update_badges) - entry = unwrap(self.lib.get_entry_full(entry_id)) - self.cached_entries = [entry] - self.update_granular(entry.tags, entry.fields, update_badges) + def update_from_entries(self, entry_ids: list[int], update_badges: bool = True): + """Update tags and fields from multiple Entry sources, showing shared tags.""" + self.update_from_selection(entry_ids, update_badges) + + def update_from_selection(self, entry_ids: int | list[int], update_badges: bool = True): + """Update tags and fields from one or more Entry sources.""" + entry_ids = [entry_ids] if isinstance(entry_ids, int) else list(entry_ids) + logger.warning("[FieldContainers] Updating Selection", entry_ids=entry_ids) + + if len(entry_ids) == 1: + entries = [unwrap(self.lib.get_entry_full(entry_ids[0]))] + else: + entries = list(self.lib.get_entries_full(entry_ids)) + + if not entries: + self.cached_entries = [] + self.hide_containers() + return + + self.cached_entries = entries + + if len(entries) == 1: + entry = entries[0] + self.update_granular(entry.tags, entry.fields, update_badges) + return + + shared_tags = self._get_shared_tags(entries) + mixed_tags = set().union(*(entry.tags for entry in entries)) - shared_tags + shared_fields, mixed_fields = self._split_fields(entries) + + next_index = self.update_granular( + shared_tags, + shared_fields, + update_badges, + hide_leftovers=False, + ) + + if mixed_tags or mixed_fields: + next_index = self.write_info_container( + next_index, + Translations["preview.partial_section"], + Translations["preview.partial_section_body"], + ) + + if mixed_tags: + categories = self.get_tag_categories(mixed_tags) + for cat, tags in sorted(categories.items(), key=lambda kv: (kv[0] is None, kv)): + self.write_tag_container(next_index, tags=tags, category_tag=cat, is_mixed=True) + next_index += 1 + + for field in mixed_fields: + self.write_container(next_index, field, is_mixed=True) + next_index += 1 + + self.hide_unused_containers(next_index) + + def _get_shared_tags(self, entries: list[Entry]) -> set[Tag]: + """Get tags that are present in all entries.""" + if not entries: + return set() + + shared_tags = set(entries[0].tags) + for entry in entries[1:]: + shared_tags &= set(entry.tags) + + return shared_tags + + def _get_shared_fields(self, entries: list[Entry]) -> list[BaseField]: + """Get fields that are present in all entries with the same value.""" + if not entries: + return [] + + shared_fields = [] + first_entry_fields = entries[0].fields + + for field in first_entry_fields: + if all( + any(f.type_key == field.type_key and f.value == field.value for f in entry.fields) + for entry in entries[1:] + ): + shared_fields.append(field) + + return shared_fields + + def _split_fields(self, entries: list[Entry]) -> tuple[list[BaseField], list[BaseField]]: + """Split fields into shared and mixed groups for a multi-selection.""" + all_fields_by_type: dict[str, list[BaseField]] = {} + for entry in entries: + for field in entry.fields: + all_fields_by_type.setdefault(field.type_key, []).append(field) + + shared_fields: list[BaseField] = [] + mixed_fields: list[BaseField] = [] + for fields in all_fields_by_type.values(): + if len(fields) == len(entries) and all(f.value == fields[0].value for f in fields): + shared_fields.append(fields[0]) + else: + mixed_fields.append(fields[0]) + + return shared_fields, mixed_fields def update_granular( - self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True - ): + self, + entry_tags: set[Tag], + entry_fields: list[BaseField], + update_badges: bool = True, + *, + hide_leftovers: bool = True, + ) -> int: """Individually update elements of the item preview.""" container_len: int = len(entry_fields) container_index = 0 @@ -134,10 +237,10 @@ def update_granular( self.write_container(index, field, is_mixed=False) # Hide leftover container(s) - if len(self.containers) > container_len: - for i, c in enumerate(self.containers): - if i > (container_len - 1): - c.setHidden(True) + if hide_leftovers: + self.hide_unused_containers(container_len) + + return container_len def update_toggled_tag(self, tag_id: int, toggle_value: bool): """Visually add or remove a tag from the item preview without needing to query the db.""" @@ -157,6 +260,12 @@ def hide_containers(self): for c in self.containers: c.setHidden(True) + def hide_unused_containers(self, visible_count: int) -> None: + """Hide containers that are no longer part of the active selection view.""" + for i, container in enumerate(self.containers): + if i >= visible_count: + container.setHidden(True) + def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: """Get a dictionary of category tags mapped to their respective tags. @@ -240,6 +349,15 @@ def add_tags_to_selected(self, tags: int | list[int]): ) self.driver.emit_badge_signals(tags, emit_on_absent=False) + def set_container_partial(self, container: FieldContainer, partial: bool) -> None: + """Apply a visual partial-selection treatment to a container.""" + if partial: + effect = QGraphicsOpacityEffect(container) + effect.setOpacity(0.7) + container.setGraphicsEffect(effect) + else: + container.setGraphicsEffect(None) + def write_container(self, index: int, field: BaseField, is_mixed: bool = False): """Update/Create data for a FieldContainer. @@ -258,6 +376,11 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): else: container = self.containers[index] + self.set_container_partial(container, is_mixed) + container.set_copy_callback() + container.set_edit_callback() + container.set_remove_callback() + if field.type.type == FieldTypeEnum.TEXT_LINE: container.set_title(field.type.name) container.set_inline(False) @@ -398,6 +521,26 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): container.setHidden(False) + def write_info_container(self, index: int, title: str, text: str) -> int: + """Render a non-interactive informational container.""" + logger.info("[FieldContainers][write_info_container]", index=index) + if len(self.containers) < (index + 1): + container = FieldContainer() + self.containers.append(container) + self.scroll_layout.addWidget(container) + else: + container = self.containers[index] + + self.set_container_partial(container, False) + container.set_title(title) + container.set_inline(False) + container.set_inner_widget(TextWidget(title, text)) + container.set_copy_callback() + container.set_edit_callback() + container.set_remove_callback() + container.setHidden(False) + return index + 1 + def write_tag_container( self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False ): @@ -419,32 +562,39 @@ def write_tag_container( else: container = self.containers[index] + self.set_container_partial(container, is_mixed) container.set_title("Tags" if not category_tag else category_tag.name) container.set_inline(False) - if not is_mixed: - inner_widget = container.get_inner_widget() + inner_widget = container.get_inner_widget() - if isinstance(inner_widget, TagBoxWidget): - with catch_warnings(record=True): - inner_widget.on_update.disconnect() + if isinstance(inner_widget, TagBoxWidget): + with catch_warnings(record=True): + inner_widget.on_update.disconnect() + else: + inner_widget = TagBoxWidget( + "Tags", + self.driver, + ) + container.set_inner_widget(inner_widget) + + # For mixed tag containers, mark the widget so it can gray out all tags. + if is_mixed: + inner_widget.set_mixed_only(True) + else: + inner_widget.set_mixed_only(False) + + inner_widget.set_entries([e.id for e in self.cached_entries]) + inner_widget.set_tags(tags) + def update_callback(): + if len(self.cached_entries) == 1: + self.update_from_entry(self.cached_entries[0].id, update_badges=True) else: - inner_widget = TagBoxWidget( - "Tags", - self.driver, - ) - container.set_inner_widget(inner_widget) - inner_widget.set_entries([e.id for e in self.cached_entries]) - inner_widget.set_tags(tags) + entry_ids = [e.id for e in self.cached_entries] + self.update_from_entries(entry_ids, update_badges=True) - inner_widget.on_update.connect( - lambda: (self.update_from_entry(self.cached_entries[0].id, update_badges=True)) - ) - else: - text = "Mixed Data" - inner_widget = TextWidget("Mixed Tags", text) - container.set_inner_widget(inner_widget) + inner_widget.on_update.connect(update_callback) container.set_edit_callback() container.set_remove_callback() diff --git a/src/tagstudio/qt/mixed/tag_widget.py b/src/tagstudio/qt/mixed/tag_widget.py index 85b62c4e0..507ae79c1 100644 --- a/src/tagstudio/qt/mixed/tag_widget.py +++ b/src/tagstudio/qt/mixed/tag_widget.py @@ -9,7 +9,14 @@ import structlog from PySide6.QtCore import QEvent, Qt, Signal from PySide6.QtGui import QAction, QColor, QEnterEvent, QFontMetrics -from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget +from PySide6.QtWidgets import ( + QGraphicsOpacityEffect, + QHBoxLayout, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.models import Tag @@ -273,6 +280,15 @@ def set_tag(self, tag: Tag | None) -> None: def set_has_remove(self, has_remove: bool): self.has_remove = has_remove + def set_partial(self, partial: bool) -> None: + """Visually dim tags that are only present on part of the selection.""" + if partial: + effect = QGraphicsOpacityEffect(self) + effect.setOpacity(0.55) + self.setGraphicsEffect(effect) + else: + self.setGraphicsEffect(None) + @override def enterEvent(self, event: QEnterEvent) -> None: if self.has_remove: diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index 5ae7004cd..9c0610745 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -132,6 +132,10 @@ def _add_tag_button_callback(self): def _set_selection_callback(self): raise NotImplementedError() + def refresh_selection(self, update_preview: bool = False) -> None: + """Refresh the current selection without requiring the caller to re-read it.""" + self.set_selection(self._selected, update_preview=update_preview) + def set_selection(self, selected: list[int], update_preview: bool = True): """Render the panel widgets with the newest data from the Library. @@ -158,6 +162,8 @@ def set_selection(self, selected: list[int], update_preview: bool = True): filepath: Path = unwrap(self.lib.library_dir) / entry.path + self.add_buttons_enabled = True + if update_preview: stats: FileAttributeData = self.__thumb.display_file(filepath) self.__file_attrs.update_stats(filepath, stats) @@ -166,20 +172,16 @@ def set_selection(self, selected: list[int], update_preview: bool = True): self._set_selection_callback() - self.add_buttons_enabled = True - # Multiple Selected Items elif len(selected) > 1: - # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected] + self.add_buttons_enabled = 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.update_from_entries(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() diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py index e50509ad7..33bfd42e0 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_thumb_view.py @@ -37,12 +37,14 @@ class PreviewThumbView(QWidget): __filepath: Path | None __rendered_res: tuple[int, int] + __render_cutoff: float def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() self.__img_button_size = (266, 266) self.__image_ratio = 1.0 + self.__render_cutoff = 0.0 self.__image_layout = QStackedLayout(self) self.__image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -126,8 +128,11 @@ 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 + self, timestamp: float, img: QPixmap, _size: QSize, _path: Path ) -> None: + # Ignore outdated renders if a newer selection has been requested. + if timestamp < self.__render_cutoff: + return self.__button_wrapper.setIcon(img) def __thumb_renderer_updated_ratio_callback(self, ratio: float) -> None: @@ -213,8 +218,11 @@ def __render_thumb(self, filepath: Path) -> None: math.ceil(self.__img_button_size[1] * THUMB_SIZE_FACTOR), ) + timestamp = time.time() + self.__render_cutoff = timestamp + self.__thumb_renderer.render( - time.time(), + timestamp, filepath, self.__rendered_res, self.devicePixelRatio(), diff --git a/src/tagstudio/qt/views/tag_box_view.py b/src/tagstudio/qt/views/tag_box_view.py index bf24a88cf..6ab4e0052 100644 --- a/src/tagstudio/qt/views/tag_box_view.py +++ b/src/tagstudio/qt/views/tag_box_view.py @@ -31,7 +31,7 @@ def __init__(self, title: str, driver: "QtDriver") -> None: self.__root_layout.setContentsMargins(0, 0, 0, 0) self.setLayout(self.__root_layout) - def set_tags(self, tags: Iterable[Tag]) -> None: + def set_tags(self, tags: Iterable[Tag], partial_tag_ids: set[int] | None = None) -> None: tags_ = sorted(list(tags), key=lambda tag: self.__lib.tag_display_name(tag)) logger.info("[TagBoxWidget] Tags:", tags=tags) while self.__root_layout.itemAt(0): @@ -39,6 +39,7 @@ def set_tags(self, tags: Iterable[Tag]) -> None: for tag in tags_: tag_widget = TagWidget(tag, library=self.__lib, has_edit=True, has_remove=True) + tag_widget.set_partial(bool(partial_tag_ids and tag.id in partial_tag_ids)) tag_widget.on_click.connect(lambda t=tag: self._on_click(t)) diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 8b84af737..1159bd4f0 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -248,8 +248,11 @@ "namespace.new.button": "New Namespace", "namespace.new.prompt": "Create a New Namespace to Start Adding Custom Colors!", "preview.ignored": "Ignored", - "preview.multiple_selection": "{count} Items Selected", + "preview.multiple_selection": "{count} Items Selected
Showing tags and fields shared by all selected entries", "preview.no_selection": "No Items Selected", + "preview.partial_section": "Tags and Fields Not On Every Selected Item", + "preview.partial_section_body": "_These are only present on some selected entries._", + "preview.partial_tags": "Tags (Some Entries)", "preview.unlinked": "Unlinked", "select.add_tag_to_selected": "Add Tag to Selected", "select.all": "Select All", diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 2b9921146..00da8cc12 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -7,6 +7,7 @@ from tagstudio.core.library.alchemy.models import Entry, Tag from tagstudio.core.utils.types import unwrap from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel +from tagstudio.qt.translations import Translations from tagstudio.qt.ts_qt import QtDriver @@ -36,8 +37,6 @@ def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_fu def test_update_selection_multiple(qt_driver: QtDriver, library: Library): - # TODO: Implement mixed field editing. Currently these containers will be hidden, - # same as the empty selection behavior. panel = PreviewPanel(library, qt_driver) # Select the multiple entries @@ -45,9 +44,9 @@ def test_update_selection_multiple(qt_driver: QtDriver, library: Library): qt_driver.toggle_item_selection(2, append=True, bridge=False) panel.set_selection(qt_driver.selected) - # FieldContainer should show mixed field editing - for container in panel.field_containers_widget.containers: - assert container.isHidden() + # Panel should enable UI that allows for entry modification and cache all selected entries + assert panel.add_buttons_enabled + assert len(panel.field_containers_widget.cached_entries) == 2 def test_add_tag_to_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry): @@ -185,3 +184,26 @@ def test_custom_tag_category(qt_driver: QtDriver, library: Library, entry_full: assert container.title != "

Tags

" case _: pass + + +def test_multi_selection_mixed_section_resets_on_single_selection( + qt_driver: QtDriver, library: Library +): + panel = PreviewPanel(library, qt_driver) + field_containers = panel.field_containers_widget + + field_containers.update_from_entries([1, 2]) + + container_titles = [c.title for c in field_containers.containers] + assert f"

{Translations['preview.partial_section']}

" in container_titles + assert "

Tags

" in container_titles + assert "

Title

" in container_titles + assert [entry.id for entry in field_containers.cached_entries] == [1, 2] + + field_containers.update_from_entry(1) + + entry = unwrap(library.get_entry_full(1)) + active_container_count = len(field_containers.get_tag_categories(entry.tags)) + len(entry.fields) + active_titles = [field_containers.containers[i].title for i in range(active_container_count)] + assert f"

{Translations['preview.partial_section']}

" not in active_titles + assert [cached_entry.id for cached_entry in field_containers.cached_entries] == [1] diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py index 12282c9b2..cc23ea6d9 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -3,9 +3,15 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +from unittest.mock import Mock + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QListWidgetItem + from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel +from tagstudio.qt.translations import Translations from tagstudio.qt.ts_qt import QtDriver @@ -42,3 +48,43 @@ def test_update_selection_multiple(qt_driver: QtDriver, library: Library): # Panel should enable UI that allows for entry modification assert panel.add_buttons_enabled + + # File attributes should indicate multiple selection and shared tags + attrs = panel._file_attributes_widget + expected_label = Translations.format( + "preview.multiple_selection", count=len(qt_driver.selected) + ) + assert attrs.file_label.text() == expected_label + + +def test_add_field_to_selection_multiple_refreshes(qt_driver: QtDriver, library: Library): + panel = PreviewPanel(library, qt_driver) + + # The add-field callback uses the driver's current selection, so seed it directly here. + qt_driver.main_window.thumb_layout._selected = {1: 0, 2: 1} + panel.set_selection([1, 2], update_preview=False) + refresh_selection_spy = Mock(wraps=panel.refresh_selection) + panel.refresh_selection = refresh_selection_spy + + selected_entries = list(library.get_entries_full([1, 2])) + existing_field_keys = {field.type_key for entry in selected_entries for field in entry.fields} + field_type = next( + value_type + for value_type in library.field_types.values() + if value_type.key not in existing_field_keys + ) + + item = QListWidgetItem(f"{field_type.name} ({field_type.type.value})") + item.setData(Qt.ItemDataRole.UserRole, field_type.key) + + panel._add_field_to_selected([item]) + refresh_selection_spy.assert_called_once_with(update_preview=False) + + refreshed_entries = list(library.get_entries_full([1, 2])) + assert all( + any(field.type_key == field_type.key for field in entry.fields) for entry in refreshed_entries + ) + assert all( + any(field.type_key == field_type.key for field in entry.fields) + for entry in panel.field_containers_widget.cached_entries + )