rubber band from cell padding with 30px drag threshold

- ThumbnailWidget detects clicks outside the pixmap and calls
  grid.on_padding_click() via parent walk (signals + event filters
  both failed on Wayland/QScrollArea)
- Grid tracks a pending rubber band origin; only activates past 30px
  manhattan distance so small clicks deselect cleanly
- Move/release events forwarded from ThumbnailWidget to grid for both
  the pending-drag check and the active rubber band drag
- Fixed mapFrom/mapTo direction (mapFrom's first arg must be a parent)
This commit is contained in:
pax 2026-04-10 20:54:37 -05:00
parent c440065513
commit fa9fcc3db0

View File

@ -2,8 +2,11 @@
from __future__ import annotations
import logging
from pathlib import Path
log = logging.getLogger("booru")
from PySide6.QtCore import Qt, Signal, QSize, QRect, QRectF, QMimeData, QUrl, QPoint, Property, QPropertyAnimation, QEasingCurve
from PySide6.QtGui import QPixmap, QPainter, QPainterPath, QColor, QPen, QKeyEvent, QWheelEvent, QDrag, QMouseEvent
from PySide6.QtWidgets import (
@ -276,6 +279,17 @@ class ThumbnailWidget(QWidget):
self.update()
def mouseMoveEvent(self, event) -> None:
# If the grid has a pending or active rubber band, forward the move
grid = self._grid()
if grid and (grid._rb_origin or grid._rb_pending_origin):
vp_pos = self.mapTo(grid.viewport(), event.position().toPoint())
if grid._rb_origin:
grid._rb_drag(vp_pos)
return
if grid._maybe_start_rb(vp_pos):
grid._rb_drag(vp_pos)
return
return
# Update hover and cursor based on whether cursor is over the pixmap
over = self._hit_pixmap(event.position().toPoint()) if self._pixmap else False
if over != self._hover:
@ -303,19 +317,49 @@ class ThumbnailWidget(QWidget):
py = (self.height() - self._pixmap.height()) // 2
return QRect(px, py, self._pixmap.width(), self._pixmap.height()).contains(pos)
def _grid(self):
"""Walk up to the ThumbnailGrid ancestor."""
w = self.parentWidget()
while w:
if isinstance(w, ThumbnailGrid):
return w
w = w.parentWidget()
return None
def mousePressEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self._drag_start = event.position().toPoint()
pos = event.position().toPoint()
if not self._hit_pixmap(pos):
grid = self._grid()
if grid:
grid.on_padding_click(self, pos)
event.accept()
return
self._drag_start = pos
self.clicked.emit(self.index, event)
elif event.button() == Qt.MouseButton.RightButton:
self.right_clicked.emit(self.index, event.globalPosition().toPoint())
def mouseReleaseEvent(self, event) -> None:
self._drag_start = None
grid = self._grid()
if grid:
if grid._rb_origin:
grid._rb_end()
elif grid._rb_pending_origin is not None:
# Click without drag — treat as deselect
grid._rb_pending_origin = None
grid.clear_selection()
def mouseDoubleClickEvent(self, event) -> None:
self._drag_start = None
if event.button() == Qt.MouseButton.LeftButton:
pos = event.position().toPoint()
if not self._hit_pixmap(pos):
grid = self._grid()
if grid:
grid.on_padding_click(self, pos)
return
self.double_clicked.emit(self.index)
@ -436,9 +480,9 @@ class ThumbnailGrid(QScrollArea):
self._last_click_index = -1 # for shift-click range
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.verticalScrollBar().valueChanged.connect(self._check_scroll_bottom)
self.viewport().installEventFilter(self)
# Rubber band drag selection
self._rubber_band: QRubberBand | None = None
self._rb_pending_origin: QPoint | None = None # press position, not yet confirmed as drag
self._rb_origin: QPoint | None = None
@property
@ -466,7 +510,7 @@ class ThumbnailGrid(QScrollArea):
thumb.clicked.connect(self._on_thumb_click)
thumb.double_clicked.connect(self._on_thumb_double_click)
thumb.right_clicked.connect(self._on_thumb_right_click)
thumb.installEventFilter(self)
self._flow.add_widget(thumb)
self._thumbs.append(thumb)
@ -481,7 +525,7 @@ class ThumbnailGrid(QScrollArea):
thumb.clicked.connect(self._on_thumb_click)
thumb.double_clicked.connect(self._on_thumb_double_click)
thumb.right_clicked.connect(self._on_thumb_right_click)
thumb.installEventFilter(self)
self._flow.add_widget(thumb)
self._thumbs.append(thumb)
new_thumbs.append(thumb)
@ -571,24 +615,26 @@ class ThumbnailGrid(QScrollArea):
self._rubber_band.show()
self.clear_selection()
def eventFilter(self, obj, event) -> bool:
if isinstance(obj, ThumbnailWidget):
if event.type() in (event.Type.MouseButtonPress, event.Type.MouseButtonDblClick):
if event.button() == Qt.MouseButton.LeftButton and not obj._hit_pixmap(event.position().toPoint()):
vp_pos = self.viewport().mapFromGlobal(obj.mapToGlobal(event.position().toPoint()))
self._start_rubber_band(vp_pos)
return True
elif obj is self.viewport():
if event.type() == event.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton:
self._start_rubber_band(event.position().toPoint())
return True
return super().eventFilter(obj, event)
def on_padding_click(self, thumb, local_pos) -> None:
"""Called directly by ThumbnailWidget when a click misses the pixmap."""
vp_pos = thumb.mapTo(self.viewport(), local_pos)
self._rb_pending_origin = vp_pos
def mouseMoveEvent(self, event: QMouseEvent) -> None:
if self._rb_origin and self._rubber_band:
rb_rect = QRect(self._rb_origin, event.position().toPoint()).normalized()
def mousePressEvent(self, event: QMouseEvent) -> None:
# Clicks on viewport/flow (gaps, space below thumbs) start rubber band
if event.button() == Qt.MouseButton.LeftButton:
child = self.childAt(event.position().toPoint())
if child is self.widget() or child is self.viewport():
self._rb_pending_origin = event.position().toPoint()
return
super().mousePressEvent(event)
def _rb_drag(self, vp_pos: QPoint) -> None:
"""Update rubber band geometry and intersected thumb selection."""
if not (self._rb_origin and self._rubber_band):
return
rb_rect = QRect(self._rb_origin, vp_pos).normalized()
self._rubber_band.setGeometry(rb_rect)
# Select thumbnails that intersect the rubber band
vp_offset = self.widget().mapFrom(self.viewport(), QPoint(0, 0))
self._clear_multi()
for i, thumb in enumerate(self._thumbs):
@ -596,15 +642,42 @@ class ThumbnailGrid(QScrollArea):
if rb_rect.intersects(thumb_rect):
self._multi_selected.add(i)
thumb.set_multi_selected(True)
def _rb_end(self) -> None:
"""Hide the rubber band and clear origin."""
if self._rubber_band:
self._rubber_band.hide()
self._rb_origin = None
def _maybe_start_rb(self, vp_pos: QPoint) -> bool:
"""If a rubber band press is pending and we've moved past threshold, start it."""
if self._rb_pending_origin is None:
return False
if (vp_pos - self._rb_pending_origin).manhattanLength() < 30:
return False
self._start_rubber_band(self._rb_pending_origin)
self._rb_pending_origin = None
return True
def mouseMoveEvent(self, event: QMouseEvent) -> None:
pos = event.position().toPoint()
if self._rb_origin and self._rubber_band:
self._rb_drag(pos)
return
if self._maybe_start_rb(pos):
self._rb_drag(pos)
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
if self._rb_origin and self._rubber_band:
self._rubber_band.hide()
self._rb_origin = None
self._rb_end()
return
if self._rb_pending_origin is not None:
# Click without drag — treat as deselect
self._rb_pending_origin = None
self.clear_selection()
return
# Reset any stuck cursor from a cancelled drag-and-drop
self.unsetCursor()
super().mouseReleaseEvent(event)