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/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 ae8df9107..d2b48650c 100644
--- a/src/tagstudio/qt/mixed/field_containers.py
+++ b/src/tagstudio/qt/mixed/field_containers.py
@@ -111,8 +111,87 @@ 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)
+
+ # 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."""
+ 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
+ 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)
@@ -131,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:
@@ -422,29 +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)
+ entry_ids = [e.id for e in self.cached_entries]
+ self.update_from_entries(entry_ids, update_badges=True)
- inner_widget.on_update.connect(
- lambda: (self.update_from_entry(self.cached_entries[0].id, update_badges=True))
- )
- else:
- text = "Mixed Data"
- inner_widget = TextWidget("Mixed Tags", text)
- container.set_inner_widget(inner_widget)
+ inner_widget.on_update.connect(update_callback)
container.set_edit_callback()
container.set_remove_callback()
diff --git a/src/tagstudio/qt/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/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 8b84af737..94d41ba31 100644
--- a/src/tagstudio/resources/translations/en.json
+++ b/src/tagstudio/resources/translations/en.json
@@ -248,8 +248,9 @@
"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.partial_tags": "Tags (Some Entries)",
"preview.unlinked": "Unlinked",
"select.add_tag_to_selected": "Add Tag to Selected",
"select.all": "Select All",
diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py
index 2b9921146..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