fix rubber band state getting stuck across interrupted drags

Two fixes:

1. Stale state cleanup. If a rubber band drag is interrupted without a
   matching release event (Wayland focus steal, drag outside window,
   tab switch, alt-tab), _rb_origin and the rubber band widget stay
   stuck. The next click then reuses the stale origin and rubber band
   stops working until the app is restarted. New _clear_stale_rubber_band
   helper is called at the top of every mouse press entry point
   (Grid.mousePressEvent, on_padding_click, ThumbnailWidget pixmap
   press) so the next interaction starts from a clean slate.

2. Scroll offset sign error in _rb_drag. The intersection test
   translated thumb geometry by +vp_offset, but thumb.geometry() is in
   widget coords and rb_rect is in viewport coords — the translation
   needs to convert between them. Switched to translating rb_rect into
   widget coords (rb_widget = rb_rect.translated(vp_offset)) before the
   intersection test, which is the mathematically correct direction.
   Rubber band selection now tracks the visible band when scrolled.

behavior change: rubber band stays responsive after interrupted drags
This commit is contained in:
pax 2026-04-11 18:04:55 -05:00
parent e31ca07973
commit 7249d57852

View File

@ -335,6 +335,11 @@ class ThumbnailWidget(QWidget):
grid.on_padding_click(self, pos) grid.on_padding_click(self, pos)
event.accept() event.accept()
return return
# Pixmap click — clear any stale rubber band state from a
# previous interrupted drag before starting a new interaction.
grid = self._grid()
if grid:
grid._clear_stale_rubber_band()
self._drag_start = pos self._drag_start = pos
self.clicked.emit(self.index, event) self.clicked.emit(self.index, event)
elif event.button() == Qt.MouseButton.RightButton: elif event.button() == Qt.MouseButton.RightButton:
@ -544,6 +549,21 @@ class ThumbnailGrid(QScrollArea):
self._thumbs[self._selected_index].set_selected(False) self._thumbs[self._selected_index].set_selected(False)
self._selected_index = -1 self._selected_index = -1
def _clear_stale_rubber_band(self) -> None:
"""Reset any leftover rubber band state before starting a new interaction.
Rubber band state can get stuck if a drag is interrupted without
a matching release event Wayland focus steal, drag outside the
window, tab switch mid-drag, etc. Every new mouse press calls this
so the next interaction starts from a clean slate instead of
reusing a stale origin (which would make the rubber band "not
work" until the app is restarted).
"""
if self._rubber_band is not None:
self._rubber_band.hide()
self._rb_origin = None
self._rb_pending_origin = None
def _select(self, index: int) -> None: def _select(self, index: int) -> None:
if index < 0 or index >= len(self._thumbs): if index < 0 or index >= len(self._thumbs):
return return
@ -617,12 +637,14 @@ class ThumbnailGrid(QScrollArea):
def on_padding_click(self, thumb, local_pos) -> None: def on_padding_click(self, thumb, local_pos) -> None:
"""Called directly by ThumbnailWidget when a click misses the pixmap.""" """Called directly by ThumbnailWidget when a click misses the pixmap."""
self._clear_stale_rubber_band()
vp_pos = thumb.mapTo(self.viewport(), local_pos) vp_pos = thumb.mapTo(self.viewport(), local_pos)
self._rb_pending_origin = vp_pos self._rb_pending_origin = vp_pos
def mousePressEvent(self, event: QMouseEvent) -> None: def mousePressEvent(self, event: QMouseEvent) -> None:
# Clicks on viewport/flow (gaps, space below thumbs) start rubber band # Clicks on viewport/flow (gaps, space below thumbs) start rubber band
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
self._clear_stale_rubber_band()
child = self.childAt(event.position().toPoint()) child = self.childAt(event.position().toPoint())
if child is self.widget() or child is self.viewport(): if child is self.widget() or child is self.viewport():
self._rb_pending_origin = event.position().toPoint() self._rb_pending_origin = event.position().toPoint()
@ -635,11 +657,15 @@ class ThumbnailGrid(QScrollArea):
return return
rb_rect = QRect(self._rb_origin, vp_pos).normalized() rb_rect = QRect(self._rb_origin, vp_pos).normalized()
self._rubber_band.setGeometry(rb_rect) self._rubber_band.setGeometry(rb_rect)
# rb_rect is in viewport coords; thumb.geometry() is in widget (content)
# coords. Convert rb_rect to widget coords for the intersection test —
# widget.mapFrom(viewport, (0,0)) gives the widget-coord of viewport's
# origin, which is exactly the translation needed when scrolled.
vp_offset = self.widget().mapFrom(self.viewport(), QPoint(0, 0)) vp_offset = self.widget().mapFrom(self.viewport(), QPoint(0, 0))
rb_widget = rb_rect.translated(vp_offset)
self._clear_multi() self._clear_multi()
for i, thumb in enumerate(self._thumbs): for i, thumb in enumerate(self._thumbs):
thumb_rect = thumb.geometry().translated(vp_offset) if rb_widget.intersects(thumb.geometry()):
if rb_rect.intersects(thumb_rect):
self._multi_selected.add(i) self._multi_selected.add(i)
thumb.set_multi_selected(True) thumb.set_multi_selected(True)