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: 5 additions & 1 deletion src/tagstudio/qt/controllers/preview_panel_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,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.
Expand Down Expand Up @@ -208,6 +210,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()):
Expand Down Expand Up @@ -261,7 +265,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:
Expand Down
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 @@ -26,6 +26,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 @@ -34,6 +35,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:
Expand Down
190 changes: 160 additions & 30 deletions src/tagstudio/qt/mixed/field_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QGraphicsOpacityEffect,
QHBoxLayout,
QMessageBox,
QScrollArea,
Expand Down Expand Up @@ -101,15 +102,102 @@ 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_granular(
self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True
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,
*,
hide_leftovers: bool = True,
) -> int:
"""Individually update elements of the item preview."""
container_len: int = len(entry_fields)
container_index = 0
Expand All @@ -130,10 +218,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."""
Expand All @@ -153,6 +241,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.

Expand Down Expand Up @@ -244,6 +338,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)

def update_text_field_callback(
self, field: TextField, entry_id: int, content: dict[str, str | bool]
) -> None:
Expand Down Expand Up @@ -373,6 +476,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]})"
Expand All @@ -387,6 +496,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:
Expand All @@ -407,32 +535,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"<i>{Translations['field.mixed_data']}</i>"
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)
Expand Down
19 changes: 18 additions & 1 deletion src/tagstudio/qt/mixed/tag_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -211,6 +219,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
12 changes: 10 additions & 2 deletions src/tagstudio/qt/views/preview_thumb_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ 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__()
self._driver = driver

self.__img_button_size = (266, 266)
self.__image_ratio = 1.0
self.__render_cutoff = 0.0

self.__should_render_on_resize = False

Expand Down Expand Up @@ -148,8 +150,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:
Expand Down Expand Up @@ -234,10 +239,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(),
Expand Down
Loading