77from typing import TYPE_CHECKING , override
88
99import 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
1111from PySide6 .QtGui import QColor , QKeyEvent , QMouseEvent , QPixmap
12- from PySide6 .QtWidgets import QScrollArea , QWidget
12+ from PySide6 .QtWidgets import QApplication , QScrollArea , QWidget
1313
1414from tagstudio .core .library .alchemy .enums import ItemType
1515from tagstudio .core .library .alchemy .models import Entry
1818from tagstudio .qt .controllers .item_thumb import ItemThumb
1919from tagstudio .qt .qt_file_renderer import QtFileRenderer
2020from tagstudio .qt .views .layouts .thumb_grid_layout import ThumbGridLayout
21+ from tagstudio .qt .views .styles .stylesheets import box_selection_style
2122
2223if TYPE_CHECKING :
2324 from tagstudio .qt .qt_driver import QtDriver
2829class 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."""
0 commit comments