From 4f93844c079d6d5186648d31bc5bf7089ecb51d1 Mon Sep 17 00:00:00 2001 From: Bob Bobs Date: Mon, 8 Dec 2025 14:26:57 -0700 Subject: [PATCH 1/2] fix: persist entry selection across pages and save scroll positions --- src/tagstudio/core/library/alchemy/enums.py | 5 +- .../alchemy/registries/dupe_files_registry.py | 14 +-- src/tagstudio/qt/thumb_grid_layout.py | 109 ++++------------ src/tagstudio/qt/ts_qt.py | 117 ++++++++++++++---- 4 files changed, 130 insertions(+), 115 deletions(-) diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py index 3523b810c..15e6efa93 100644 --- a/src/tagstudio/core/library/alchemy/enums.py +++ b/src/tagstudio/core/library/alchemy/enums.py @@ -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 diff --git a/src/tagstudio/core/library/alchemy/registries/dupe_files_registry.py b/src/tagstudio/core/library/alchemy/registries/dupe_files_registry.py index d99a4f498..c1b7b549e 100644 --- a/src/tagstudio/core/library/alchemy/registries/dupe_files_registry.py +++ b/src/tagstudio/core/library/alchemy/registries/dupe_files_registry.py @@ -4,9 +4,9 @@ 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 logger = structlog.get_logger() @@ -28,7 +28,7 @@ def refresh_dupe_files(self, results_filepath: str | Path): A duplicate file is defined as an identical or near-identical file as determined by a DupeGuru results file. """ - library_dir = self.library.library_dir + library_dir = unwrap(self.library.library_dir) if not isinstance(results_filepath, Path): results_filepath = Path(results_filepath) @@ -51,16 +51,12 @@ def refresh_dupe_files(self, results_filepath: str | Path): # 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 diff --git a/src/tagstudio/qt/thumb_grid_layout.py b/src/tagstudio/qt/thumb_grid_layout.py index 27ad31145..7427d48d6 100644 --- a/src/tagstudio/qt/thumb_grid_layout.py +++ b/src/tagstudio/qt/thumb_grid_layout.py @@ -3,7 +3,7 @@ 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 +19,9 @@ 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 +29,6 @@ def __init__(self, driver: "QtDriver", scroll_area: QScrollArea) -> None: 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 +46,14 @@ def __init__(self, driver: "QtDriver", scroll_area: QScrollArea) -> None: # _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,80 +84,10 @@ def set_entries(self, entry_ids: list[int]): 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 - - 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 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) def add_tags(self, entry_ids: list[int], tag_ids: list[int]): for tag_id in tag_ids: @@ -263,12 +194,24 @@ def setGeometry(self, arg__1: QRect) -> None: 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 +306,7 @@ def setGeometry(self, arg__1: QRect) -> None: 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]) diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 34dec16fa..832f5e9a2 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -17,6 +17,7 @@ import sys import time from argparse import Namespace +from collections import OrderedDict from pathlib import Path from queue import Queue from shutil import which @@ -205,7 +206,8 @@ def __init__(self, args: Namespace): 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 @@ -257,7 +259,13 @@ def __init__(self, args: Namespace): @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()) @@ -563,6 +571,16 @@ def create_about_modal(): 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 @@ -754,6 +772,7 @@ def close_library(self, is_shutdown: bool = False): self.main_window.setWindowTitle(self.base_title) self.frame_content.clear() + self._selected.clear() if self.color_manager_panel: self.color_manager_panel.reset() @@ -848,7 +867,7 @@ def add_tag_action_callback(self): 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() @@ -857,7 +876,7 @@ def select_all_action_callback(self): 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() @@ -865,7 +884,7 @@ def select_inverse_action_callback(self): 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() @@ -1199,16 +1218,16 @@ def mouse_navigation(self, event: QMouseEvent): 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: @@ -1269,12 +1288,12 @@ def toggle_item_selection(self, item_id: int, append: bool, bridge: bool): """ 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() @@ -1389,7 +1408,14 @@ def update_thumbs(self): 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() @@ -1404,7 +1430,7 @@ def update_badges(self, badge_values: dict[BadgeType, bool], origin_id: int, add 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] + item_ids = self.selected if (not origin_id or origin_id in self._selected) else [origin_id] pending_entries: dict[BadgeType, list[int]] = {} logger.info( @@ -1459,8 +1485,7 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None: # 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() @@ -1475,9 +1500,17 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None: # 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: @@ -1693,3 +1726,45 @@ def drag_move_event(self, event: QDragMoveEvent): 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() From 0218fea894a53730a8efbbbc01460d843d93e6a4 Mon Sep 17 00:00:00 2001 From: Bob Bobs Date: Tue, 9 Dec 2025 16:21:59 -0700 Subject: [PATCH 2/2] fix: add badges to all selected entries not just visible ones --- src/tagstudio/qt/mixed/item_thumb.py | 12 +++++------- src/tagstudio/qt/thumb_grid_layout.py | 7 ++++--- src/tagstudio/qt/ts_qt.py | 15 +++++++-------- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/tagstudio/qt/mixed/item_thumb.py b/src/tagstudio/qt/mixed/item_thumb.py index d2f1f9856..49a9858ff 100644 --- a/src/tagstudio/qt/mixed/item_thumb.py +++ b/src/tagstudio/qt/mixed/item_thumb.py @@ -496,13 +496,11 @@ def toggle_item_tag( 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] diff --git a/src/tagstudio/qt/thumb_grid_layout.py b/src/tagstudio/qt/thumb_grid_layout.py index 7427d48d6..eec9fbf82 100644 --- a/src/tagstudio/qt/thumb_grid_layout.py +++ b/src/tagstudio/qt/thumb_grid_layout.py @@ -1,5 +1,6 @@ import math import time +from collections.abc import Iterable from pathlib import Path from typing import TYPE_CHECKING, Any, override @@ -89,15 +90,15 @@ def update_selected(self): value = item_thumb.item_id in self.driver._selected item_thumb.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: diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 832f5e9a2..f5f9f64b1 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -1430,8 +1430,11 @@ def update_badges(self, badge_values: dict[BadgeType, bool], origin_id: int, add 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", @@ -1440,12 +1443,9 @@ def update_badges(self, badge_values: dict[BadgeType, bool], origin_id: int, add 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) @@ -1454,10 +1454,9 @@ def update_badges(self, badge_values: dict[BadgeType, bool], origin_id: int, add 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: