From f2ee4d4d7c4fc9148fd622c0b15ecade561888ed Mon Sep 17 00:00:00 2001 From: JasonC Date: Fri, 28 Nov 2025 22:24:41 -0500 Subject: [PATCH 1/2] 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/2] 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",