Skip to content

Commit ccac025

Browse files
committed
feat(ui): add box selection for thumbnail grid
1 parent 2ea2aad commit ccac025

6 files changed

Lines changed: 206 additions & 28 deletions

File tree

‎src/tagstudio/qt/controllers/item_thumb.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,7 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None:
467467
pos = event.position().toPoint()
468468
self._set_image_hovered(self._image_rect().contains(pos))
469469
if self._press_pos is None or event.buttons() != Qt.MouseButton.LeftButton:
470-
return
470+
return super().mouseMoveEvent(event)
471471
if (pos - self._press_pos).manhattanLength() < QApplication.startDragDistance():
472472
return
473473

‎src/tagstudio/qt/controllers/main_window.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -694,7 +694,7 @@ def setup_entry_list(self, driver: QtDriver):
694694
self.entry_list_container = QWidget()
695695
self.entry_list_layout = QVBoxLayout(self.entry_list_container)
696696
self.entry_list_layout.setSpacing(0)
697-
self.entry_list_layout.setContentsMargins(WIN_PAD, 0, 0, 0)
697+
self.entry_list_layout.setContentsMargins(0, 0, 0, 0)
698698

699699
self.entry_scroll_area = QScrollArea()
700700
self.entry_scroll_area.setObjectName("entry_scroll_area")
@@ -705,15 +705,18 @@ def setup_entry_list(self, driver: QtDriver):
705705
self.entry_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
706706

707707
self.thumb_grid = ThumbGrid(driver, self.entry_scroll_area)
708-
# Padding so floating pagination bar doesn't block bottom of scroll contents
709-
self.thumb_grid.setContentsMargins(0, 0, 0, Pagination.HEIGHT)
708+
# Left padding is part of the grid so box selections can start from it.
709+
# Bottom padding so floating pagination bar doesn't block bottom of scroll contents.
710+
self.thumb_grid.setContentsMargins(WIN_PAD, 0, 0, Pagination.HEIGHT)
710711
self.entry_scroll_area.setWidget(self.thumb_grid)
711712

712713
self.banners = BannerStack()
714+
self.banners.setContentsMargins(WIN_PAD, 0, 0, 0)
713715
self.entry_list_layout.addWidget(self.banners)
714716

715717
self.entry_list_layout.addWidget(self.entry_scroll_area)
716718
self.landing_widget = LandingWidget(driver, self.devicePixelRatio())
719+
self.landing_widget.setContentsMargins(WIN_PAD, 0, 0, 0)
717720
self.entry_list_layout.addWidget(self.landing_widget)
718721
self.pagination = Pagination(self.entry_list_container)
719722
self.central_content_layout.addWidget(self.entry_list_container)

‎src/tagstudio/qt/controllers/thumb_grid.py‎

Lines changed: 114 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
from typing import TYPE_CHECKING, override
88

99
import structlog
10-
from PySide6.QtCore import QEvent, QObject, Qt, QThreadPool, QTimer, Signal
10+
from PySide6.QtCore import QEvent, QObject, QPoint, QRect, Qt, QThreadPool, QTimer, Signal
1111
from PySide6.QtGui import QColor, QKeyEvent, QMouseEvent, QPixmap
12-
from PySide6.QtWidgets import QScrollArea, QWidget
12+
from PySide6.QtWidgets import QApplication, QScrollArea, QWidget
1313

1414
from tagstudio.core.library.alchemy.enums import ItemType
1515
from tagstudio.core.library.alchemy.models import Entry
@@ -18,6 +18,7 @@
1818
from tagstudio.qt.controllers.item_thumb import ItemThumb
1919
from tagstudio.qt.qt_file_renderer import QtFileRenderer
2020
from tagstudio.qt.views.layouts.thumb_grid_layout import ThumbGridLayout
21+
from tagstudio.qt.views.styles.stylesheets import box_selection_style
2122

2223
if TYPE_CHECKING:
2324
from tagstudio.qt.qt_driver import QtDriver
@@ -28,9 +29,10 @@
2829
class ThumbGrid(QWidget):
2930
"""A scrollable grid of item thumbnails."""
3031

31-
DURATION_DELAY_MS = 200 # Allows fast scrolling without getting lagged by duration labels
32-
RESIZE_RENDER_DELAY_MS = 200 # Allows more responsive rapid resizing
33-
SHOW_THUMBS_DELAY_MS = 200 # Allows quick renders to appear together without a loading flash
32+
_BOX_SCROLL_INTERVAL_MS = 16 # Scrolls every interval while box selecting past an edge
33+
_DURATION_DELAY_MS = 200 # Allows fast scrolling without getting lagged by duration labels
34+
_RESIZE_RENDER_DELAY_MS = 200 # Allows more responsive rapid resizing
35+
_SHOW_THUMBS_DELAY_MS = 200 # Allows quick renders to appear together without a loading flash
3436

3537
_duration_loaded = Signal(Path, object)
3638
_entries_fetched = Signal(int, object, object)
@@ -42,17 +44,29 @@ def __init__(self, driver: QtDriver, scroll_area: QScrollArea) -> None:
4244
self._driver = driver
4345
self._renderer = QtFileRenderer(driver.lib, driver.settings)
4446

47+
self._box_scroll_timer = QTimer(self)
48+
self._box_scroll_timer.setInterval(self._BOX_SCROLL_INTERVAL_MS)
4549
self._duration_timer = QTimer(self)
46-
self._duration_timer.setInterval(self.DURATION_DELAY_MS)
50+
self._duration_timer.setInterval(self._DURATION_DELAY_MS)
4751
self._duration_timer.setSingleShot(True)
4852
self._resize_render_timer = QTimer(self)
49-
self._resize_render_timer.setInterval(self.RESIZE_RENDER_DELAY_MS)
53+
self._resize_render_timer.setInterval(self._RESIZE_RENDER_DELAY_MS)
5054
self._resize_render_timer.setSingleShot(True)
5155
self._show_thumbs_timer = QTimer(self)
52-
self._show_thumbs_timer.setInterval(self.SHOW_THUMBS_DELAY_MS)
56+
self._show_thumbs_timer.setInterval(self._SHOW_THUMBS_DELAY_MS)
5357
self._show_thumbs_timer.setSingleShot(True)
5458

59+
self._box_rect = QWidget(self)
60+
self._box_rect.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)
61+
self._box_rect.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
62+
self._box_rect.setStyleSheet(box_selection_style())
63+
self._box_rect.hide()
64+
65+
self._box_cursor = QPoint()
66+
self._box_entries: dict[int, None] = {} # Entries in the selection box, in grid order
67+
self._box_origin: QPoint | None = None
5568
self._changed_while_fetching: set[int] = set()
69+
self._does_box_toggle = False # Ctrl+Drag toggles the selection instead of adding
5670
self._durations: dict[Path, float | None] = {}
5771
self._entries: dict[int, Entry] = {}
5872
self._entry_ids: list[int] = []
@@ -68,6 +82,7 @@ def __init__(self, driver: QtDriver, scroll_area: QScrollArea) -> None:
6882
self._previous_size_renders: dict[Path, QPixmap] = {}
6983
self._render_cutoff = 0.0
7084
self._renders: dict[Path, QPixmap | None] = {}
85+
self._scroll_area = scroll_area
7186
self._scroll_to: int | None = None
7287

7388
self.setLayout(ThumbGridLayout(scroll_area, self._create_thumb))
@@ -79,16 +94,86 @@ def layout(self) -> ThumbGridLayout[ItemThumb]:
7994

8095
@override
8196
def mousePressEvent(self, event: QMouseEvent) -> None:
82-
# Clear selected entries when clicking the background
8397
if (
8498
event.button() == Qt.MouseButton.LeftButton
85-
and event.modifiers() == Qt.KeyboardModifier.NoModifier
99+
and event.modifiers()
100+
in (
101+
Qt.KeyboardModifier.NoModifier,
102+
Qt.KeyboardModifier.ShiftModifier,
103+
Qt.KeyboardModifier.ControlModifier,
104+
)
86105
and isinstance(self.childAt(event.position().toPoint()), ItemThumb | None)
87-
and self._driver.selection.selected
88106
):
89-
self._driver.selection.clear()
107+
# Clear selected entries when clicking the background, unless Shift/Ctrl clicking
108+
if (
109+
event.modifiers() == Qt.KeyboardModifier.NoModifier
110+
and self._driver.selection.selected
111+
):
112+
self._driver.selection.clear()
113+
self._does_box_toggle = event.modifiers() == Qt.KeyboardModifier.ControlModifier
114+
self._box_origin = event.position().toPoint()
115+
self._box_cursor = event.globalPosition().toPoint()
116+
return
90117
super().mousePressEvent(event)
91118

119+
@override
120+
def mouseMoveEvent(self, event: QMouseEvent) -> None:
121+
# Track the box selection if it's active
122+
if self._box_origin is None:
123+
return super().mouseMoveEvent(event)
124+
self._box_cursor = event.globalPosition().toPoint()
125+
self._update_box()
126+
viewport = self._scroll_area.viewport()
127+
# Mouse cursor is inside the viewport, cancel the scroll timer
128+
if 0 <= viewport.mapFromGlobal(self._box_cursor).y() < viewport.height():
129+
self._box_scroll_timer.stop()
130+
# Mouse cursor is above or below the viewport, start scrolling in that direction on a timer
131+
elif self._box_rect.isVisible() and not self._box_scroll_timer.isActive():
132+
self._box_scroll_timer.start()
133+
134+
@override
135+
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
136+
# End the box selection, if it was active
137+
if self._box_origin is None or event.button() != Qt.MouseButton.LeftButton:
138+
return super().mouseReleaseEvent(event)
139+
if self._box_rect.isVisible():
140+
selection = self._driver.selection
141+
kept = selection.selected
142+
if self._does_box_toggle:
143+
kept = [entry_id for entry_id in kept if entry_id not in self._box_entries]
144+
added = [
145+
entry_id for entry_id in self._box_entries if not selection.is_selected(entry_id)
146+
]
147+
selection.select_items([*kept, *added])
148+
self._box_origin = None
149+
self._box_rect.hide()
150+
self._box_scroll_timer.stop()
151+
self._box_entries = {}
152+
self._update_selected()
153+
154+
def _update_box(self) -> None:
155+
"""Highlight entries in the selection box from the point clicked to the mouse cursor."""
156+
origin = unwrap(self._box_origin)
157+
end = self.mapFromGlobal(self._box_cursor)
158+
if not self._box_rect.isVisible():
159+
if (end - origin).manhattanLength() < QApplication.startDragDistance():
160+
return
161+
self._box_rect.raise_()
162+
self._box_rect.show()
163+
rect = QRect(origin, end).normalized()
164+
self._box_rect.setGeometry(rect)
165+
indices = self.layout().indices_in_rect(rect)
166+
self._box_entries = dict.fromkeys(self._entry_ids[index] for index in indices)
167+
self._update_selected()
168+
169+
def _scroll_box(self) -> None:
170+
"""Scroll towards the mouse cursor while it's box selecting past the top or bottom edge."""
171+
viewport = self._scroll_area.viewport()
172+
y = viewport.mapFromGlobal(self._box_cursor).y()
173+
scroll_bar = self._scroll_area.verticalScrollBar()
174+
scroll_bar.setValue(scroll_bar.value() + min(y, 0) + max(y - viewport.height(), 0))
175+
self._update_box()
176+
92177
def _connect_callbacks(self, scroll_area: QScrollArea) -> None:
93178
scroll_area.installEventFilter(self)
94179
scroll_area.verticalScrollBar().valueChanged.connect(lambda _: self.layout().update())
@@ -100,6 +185,7 @@ def _connect_callbacks(self, scroll_area: QScrollArea) -> None:
100185
self._renderer.updated.connect(self._on_rendered)
101186
self._resize_render_timer.timeout.connect(self._request_renders)
102187
self._show_thumbs_timer.timeout.connect(self._show_held_entries)
188+
self._box_scroll_timer.timeout.connect(self._scroll_box)
103189
self._duration_timer.timeout.connect(self._queue_durations)
104190
self._entries_fetched.connect(self._on_entries_fetched)
105191
self._duration_loaded.connect(self._on_duration_loaded)
@@ -121,7 +207,7 @@ def set_entries(self, entry_ids: list[int]) -> None:
121207
if self._fetch_pool is not None:
122208
self._fetch_pool.clear()
123209
now = time.monotonic()
124-
if now - self._last_entries_time < self.SHOW_THUMBS_DELAY_MS / 1000:
210+
if now - self._last_entries_time < self._SHOW_THUMBS_DELAY_MS / 1000:
125211
# Show entries immediately if the results are changing quickly.
126212
# Traversing pages or searching quickly feels much more responsive.
127213
self._show_thumbs_timer.stop()
@@ -240,7 +326,20 @@ def _index_in_direction(self, index: int, key: Qt.Key) -> int:
240326
def _update_selected(self) -> None:
241327
for _, thumb in self.layout().assigned_items():
242328
if thumb not in self._held:
243-
thumb.set_selected(self._driver.selection.is_selected(thumb.item_id))
329+
thumb.set_selected(self._shows_selected(thumb.item_id))
330+
331+
def _shows_selected(self, entry_id: int) -> bool:
332+
"""Whether an item shows as selected, including an unreleased selection box's changes.
333+
334+
A selection box won't truly select items until released, but items are allowed to
335+
appear selected under the box before the selection is completed.
336+
337+
NOTE: This mimics macOS Finder behavior, and prevents rapid Inspector updates.
338+
"""
339+
selected = self._driver.selection.is_selected(entry_id)
340+
if entry_id not in self._box_entries:
341+
return selected
342+
return not selected if self._does_box_toggle else True
244343

245344
def _create_thumb(self) -> ItemThumb:
246345
size = self._driver.main_window.thumb_size
@@ -285,7 +384,7 @@ def _show_entry(self, thumb: ItemThumb) -> None:
285384
self._show_loading_image(thumb)
286385
else:
287386
self._assign_entry(thumb, entry)
288-
thumb.set_selected(self._driver.selection.is_selected(thumb.item_id))
387+
thumb.set_selected(self._shows_selected(thumb.item_id))
289388

290389
def _clear_thumb(self, thumb: ItemThumb) -> None:
291390
"""Remove the entry details and image a thumbnail is showing."""

‎src/tagstudio/qt/selection_state.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def selected(self) -> list[T]:
3333

3434
@property
3535
def cursor(self) -> T | None:
36+
"""The last item clicked or moved to with arrow keys and where they continue from."""
3637
return self._selection_cursor
3738

3839
def is_selected(self, item_id: T) -> bool:
@@ -106,6 +107,16 @@ def deselect(self, item_ids: Iterable[T]) -> None:
106107
self._selection_cursor = self._selection_anchor
107108
self.changed.emit()
108109

110+
def select_items(self, item_ids: Iterable[T]) -> None:
111+
"""Set the selection to these items, setting the last one as the anchor and cursor."""
112+
previous = self.selected
113+
self._selected = dict.fromkeys(item_ids)
114+
self._existing_selection = self.selected
115+
self._selection_anchor = next(reversed(self._selected), None)
116+
self._selection_cursor = self._selection_anchor
117+
if self.selected != previous:
118+
self.changed.emit()
119+
109120
def move_selection(self, order: Sequence[T], target: int, extend: bool) -> int:
110121
"""Move or extend the selection to `order[target]`, like with an arrow key.
111122

0 commit comments

Comments
 (0)