mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-02-10 11:52:28 +00:00
fix: persist entry selection across pages and save scroll positions (#1248)
* fix: persist entry selection across pages and save scroll positions * fix: add badges to all selected entries not just visible ones
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import enum
|
||||
import random
|
||||
from dataclasses import dataclass, replace
|
||||
from dataclasses import dataclass, field, replace
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
@@ -78,8 +78,9 @@ class BrowsingState:
|
||||
"""Represent a state of the Library grid view."""
|
||||
|
||||
page_index: int = 0
|
||||
page_positions: dict[int, int] = field(default_factory=dict)
|
||||
sorting_mode: SortingModeEnum = SortingModeEnum.DATE_ADDED
|
||||
ascending: bool = True
|
||||
ascending: bool = False
|
||||
random_seed: float = 0
|
||||
|
||||
show_hidden_entries: bool = False
|
||||
|
||||
@@ -4,7 +4,6 @@ from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import BrowsingState
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
@@ -52,16 +51,12 @@ class DupeFilesRegistry:
|
||||
# The file is not in the library directory
|
||||
continue
|
||||
|
||||
results = self.library.search_library(
|
||||
BrowsingState.from_path(path_relative), 500
|
||||
)
|
||||
entries = self.library.get_entries(results.ids)
|
||||
|
||||
if not results:
|
||||
entry = self.library.get_entry_full_by_path(path_relative)
|
||||
if entry is None:
|
||||
# file not in library
|
||||
continue
|
||||
|
||||
files.append(entries[0])
|
||||
files.append(entry)
|
||||
|
||||
if not len(files) > 1:
|
||||
# only one file in the group, nothing to do
|
||||
|
||||
@@ -496,13 +496,11 @@ class ItemThumb(FlowWidget):
|
||||
toggle_value: bool,
|
||||
tag_id: int,
|
||||
):
|
||||
if entry_id in self.driver.selected:
|
||||
if len(self.driver.selected) == 1:
|
||||
self.driver.main_window.preview_panel.field_containers_widget.update_toggled_tag(
|
||||
tag_id, toggle_value
|
||||
)
|
||||
else:
|
||||
pass
|
||||
selected = self.driver._selected
|
||||
if len(selected) == 1 and entry_id in selected:
|
||||
self.driver.main_window.preview_panel.field_containers_widget.update_toggled_tag(
|
||||
tag_id, toggle_value
|
||||
)
|
||||
|
||||
@override
|
||||
def mouseMoveEvent(self, event: QMouseEvent) -> None: # type: ignore[misc]
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
from PySide6.QtCore import QPoint, QRect, QSize
|
||||
from PySide6.QtCore import QPoint, QRect, QSize, Signal
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QLayout, QLayoutItem, QScrollArea
|
||||
|
||||
@@ -19,6 +20,9 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class ThumbGridLayout(QLayout):
|
||||
# Id of first visible entry
|
||||
visible_changed = Signal(int)
|
||||
|
||||
def __init__(self, driver: "QtDriver", scroll_area: QScrollArea) -> None:
|
||||
super().__init__(None)
|
||||
self.driver: QtDriver = driver
|
||||
@@ -26,10 +30,6 @@ class ThumbGridLayout(QLayout):
|
||||
|
||||
self._item_thumbs: list[ItemThumb] = []
|
||||
self._items: list[QLayoutItem] = []
|
||||
# Entry.id -> _entry_ids[index]
|
||||
self._selected: dict[int, int] = {}
|
||||
# _entry_ids[index]
|
||||
self._last_selected: int | None = None
|
||||
|
||||
self._entry_ids: list[int] = []
|
||||
self._entries: dict[int, Entry] = {}
|
||||
@@ -47,12 +47,14 @@ class ThumbGridLayout(QLayout):
|
||||
# _entry_ids[StartIndex:EndIndex]
|
||||
self._last_page_update: tuple[int, int] | None = None
|
||||
|
||||
self._scroll_to: int | None = None
|
||||
|
||||
def scroll_to(self, entry_id: int):
|
||||
self._scroll_to = entry_id
|
||||
|
||||
def set_entries(self, entry_ids: list[int]):
|
||||
self.scroll_area.verticalScrollBar().setValue(0)
|
||||
|
||||
self._selected.clear()
|
||||
self._last_selected = None
|
||||
|
||||
self._entry_ids = entry_ids
|
||||
self._entries.clear()
|
||||
self._tag_entries.clear()
|
||||
@@ -83,90 +85,20 @@ class ThumbGridLayout(QLayout):
|
||||
|
||||
self._last_page_update = None
|
||||
|
||||
def select_all(self):
|
||||
self._selected.clear()
|
||||
for index, id in enumerate(self._entry_ids):
|
||||
self._selected[id] = index
|
||||
self._last_selected = index
|
||||
def update_selected(self):
|
||||
for item_thumb in self._item_thumbs:
|
||||
value = item_thumb.item_id in self.driver._selected
|
||||
item_thumb.thumb_button.set_selected(value)
|
||||
|
||||
for entry_id in self._entry_items:
|
||||
self._set_selected(entry_id)
|
||||
|
||||
def select_inverse(self):
|
||||
selected = {}
|
||||
for index, id in enumerate(self._entry_ids):
|
||||
if id not in self._selected:
|
||||
selected[id] = index
|
||||
self._last_selected = index
|
||||
|
||||
for id in self._selected:
|
||||
if id not in selected:
|
||||
self._set_selected(id, value=False)
|
||||
for id in selected:
|
||||
self._set_selected(id)
|
||||
|
||||
self._selected = selected
|
||||
|
||||
def select_entry(self, entry_id: int):
|
||||
if entry_id in self._selected:
|
||||
index = self._selected.pop(entry_id)
|
||||
if index == self._last_selected:
|
||||
self._last_selected = None
|
||||
self._set_selected(entry_id, value=False)
|
||||
else:
|
||||
try:
|
||||
index = self._entry_ids.index(entry_id)
|
||||
except ValueError:
|
||||
index = -1
|
||||
|
||||
self._selected[entry_id] = index
|
||||
self._last_selected = index
|
||||
self._set_selected(entry_id)
|
||||
|
||||
def select_to_entry(self, entry_id: int):
|
||||
index = self._entry_ids.index(entry_id)
|
||||
if len(self._selected) == 0:
|
||||
self.select_entry(entry_id)
|
||||
return
|
||||
if self._last_selected is None:
|
||||
self._last_selected = min(self._selected.values(), key=lambda i: abs(index - i))
|
||||
|
||||
start = self._last_selected
|
||||
self._last_selected = index
|
||||
|
||||
if start > index:
|
||||
index, start = start, index
|
||||
else:
|
||||
index += 1
|
||||
|
||||
for i in range(start, index):
|
||||
entry_id = self._entry_ids[i]
|
||||
self._selected[entry_id] = i
|
||||
self._set_selected(entry_id)
|
||||
|
||||
def clear_selected(self):
|
||||
for entry_id in self._entry_items:
|
||||
self._set_selected(entry_id, value=False)
|
||||
|
||||
self._selected.clear()
|
||||
self._last_selected = None
|
||||
|
||||
def _set_selected(self, entry_id: int, value: bool = True):
|
||||
if entry_id not in self._entry_items:
|
||||
return
|
||||
index = self._entry_items[entry_id]
|
||||
if index < len(self._item_thumbs):
|
||||
self._item_thumbs[index].thumb_button.set_selected(value)
|
||||
|
||||
def add_tags(self, entry_ids: list[int], tag_ids: list[int]):
|
||||
def add_tags(self, entry_ids: Iterable[int], tag_ids: Iterable[int]):
|
||||
for tag_id in tag_ids:
|
||||
self._tag_entries.setdefault(tag_id, set()).update(entry_ids)
|
||||
|
||||
def remove_tags(self, entry_ids: list[int], tag_ids: list[int]):
|
||||
def remove_tags(self, entry_ids: Iterable[int], tag_ids: Iterable[int]):
|
||||
for tag_id in tag_ids:
|
||||
self._tag_entries.setdefault(tag_id, set()).difference_update(entry_ids)
|
||||
|
||||
def _fetch_entries(self, ids: list[int]):
|
||||
def _fetch_entries(self, ids: Iterable[int]):
|
||||
ids = [id for id in ids if id not in self._entries]
|
||||
entries = self.driver.lib.get_entries(ids)
|
||||
for entry in entries:
|
||||
@@ -263,12 +195,24 @@ class ThumbGridLayout(QLayout):
|
||||
per_row, width_offset, height_offset = self._size(rect.right())
|
||||
view_height = self.parentWidget().parentWidget().height()
|
||||
offset = self.scroll_area.verticalScrollBar().value()
|
||||
if self._scroll_to is not None:
|
||||
try:
|
||||
index = self._entry_ids.index(self._scroll_to)
|
||||
value = (index // per_row) * height_offset
|
||||
self.scroll_area.verticalScrollBar().setMaximum(value)
|
||||
self.scroll_area.verticalScrollBar().setSliderPosition(value)
|
||||
offset = value
|
||||
except ValueError:
|
||||
pass
|
||||
self._scroll_to = None
|
||||
|
||||
visible_rows = math.ceil((view_height + (offset % height_offset)) / height_offset)
|
||||
offset = int(offset / height_offset)
|
||||
start = offset * per_row
|
||||
end = start + (visible_rows * per_row)
|
||||
|
||||
self.visible_changed.emit(self._entry_ids[start])
|
||||
|
||||
# Load closest off screen rows
|
||||
start -= per_row * 3
|
||||
end += per_row * 3
|
||||
@@ -363,7 +307,7 @@ class ThumbGridLayout(QLayout):
|
||||
entry_id = self._entry_ids[i]
|
||||
item_index = self._entry_items[entry_id]
|
||||
item_thumb = self._item_thumbs[item_index]
|
||||
item_thumb.thumb_button.set_selected(entry_id in self._selected)
|
||||
item_thumb.thumb_button.set_selected(entry_id in self.driver._selected)
|
||||
|
||||
item_thumb.assign_badge(BadgeType.ARCHIVED, entry_id in self._tag_entries[TAG_ARCHIVED])
|
||||
item_thumb.assign_badge(BadgeType.FAVORITE, entry_id in self._tag_entries[TAG_FAVORITE])
|
||||
|
||||
@@ -17,6 +17,7 @@ import re
|
||||
import sys
|
||||
import time
|
||||
from argparse import Namespace
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from shutil import which
|
||||
@@ -206,7 +207,8 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.lib = Library()
|
||||
self.rm: ResourceManager = ResourceManager()
|
||||
self.args = args
|
||||
self.frame_content: list[int] = [] # List of Entry IDs on the current page
|
||||
self.frame_content: list[int] = [] # List of Entry IDs for the current query
|
||||
self._selected: OrderedDict[int, None] = OrderedDict()
|
||||
self.pages_count = 0
|
||||
|
||||
self.scrollbar_pos = 0
|
||||
@@ -258,7 +260,13 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
@property
|
||||
def selected(self) -> list[int]:
|
||||
return list(self.main_window.thumb_layout._selected.keys())
|
||||
return list(self._selected.keys())
|
||||
|
||||
@property
|
||||
def last_selected(self) -> int | None:
|
||||
if len(self._selected) == 0:
|
||||
return None
|
||||
return reversed(self._selected).__next__()
|
||||
|
||||
def __reset_navigation(self) -> None:
|
||||
self.browsing_history = History(BrowsingState.show_all())
|
||||
@@ -564,6 +572,16 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
self.main_window.search_field.textChanged.connect(self.update_completions_list)
|
||||
|
||||
def on_visible_changed(entry_id: int | None):
|
||||
current = self.browsing_history.current
|
||||
page_index = current.page_index
|
||||
if entry_id is None:
|
||||
current.page_positions.pop(page_index)
|
||||
else:
|
||||
current.page_positions[page_index] = entry_id
|
||||
|
||||
self.main_window.thumb_layout.visible_changed.connect(on_visible_changed)
|
||||
|
||||
self.archived_updated.connect(
|
||||
lambda hidden: self.update_badges(
|
||||
{BadgeType.ARCHIVED: hidden}, origin_id=0, add_tags=False
|
||||
@@ -758,6 +776,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.main_window.setWindowTitle(self.base_title)
|
||||
|
||||
self.frame_content.clear()
|
||||
self._selected.clear()
|
||||
if self.color_manager_panel:
|
||||
self.color_manager_panel.reset()
|
||||
|
||||
@@ -852,7 +871,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
def select_all_action_callback(self):
|
||||
"""Set the selection to all visible items."""
|
||||
self.main_window.thumb_layout.select_all()
|
||||
self.select_all()
|
||||
|
||||
self.set_clipboard_menu_viability()
|
||||
self.set_select_actions_visibility()
|
||||
@@ -861,7 +880,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
def select_inverse_action_callback(self):
|
||||
"""Invert the selection of all visible items."""
|
||||
self.main_window.thumb_layout.select_inverse()
|
||||
self.select_inverse()
|
||||
|
||||
self.set_clipboard_menu_viability()
|
||||
self.set_select_actions_visibility()
|
||||
@@ -869,7 +888,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.main_window.preview_panel.set_selection(self.selected, update_preview=False)
|
||||
|
||||
def clear_select_action_callback(self):
|
||||
self.main_window.thumb_layout.clear_selected()
|
||||
self.clear_selected()
|
||||
|
||||
self.set_select_actions_visibility()
|
||||
self.set_clipboard_menu_viability()
|
||||
@@ -1195,16 +1214,16 @@ class QtDriver(DriverMixin, QObject):
|
||||
def page_move(self, value: int, absolute=False) -> None:
|
||||
logger.info("page_move", value=value, absolute=absolute)
|
||||
|
||||
current = self.browsing_history.current
|
||||
if not absolute:
|
||||
value += self.browsing_history.current.page_index
|
||||
|
||||
self.browsing_history.push(
|
||||
self.browsing_history.current.with_page_index(clamp(value, 0, self.pages_count - 1))
|
||||
)
|
||||
|
||||
# TODO: Re-allow selecting entries across multiple pages at once.
|
||||
# This works fine with additive selection but becomes a nightmare with bridging.
|
||||
current.page_index += value
|
||||
else:
|
||||
current.page_index = value
|
||||
current.page_index = clamp(current.page_index, 0, self.pages_count - 1)
|
||||
|
||||
# TODO: The back mouse button will no longer move to the previous page and
|
||||
# instead goto the previous query passing a new state to update_browsing_state
|
||||
# will get this behaviour back but would mess with persisting page scroll positions
|
||||
self.update_browsing_state()
|
||||
|
||||
def navigation_callback(self, delta: int) -> None:
|
||||
@@ -1265,12 +1284,12 @@ class QtDriver(DriverMixin, QObject):
|
||||
"""
|
||||
logger.info("[QtDriver] Selecting Items:", item_id=item_id, append=append, bridge=bridge)
|
||||
if append:
|
||||
self.main_window.thumb_layout.select_entry(item_id)
|
||||
self.select_entry(item_id)
|
||||
elif bridge:
|
||||
self.main_window.thumb_layout.select_to_entry(item_id)
|
||||
self.select_to_entry(item_id)
|
||||
else:
|
||||
self.main_window.thumb_layout.clear_selected()
|
||||
self.main_window.thumb_layout.select_entry(item_id)
|
||||
self.clear_selected()
|
||||
self.select_entry(item_id)
|
||||
|
||||
self.set_clipboard_menu_viability()
|
||||
self.set_select_actions_visibility()
|
||||
@@ -1385,7 +1404,14 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.thumb_job_queue.all_tasks_done.notify_all()
|
||||
self.thumb_job_queue.not_full.notify_all()
|
||||
|
||||
self.main_window.thumb_layout.set_entries(self.frame_content)
|
||||
page_size = (
|
||||
len(self.frame_content) if self.settings.infinite_scroll else self.settings.page_size
|
||||
)
|
||||
page = self.browsing_history.current.page_index
|
||||
start = page * page_size
|
||||
end = min(start + page_size, len(self.frame_content))
|
||||
|
||||
self.main_window.thumb_layout.set_entries(self.frame_content[start:end])
|
||||
self.main_window.thumb_layout.update()
|
||||
self.main_window.update()
|
||||
|
||||
@@ -1400,8 +1426,11 @@ class QtDriver(DriverMixin, QObject):
|
||||
add_tags(bool): Flag determining if tags associated with the badges need to be added to
|
||||
the items. Defaults to True.
|
||||
"""
|
||||
item_ids = self.selected if (not origin_id or origin_id in self.selected) else [origin_id]
|
||||
pending_entries: dict[BadgeType, list[int]] = {}
|
||||
entry_ids = (
|
||||
set(self._selected.keys())
|
||||
if (origin_id == 0 or origin_id in self._selected)
|
||||
else {origin_id}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[QtDriver][update_badges] Updating ItemThumb badges",
|
||||
@@ -1410,12 +1439,9 @@ class QtDriver(DriverMixin, QObject):
|
||||
add_tags=add_tags,
|
||||
)
|
||||
for it in self.main_window.thumb_layout._item_thumbs:
|
||||
if it.item_id in item_ids:
|
||||
if it.item_id in entry_ids:
|
||||
for badge_type, value in badge_values.items():
|
||||
if add_tags:
|
||||
if not pending_entries.get(badge_type):
|
||||
pending_entries[badge_type] = []
|
||||
pending_entries[badge_type].append(it.item_id)
|
||||
it.toggle_item_tag(it.item_id, value, BADGE_TAGS[badge_type])
|
||||
it.assign_badge(badge_type, value)
|
||||
|
||||
@@ -1424,10 +1450,9 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
logger.info(
|
||||
"[QtDriver][update_badges] Adding tags to updated entries",
|
||||
pending_entries=pending_entries,
|
||||
pending_entries=entry_ids,
|
||||
)
|
||||
for badge_type, value in badge_values.items():
|
||||
entry_ids = pending_entries.get(badge_type, [])
|
||||
tag_ids = [BADGE_TAGS[badge_type]]
|
||||
|
||||
if value:
|
||||
@@ -1455,8 +1480,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
# search the library
|
||||
start_time = time.time()
|
||||
Ignore.get_patterns(self.lib.library_dir, include_global=True)
|
||||
page_size = 0 if self.settings.infinite_scroll else self.settings.page_size
|
||||
results = self.lib.search_library(self.browsing_history.current, page_size)
|
||||
results = self.lib.search_library(self.browsing_history.current, page_size=0)
|
||||
logger.info("items to render", count=len(results))
|
||||
end_time = time.time()
|
||||
|
||||
@@ -1471,9 +1495,17 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
# update page content
|
||||
self.frame_content = results.ids
|
||||
page_index = self.browsing_history.current.page_index
|
||||
if state is None:
|
||||
entry_id = self.browsing_history.current.page_positions.get(page_index)
|
||||
else:
|
||||
entry_id = self.last_selected
|
||||
if entry_id is not None:
|
||||
self.main_window.thumb_layout.scroll_to(entry_id)
|
||||
self.update_thumbs()
|
||||
|
||||
# update pagination
|
||||
page_size = 0 if self.settings.infinite_scroll else self.settings.page_size
|
||||
if page_size > 0:
|
||||
self.pages_count = math.ceil(results.total_count / page_size)
|
||||
else:
|
||||
@@ -1689,3 +1721,45 @@ class QtDriver(DriverMixin, QObject):
|
||||
event.accept()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def select_all(self):
|
||||
self._selected = OrderedDict.fromkeys(self.frame_content)
|
||||
self.main_window.thumb_layout.update_selected()
|
||||
|
||||
def select_inverse(self):
|
||||
selected = OrderedDict()
|
||||
for id in self.frame_content:
|
||||
if id not in self._selected:
|
||||
selected[id] = None
|
||||
|
||||
self._selected = selected
|
||||
self.main_window.thumb_layout.update_selected()
|
||||
|
||||
def select_entry(self, entry_id: int):
|
||||
if entry_id in self._selected:
|
||||
self._selected.pop(entry_id)
|
||||
else:
|
||||
self._selected[entry_id] = None
|
||||
self.main_window.thumb_layout.update_selected()
|
||||
|
||||
def select_to_entry(self, entry_id: int):
|
||||
if len(self._selected) == 0:
|
||||
self.select_entry(entry_id)
|
||||
return
|
||||
last_selected = reversed(self._selected).__next__()
|
||||
start = self.frame_content.index(last_selected)
|
||||
end = self.frame_content.index(entry_id)
|
||||
|
||||
if start > end:
|
||||
end, start = start, end
|
||||
else:
|
||||
end += 1
|
||||
|
||||
for i in range(start, end):
|
||||
entry_id = self.frame_content[i]
|
||||
self._selected[entry_id] = None
|
||||
self.main_window.thumb_layout.update_selected()
|
||||
|
||||
def clear_selected(self):
|
||||
self._selected.clear()
|
||||
self.main_window.thumb_layout.update_selected()
|
||||
|
||||
Reference in New Issue
Block a user