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
4 changes: 4 additions & 0 deletions src/tagstudio/qt/controllers/preview_panel_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
33 changes: 33 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,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)
Copy link
Collaborator

Choose a reason for hiding this comment

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

this is fragile in regards to refactoring and generally regarded as bad practice since it violates separation of concerns.

(When a private variable really needs to accessed from outside of the declaring class it should be marked with a single underscode in the beginning instead, altough it should be carefully considered whether it makes sense in each specific case since it can lead to spaghetti code where every thing is interdependent and it this becomes hard to make changes without unforeseen consequences)

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)
Comment on lines +61 to +67
Copy link
Collaborator

Choose a reason for hiding this comment

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

this violates the core idea of MVC1. UI element related stuff should be handled on the view side not on the controller side. I would suggest moving this to TagBoxWidgetView.set_tags since that is called right before anyway (would also fix the issue from my previous comment, about the access to the private field)

Footnotes

  1. See the Style Guide for an explanation


@override
def _on_click(self, tag: Tag) -> None: # type: ignore[misc]
match self.__driver.settings.tag_click_action:
Expand Down
129 changes: 108 additions & 21 deletions src/tagstudio/qt/mixed/field_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Copy link
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."""
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)
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Comment on lines +518 to +522
Copy link
Collaborator

Choose a reason for hiding this comment

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

this seems like it would cause the wrong title to be shown if inner_widget was previously a non-mixed one, since it doesn't set the title in that case


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 = "<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
10 changes: 4 additions & 6 deletions src/tagstudio/qt/views/preview_panel_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
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 @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(),
Expand Down
3 changes: 2 additions & 1 deletion src/tagstudio/resources/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<b>{count}</b> Items Selected",
"preview.multiple_selection": "<b>{count}</b> Items Selected<br><i>Showing tags shared by all selected entries</i>",
"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",
Expand Down
8 changes: 3 additions & 5 deletions tests/qt/test_field_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,16 @@ 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
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)

# 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):
Expand Down
8 changes: 8 additions & 0 deletions tests/qt/test_preview_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


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