diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 2fa94c296..b2e88fab5 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -175,6 +175,8 @@ def _add_field_to_selected(self, template: BaseFieldTemplate) -> None: self._edit_field(entry.id, entry_field) self.layout().containers.update_from_entry(self._selected[0]) + elif len(self._selected) > 1: + self.layout().containers.update_from_entries(self._selected) def _edit_field(self, entry_id: int, field: BaseField) -> None: # TODO: A lot of this code is similar to or straight up shared with FieldContainers. @@ -208,6 +210,8 @@ def _add_tag_to_selected(self, tag_id: int) -> None: self.layout().containers.add_tags_to_selected(tag_id) if len(self._selected) == 1: self.layout().containers.update_from_entry(self._selected[0]) + elif len(self._selected) > 1: + self.layout().containers.update_from_entries(self._selected) def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None: if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()): @@ -261,7 +265,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non self._current_stats = None self.layout().file_attrs.update_multi_selection(len(selected)) self.layout().file_attrs.update_date_label() - self.layout().containers.hide_containers() # TODO: Allow for mixed editing + self.layout().containers.update_from_entries(selected) self._set_selection_callback() except Exception as e: diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 11dc2a911..2d573eeb3 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -26,6 +26,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) @@ -34,6 +35,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: 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 1a6ae897d..957e14ad5 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -12,6 +12,7 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QFrame, + QGraphicsOpacityEffect, QHBoxLayout, QMessageBox, QScrollArea, @@ -101,15 +102,102 @@ def top_entry_id(self) -> int: def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None: """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) -> None: + """Update tags and fields from multiple Entry sources, showing shared tags.""" + self.update_from_selection(entry_ids, update_badges) - def update_granular( - self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True + def update_from_selection( + self, entry_ids: int | list[int], update_badges: bool = True ) -> None: + """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_field_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 _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[tuple[str, str], list[BaseField]] = {} + for entry in entries: + for field in entry.fields: + all_fields_by_type.setdefault((field.name, field.class_name), []).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, + *, + hide_leftovers: bool = True, + ) -> int: """Individually update elements of the item preview.""" container_len: int = len(entry_fields) container_index = 0 @@ -130,10 +218,10 @@ def update_granular( self.write_field_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) -> None: """Visually add or remove a tag from the item preview without needing to query the db.""" @@ -153,6 +241,12 @@ def hide_containers(self) -> None: 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. @@ -244,6 +338,15 @@ def add_tags_to_selected(self, tag_ids: int | list[int]) -> None: ) self.driver.add_tags_to_selected_callback(tag_ids) + def set_container_partial(self, container: FieldContainer, is_partial: bool) -> None: + """Apply a visual partial-selection treatment to a container.""" + if is_partial: + effect = QGraphicsOpacityEffect(container) + effect.setOpacity(0.7) + container.setGraphicsEffect(effect) + else: + container.setGraphicsEffect(None) + def update_text_field_callback( self, field: TextField, entry_id: int, content: dict[str, str | bool] ) -> None: @@ -373,6 +476,12 @@ def write_unknown_container(): else: container = self._containers[index] + self.set_container_partial(container, is_mixed) + # Reset any callbacks left over from this container's previous contents, since not + # every branch below re-assigns them (e.g. mixed fields are not editable/removable). + container.set_edit_callback() + container.set_remove_callback() + # Set field title field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown") title = f"{field.name} ({Translations[field_name_key]})" @@ -387,6 +496,25 @@ def write_unknown_container(): 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, is_partial=False) + container.set_title(title) + container.set_inner_widget(TextContainerWidget(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 ) -> None: @@ -407,32 +535,34 @@ def write_tag_container( else: container = self._containers[index] + self.set_container_partial(container, is_mixed) container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name) - if not is_mixed: - inner_widget = container.get_inner_widget() - - if isinstance(inner_widget, TagBoxWidget): - with catch_warnings(record=True): - inner_widget.on_update.disconnect() + inner_widget = container.get_inner_widget() - else: - inner_widget = TagBoxWidget(Translations["entries.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) - - inner_widget.on_update.connect( - lambda: ( - self.update_from_entry(self.cached_entries[0].id, update_badges=True), - self.on_tags_update.emit(), - ) - ) + if isinstance(inner_widget, TagBoxWidget): + with catch_warnings(record=True): + inner_widget.on_update.disconnect() else: - text = f"{Translations['field.mixed_data']}" - inner_widget = TextContainerWidget("Mixed Tags", text) # NOTE: Unlocalized but unused + inner_widget = TagBoxWidget(Translations["entries.tags"], self.driver) container.set_inner_widget(inner_widget) + # For mixed tag containers, mark the widget so it can gray out all tags. + inner_widget.set_mixed_only(is_mixed) + + 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: + entry_ids = [e.id for e in self.cached_entries] + self.update_from_entries(entry_ids, update_badges=True) + self.on_tags_update.emit() + + inner_widget.on_update.connect(update_callback) + container.set_edit_callback() container.set_remove_callback() container.setHidden(False) diff --git a/src/tagstudio/qt/mixed/tag_widget.py b/src/tagstudio/qt/mixed/tag_widget.py index d0ae2db5c..9e9495ccf 100644 --- a/src/tagstudio/qt/mixed/tag_widget.py +++ b/src/tagstudio/qt/mixed/tag_widget.py @@ -8,7 +8,15 @@ import structlog from PySide6.QtCore import QEvent, Qt, Signal from PySide6.QtGui import QAction, QColor, QEnterEvent, QFontMetrics -from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QSizePolicy, QVBoxLayout, QWidget +from PySide6.QtWidgets import ( + QGraphicsOpacityEffect, + QHBoxLayout, + QLineEdit, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.models import Tag @@ -211,6 +219,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_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py index 916c2b85a..15e85de6c 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_thumb_view.py @@ -42,6 +42,7 @@ class PreviewThumbView(QWidget): _current_file: Path | None __should_render_on_resize: bool __rendered_res: tuple[int, int] + __render_cutoff: float def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() @@ -49,6 +50,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__img_button_size = (266, 266) self.__image_ratio = 1.0 + self.__render_cutoff = 0.0 self.__should_render_on_resize = False @@ -148,8 +150,11 @@ def __media_player_duration_changed_callback(self, duration_ms: int) -> None: ) 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: @@ -234,10 +239,13 @@ def __render_thumb(self, filepath: Path) -> None: math.ceil(self.__img_button_size[1] * THUMB_SIZE_FACTOR), ) + timestamp = time.time() + self.__render_cutoff = timestamp + # TODO: Make driver update the cache manager reference here instead of passing the driver. self.__thumb_renderer.render( self._driver.cache_manager, - 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 a3097cb17..74cba31c1 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,8 @@ 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)) tag_widget.on_remove.connect(lambda t=tag: self._on_remove(t)) tag_widget.on_edit.connect(lambda t=tag: self._on_edit(t)) diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 2766729e7..dca6f78b3 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -305,8 +305,11 @@ "preview.ignored": "Ignored", "preview.missing_module.jxl": "{module} is required for JPEG XL previews", "preview.missing_module.multimedia": "{module} is required for multimedia playback", - "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 3307239e4..52b3387c8 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -3,9 +3,11 @@ # pyright: reportPrivateUsage=false +from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry, Tag from tagstudio.core.utils.types import unwrap from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel +from tagstudio.qt.translations import Translations from tagstudio.qt.ts_qt import QtDriver @@ -35,8 +37,6 @@ def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry): def test_update_selection_multiple(qt_driver: QtDriver): - # TODO: Implement mixed field editing. Currently these containers will be hidden, - # same as the empty selection behavior. panel = PreviewPanel(qt_driver) # Select the multiple entries @@ -44,9 +44,10 @@ def test_update_selection_multiple(qt_driver: QtDriver): 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.containers._containers: - assert container.isHidden() + # Panel should enable UI that allows for entry modification and cache all selected entries + assert panel.layout().add_tag_button.isEnabled() + assert panel.layout().add_field_button.isEnabled() + assert len(panel.containers.cached_entries) == 2 def test_add_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry): @@ -182,3 +183,32 @@ def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry): assert container.title != "

Tags

" case _: pass + + +def test_multi_selection_mixed_section_resets_on_single_selection( + qt_driver: QtDriver, library: Library +): + panel = PreviewPanel(qt_driver) + field_containers = panel.containers + + qt_driver.toggle_item_selection(1, append=False, bridge=False) + qt_driver.toggle_item_selection(2, append=True, bridge=False) + panel.set_selection(qt_driver.selected) + + 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] + + # Switch back to single selection — the partial section should disappear + qt_driver.toggle_item_selection(1, append=False, bridge=False) + panel.set_selection(qt_driver.selected) + + 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 05068c32c..23d7b3732 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -3,8 +3,10 @@ # pyright: reportPrivateUsage=false +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 @@ -47,3 +49,38 @@ def test_update_selection_multiple(qt_driver: QtDriver): # Panel should enable UI that allows for entry modification assert panel.layout().add_tag_button.isEnabled() == panel.layout().add_field_button.isEnabled() assert panel.layout().add_tag_button.isEnabled() and panel.layout().add_field_button.isEnabled() + + # File attributes should indicate multiple selection and shared tags + attrs = panel.layout().file_attrs + 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(qt_driver) + + qt_driver.toggle_item_selection(1, append=False, bridge=False) + qt_driver.toggle_item_selection(2, append=True, bridge=False) + panel.set_selection(qt_driver.selected, update_preview=False) + + selected_entries = list(library.get_entries_full([1, 2])) + existing_field_names = {field.name for entry in selected_entries for field in entry.fields} + field_template = next( + template + for template in library.field_templates + if template.name not in existing_field_names + ) + + panel._add_field_to_selected(field_template) + + refreshed_entries = list(library.get_entries_full([1, 2])) + assert all( + any(field.name == field_template.name for field in entry.fields) + for entry in refreshed_entries + ) + assert all( + any(field.name == field_template.name for field in entry.fields) + for entry in panel.containers.cached_entries + )