Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/tagstudio/qt/controllers/preview_panel_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
23 changes: 23 additions & 0 deletions src/tagstudio/qt/controllers/tag_box_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
208 changes: 179 additions & 29 deletions src/tagstudio/qt/mixed/field_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import (
QFrame,
QGraphicsOpacityEffect,
QHBoxLayout,
QMessageBox,
QScrollArea,
Expand Down Expand Up @@ -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):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In many other cases in the codebase functionality that can be done for either one or several items is implemented in a single method that checks whether a list or a single item was provided (or if the list only has one item). Doing the same for update_from_entries and update_from_entry would simplify the call in several places

"""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
Expand All @@ -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."""
Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand All @@ -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)
Expand Down Expand Up @@ -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
):
Expand All @@ -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 = "<i>Mixed Data</i>"
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()
Expand Down
18 changes: 17 additions & 1 deletion src/tagstudio/qt/mixed/tag_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 8 additions & 6 deletions src/tagstudio/qt/views/preview_panel_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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)
Expand All @@ -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()
Expand Down
Loading