diff --git a/src/tagstudio/core/library/alchemy/constants.py b/src/tagstudio/core/library/alchemy/constants.py index aba48a278..46a86bbe6 100644 --- a/src/tagstudio/core/library/alchemy/constants.py +++ b/src/tagstudio/core/library/alchemy/constants.py @@ -39,13 +39,23 @@ """) -DEFAULT_FIELD_TEMPLATES = ( - TextFieldTemplate(name="Title"), - TextFieldTemplate(name="Author"), - TextFieldTemplate(name="Artist"), - TextFieldTemplate(name="URL"), - TextFieldTemplate(name="Description", is_multiline=True), - TextFieldTemplate(name="Notes", is_multiline=True), - TextFieldTemplate(name="Comments", is_multiline=True), - DatetimeFieldTemplate(name="Date"), -) +def default_field_templates() -> tuple[TextFieldTemplate | DatetimeFieldTemplate, ...]: + """Build a fresh set of default field template instances. + + These must be constructed anew on every call rather than shared as module-level + singletons. SQLAlchemy instances remember their persistent identity once they've been + added and flushed to a session; reusing the same instances across multiple `Library` + (and therefore multiple database engines/sessions) causes every `Library` after the + first to silently skip inserting these rows, since SQLAlchemy assumes they already + exist. + """ + return ( + TextFieldTemplate(name="Title"), + TextFieldTemplate(name="Author"), + TextFieldTemplate(name="Artist"), + TextFieldTemplate(name="URL"), + TextFieldTemplate(name="Description", is_multiline=True), + TextFieldTemplate(name="Notes", is_multiline=True), + TextFieldTemplate(name="Comments", is_multiline=True), + DatetimeFieldTemplate(name="Date"), + ) diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 473774b46..fbbf86d82 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -65,10 +65,10 @@ DB_VERSION, DB_VERSION_CURRENT_KEY, DB_VERSION_INITIAL_KEY, - DEFAULT_FIELD_TEMPLATES, JSON_FILENAME, SQL_FILENAME, TAG_CHILDREN_QUERY, + default_field_templates, ) from tagstudio.core.library.alchemy.db import Base as ModelBase from tagstudio.core.library.alchemy.enums import MAX_SQL_VARIABLES, BrowsingState, SortingModeEnum @@ -461,7 +461,7 @@ def create_sqlite_library( session.flush() # Add default field templates - for template in DEFAULT_FIELD_TEMPLATES: + for template in default_field_templates(): session.add(template) session.flush() diff --git a/src/tagstudio/core/library/alchemy/migrations.py b/src/tagstudio/core/library/alchemy/migrations.py index 5acc31e8a..9855c130d 100644 --- a/src/tagstudio/core/library/alchemy/migrations.py +++ b/src/tagstudio/core/library/alchemy/migrations.py @@ -18,7 +18,7 @@ DB_VERSION, DB_VERSION_CURRENT_KEY, DB_VERSION_INITIAL_KEY, - DEFAULT_FIELD_TEMPLATES, + default_field_templates, ) from tagstudio.core.library.alchemy.fields import LEGACY_FIELD_MAP, DatetimeField, TextField from tagstudio.core.library.alchemy.joins import TagParent @@ -468,7 +468,7 @@ def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod): # Add default field templates logger.info(fmt_log("Adding default field templates...")) - for template in DEFAULT_FIELD_TEMPLATES: + for template in default_field_templates(): session.add(template) session.flush() diff --git a/src/tagstudio/qt/controllers/inspector.py b/src/tagstudio/qt/controllers/inspector.py index 4e98c5464..e786957c1 100644 --- a/src/tagstudio/qt/controllers/inspector.py +++ b/src/tagstudio/qt/controllers/inspector.py @@ -183,6 +183,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. @@ -216,6 +218,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()): @@ -269,7 +273,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.py b/src/tagstudio/qt/controllers/tag_box.py index f174522ef..77837f4b8 100644 --- a/src/tagstudio/qt/controllers/tag_box.py +++ b/src/tagstudio/qt/controllers/tag_box.py @@ -27,6 +27,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) @@ -35,6 +36,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 daf57109a..66dc23fdc 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, @@ -102,15 +103,100 @@ 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_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 - ) -> None: + 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 @@ -131,10 +217,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.""" @@ -154,6 +240,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. @@ -245,6 +337,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) # pyright: ignore[reportArgumentType] + def update_text_field_callback( self, field: TextField, entry_id: int, content: dict[str, str | bool] ) -> None: @@ -374,6 +475,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]})" @@ -388,6 +495,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: @@ -408,32 +534,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 05b9ca28e..61fc82e70 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 @@ -212,6 +220,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) # pyright: ignore[reportArgumentType] + @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 13f6c9c32..73383342f 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_thumb_view.py @@ -53,6 +53,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__() @@ -60,6 +61,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 @@ -159,8 +161,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: @@ -247,10 +252,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 f66829a2a..07608564a 100644 --- a/src/tagstudio/qt/views/tag_box_view.py +++ b/src/tagstudio/qt/views/tag_box_view.py @@ -32,7 +32,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): @@ -40,6 +40,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 5f35ae38f..a9c36d578 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -306,8 +306,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 d2518360c..16866d45f 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -8,6 +8,7 @@ # pyright: reportPrivateUsage=false from tagstudio.core.library.alchemy.models import Entry, Tag from tagstudio.core.utils.types import unwrap +from tagstudio.i18n.translations import Translations from tagstudio.qt.controllers.inspector import Inspector from tagstudio.qt.qt_driver import QtDriver @@ -38,8 +39,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 = Inspector(qt_driver) # Select the multiple entries @@ -47,9 +46,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): @@ -187,6 +187,35 @@ def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry): pass +def test_multi_selection_mixed_section_resets_on_single_selection( + qt_driver: QtDriver, library: Library +): + panel = Inspector(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] + + def test_exclude_tag_category( qt_driver: QtDriver, library: Library, generate_tag: Callable[..., Tag] ): diff --git a/tests/qt/test_inspector.py b/tests/qt/test_inspector.py index f5c42fa6d..956e678f8 100644 --- a/tests/qt/test_inspector.py +++ b/tests/qt/test_inspector.py @@ -3,7 +3,9 @@ # pyright: reportPrivateUsage=false +from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry +from tagstudio.i18n.translations import Translations from tagstudio.qt.controllers.inspector import Inspector from tagstudio.qt.qt_driver 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 = Inspector(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 + )