From f2ee4d4d7c4fc9148fd622c0b15ecade561888ed Mon Sep 17 00:00:00 2001 From: JasonC Date: Fri, 28 Nov 2025 22:24:41 -0500 Subject: [PATCH 1/3] Extend preview panel multi-selection with shared tag editing and update tests --- .../controllers/preview_panel_controller.py | 4 ++ src/tagstudio/qt/mixed/field_containers.py | 56 ++++++++++++++++++- src/tagstudio/qt/views/preview_panel_view.py | 10 ++-- src/tagstudio/resources/translations/en.json | 2 +- tests/qt/test_field_containers.py | 8 +-- tests/qt/test_preview_panel.py | 8 +++ 6 files changed, 73 insertions(+), 15 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 0cf666198..ba447f52b 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -40,8 +40,12 @@ 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]) + elif len(self._selected) > 1: + self._fields.update_from_entries(self._selected) 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]) + elif len(self._selected) > 1: + self._fields.update_from_entries(self._selected) diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index ae8df9107..7d2e7724f 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -111,6 +111,51 @@ def update_from_entry(self, entry_id: int, update_badges: bool = True): 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.""" + logger.warning("[FieldContainers] Updating Multiple Selection", entry_ids=entry_ids) + + entries = list(self.lib.get_entries_full(entry_ids)) + if not entries: + self.cached_entries = [] + self.hide_containers() + return + + self.cached_entries = entries + + shared_tags = self._get_shared_tags(entries) + shared_fields = self._get_shared_fields(entries) + + self.update_granular(shared_tags, shared_fields, update_badges) + + 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.id == field.type.id and f.value == field.value for f in entry.fields) + for entry in entries[1:] + ): + shared_fields.append(field) + + return shared_fields + def update_granular( self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True ): @@ -438,9 +483,14 @@ def write_tag_container( 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)) - ) + 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) + + inner_widget.on_update.connect(update_callback) else: text = "Mixed Data" inner_widget = TextWidget("Mixed Tags", text) diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index 5ae7004cd..1497a1e19 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -158,6 +158,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 +168,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/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 8b84af737..39c5fbf04 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -248,7 +248,7 @@ "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 shared by all selected entries", "preview.no_selection": "No Items Selected", "preview.unlinked": "Unlinked", "select.add_tag_to_selected": "Add Tag to Selected", diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 2b9921146..3e8483b60 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -36,8 +36,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 +43,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): diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py index 12282c9b2..08056d262 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -6,6 +6,7 @@ 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 +43,10 @@ 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 From ef2c1e3de7dfe96abf008d07ce094368df1a0f13 Mon Sep 17 00:00:00 2001 From: JasonC Date: Sat, 29 Nov 2025 18:09:24 -0500 Subject: [PATCH 2/3] Fix preview panel race condition and add mixed tag display for multi-selection --- .../qt/controllers/tag_box_controller.py | 33 +++++++ src/tagstudio/qt/mixed/field_containers.py | 93 +++++++++++++------ src/tagstudio/qt/views/preview_thumb_view.py | 12 ++- src/tagstudio/resources/translations/en.json | 1 + 4 files changed, 109 insertions(+), 30 deletions(-) diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 2a5865d8b..19672b674 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,38 @@ 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 non-shared (grayed out).""" + self.__mixed_only = value + + def set_tags(self, tags): # type: ignore[override] + """Render tags; optionally gray out those that are not shared across entries.""" + tags_ = list(tags) + + # When mixed_only is set, all tags in this widget are considered non-shared. + shared_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: + shared_tag_ids.add(tag_id) + + super().set_tags(tags_) + + # Gray out tags that are not shared across all selected entries. + from tagstudio.qt.mixed.tag_widget import TagWidget # local import to avoid cycles + + layout = getattr(self, "_TagBoxWidgetView__root_layout", None) + if layout is not None: + for i in range(layout.count()): + item = layout.itemAt(i) + widget = item.widget() + if isinstance(widget, TagWidget) and widget.tag: + if self.__mixed_only or widget.tag.id not in shared_tag_ids: + widget.setEnabled(False) + @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 7d2e7724f..d2b48650c 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -124,9 +124,39 @@ def update_from_entries(self, entry_ids: list[int], update_badges: bool = True): self.cached_entries = entries shared_tags = self._get_shared_tags(entries) - shared_fields = self._get_shared_fields(entries) - self.update_granular(shared_tags, shared_fields, update_badges) + # Compute shared and mixed fields by type id and value. + all_fields_by_type: dict[int, list[BaseField]] = {} + for entry in entries: + for field in entry.fields: + all_fields_by_type.setdefault(field.type.id, []).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]) + + all_fields: list[BaseField] = shared_fields + mixed_fields + mixed_field_type_ids: set[int] = {f.type.id for f in mixed_fields} + + self.update_granular( + shared_tags, + all_fields, + update_badges, + mixed_field_type_ids=mixed_field_type_ids if mixed_field_type_ids else None, + ) + + # Add a separate container for tags that aren't shared across all entries. + all_tags: set[Tag] = set() + for entry in entries: + all_tags.update(entry.tags) + mixed_tags: set[Tag] = all_tags - shared_tags + if mixed_tags: + index = len(self.containers) + self.write_tag_container(index, tags=mixed_tags, category_tag=None, is_mixed=True) def _get_shared_tags(self, entries: list[Entry]) -> set[Tag]: """Get tags that are present in all entries.""" @@ -157,7 +187,11 @@ def _get_shared_fields(self, entries: list[Entry]) -> list[BaseField]: return shared_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, + mixed_field_type_ids: set[int] | None = None, ): """Individually update elements of the item preview.""" container_len: int = len(entry_fields) @@ -176,7 +210,8 @@ def update_granular( # Write field container(s) for index, field in enumerate(entry_fields, start=container_index): - self.write_container(index, field, is_mixed=False) + is_mixed = mixed_field_type_ids is not None and field.type.id in mixed_field_type_ids + self.write_container(index, field, is_mixed=is_mixed) # Hide leftover container(s) if len(self.containers) > container_len: @@ -467,34 +502,36 @@ def write_tag_container( 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() + 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) + container.set_title(Translations["preview.partial_tags"]) + else: + inner_widget.set_mixed_only(False) - if isinstance(inner_widget, TagBoxWidget): - with catch_warnings(record=True): - inner_widget.on_update.disconnect() + 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) - - 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) + entry_ids = [e.id for e in self.cached_entries] + self.update_from_entries(entry_ids, update_badges=True) - inner_widget.on_update.connect(update_callback) - 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/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/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 39c5fbf04..94d41ba31 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -250,6 +250,7 @@ "preview.ignored": "Ignored", "preview.multiple_selection": "{count} Items Selected
Showing tags shared by all selected entries", "preview.no_selection": "No Items Selected", + "preview.partial_tags": "Tags (Some Entries)", "preview.unlinked": "Unlinked", "select.add_tag_to_selected": "Add Tag to Selected", "select.all": "Select All", From 46ad7b86ebbc5eb9d779811cc4ec6ea3a25a6e7c Mon Sep 17 00:00:00 2001 From: JasonC Date: Mon, 23 Mar 2026 18:59:49 -0400 Subject: [PATCH 3/3] fix bulk selection preview for partial tags and fields --- .../controllers/preview_panel_controller.py | 10 +- .../qt/controllers/tag_box_controller.py | 30 ++-- src/tagstudio/qt/mixed/field_containers.py | 153 ++++++++++++------ src/tagstudio/qt/mixed/tag_widget.py | 18 ++- src/tagstudio/qt/views/preview_panel_view.py | 4 + src/tagstudio/qt/views/tag_box_view.py | 3 +- src/tagstudio/resources/translations/en.json | 4 +- tests/qt/test_field_containers.py | 24 +++ tests/qt/test_preview_panel.py | 38 +++++ 9 files changed, 208 insertions(+), 76 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index ba447f52b..ba62c1fad 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -38,14 +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]) - elif len(self._selected) > 1: - self._fields.update_from_entries(self._selected) + 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]) - elif len(self._selected) > 1: - self._fields.update_from_entries(self._selected) + 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 19672b674..70bfc6162 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -35,36 +35,26 @@ 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 non-shared (grayed out).""" + """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; optionally gray out those that are not shared across entries.""" + """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 non-shared. - shared_tag_ids: set[int] = set() + # 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: - shared_tag_ids.add(tag_id) - - super().set_tags(tags_) - - # Gray out tags that are not shared across all selected entries. - from tagstudio.qt.mixed.tag_widget import TagWidget # local import to avoid cycles - - layout = getattr(self, "_TagBoxWidgetView__root_layout", None) - if layout is not None: - for i in range(layout.count()): - item = layout.itemAt(i) - widget = item.widget() - if isinstance(widget, TagWidget) and widget.tag: - if self.__mixed_only or widget.tag.id not in shared_tag_ids: - widget.setEnabled(False) + 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] diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index d2b48650c..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,17 +106,22 @@ 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) - - entry = unwrap(self.lib.get_entry_full(entry_id)) - self.cached_entries = [entry] - self.update_granular(entry.tags, entry.fields, update_badges) + self.update_from_selection(entry_id, 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.""" - logger.warning("[FieldContainers] Updating Multiple Selection", entry_ids=entry_ids) + 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)) - entries = list(self.lib.get_entries_full(entry_ids)) if not entries: self.cached_entries = [] self.hide_containers() @@ -123,40 +129,40 @@ def update_from_entries(self, entry_ids: list[int], update_badges: bool = True): self.cached_entries = entries - shared_tags = self._get_shared_tags(entries) - - # Compute shared and mixed fields by type id and value. - all_fields_by_type: dict[int, list[BaseField]] = {} - for entry in entries: - for field in entry.fields: - all_fields_by_type.setdefault(field.type.id, []).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]) + if len(entries) == 1: + entry = entries[0] + self.update_granular(entry.tags, entry.fields, update_badges) + return - all_fields: list[BaseField] = shared_fields + mixed_fields - mixed_field_type_ids: set[int] = {f.type.id for f in mixed_fields} + 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) - self.update_granular( + next_index = self.update_granular( shared_tags, - all_fields, + shared_fields, update_badges, - mixed_field_type_ids=mixed_field_type_ids if mixed_field_type_ids else None, + hide_leftovers=False, ) - # Add a separate container for tags that aren't shared across all entries. - all_tags: set[Tag] = set() - for entry in entries: - all_tags.update(entry.tags) - mixed_tags: set[Tag] = all_tags - shared_tags - if mixed_tags: - index = len(self.containers) - self.write_tag_container(index, tags=mixed_tags, category_tag=None, is_mixed=True) + 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.""" @@ -179,20 +185,38 @@ def _get_shared_fields(self, entries: list[Entry]) -> list[BaseField]: for field in first_entry_fields: if all( - any(f.type.id == field.type.id and f.value == field.value for f in entry.fields) + 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, - mixed_field_type_ids: set[int] | None = None, - ): + *, + hide_leftovers: bool = True, + ) -> int: """Individually update elements of the item preview.""" container_len: int = len(entry_fields) container_index = 0 @@ -210,14 +234,13 @@ def update_granular( # Write field container(s) for index, field in enumerate(entry_fields, start=container_index): - is_mixed = mixed_field_type_ids is not None and field.type.id in mixed_field_type_ids - self.write_container(index, field, is_mixed=is_mixed) + 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.""" @@ -237,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. @@ -320,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. @@ -338,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) @@ -478,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 ): @@ -499,6 +562,7 @@ 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) @@ -517,7 +581,6 @@ def write_tag_container( # For mixed tag containers, mark the widget so it can gray out all tags. if is_mixed: inner_widget.set_mixed_only(True) - container.set_title(Translations["preview.partial_tags"]) else: inner_widget.set_mixed_only(False) 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 1497a1e19..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. 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 94d41ba31..1159bd4f0 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -248,8 +248,10 @@ "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
Showing tags shared by all selected entries", + "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", diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 3e8483b60..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 @@ -183,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 08056d262..cc23ea6d9 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -3,6 +3,11 @@ # 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 @@ -50,3 +55,36 @@ def test_update_selection_multiple(qt_driver: QtDriver, library: Library): "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 + )