diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py
index 0cf666198..ba62c1fad 100644
--- a/src/tagstudio/qt/controllers/preview_panel_controller.py
+++ b/src/tagstudio/qt/controllers/preview_panel_controller.py
@@ -38,10 +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])
+ 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])
+ 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 2a5865d8b..70bfc6162 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,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: # 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..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,15 +106,117 @@ 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)
+ 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):
+ """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):
+ """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_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 _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_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
- ):
+ 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
@@ -134,10 +237,10 @@ def update_granular(
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."""
@@ -157,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.
@@ -240,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.
@@ -258,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)
@@ -398,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
):
@@ -419,32 +562,39 @@ 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)
- 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()
+ 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)
+ else:
+ inner_widget.set_mixed_only(False)
+
+ 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/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 5ae7004cd..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.
@@ -158,6 +162,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 +172,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/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 8b84af737..1159bd4f0 100644
--- a/src/tagstudio/resources/translations/en.json
+++ b/src/tagstudio/resources/translations/en.json
@@ -248,8 +248,11 @@
"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 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 2b9921146..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
@@ -36,8 +37,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 +44,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):
@@ -185,3 +184,26 @@ def test_custom_tag_category(qt_driver: QtDriver, library: Library, entry_full:
assert container.title != "