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 from __future__ import annotations
import logging
from pathlib import Path 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.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.QtGui import QPixmap, QPainter, QPainterPath, QColor, QPen, QKeyEvent, QWheelEvent, QDrag, QMouseEvent
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@ -276,6 +279,17 @@ class ThumbnailWidget(QWidget):
self.update() self.update()
def mouseMoveEvent(self, event) -> None: 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 # Update hover and cursor based on whether cursor is over the pixmap
over = self._hit_pixmap(event.position().toPoint()) if self._pixmap else False over = self._hit_pixmap(event.position().toPoint()) if self._pixmap else False
if over != self._hover: if over != self._hover:
@ -303,19 +317,49 @@ class ThumbnailWidget(QWidget):
py = (self.height() - self._pixmap.height()) // 2 py = (self.height() - self._pixmap.height()) // 2
return QRect(px, py, self._pixmap.width(), self._pixmap.height()).contains(pos) 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: def mousePressEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton: 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) self.clicked.emit(self.index, event)
elif event.button() == Qt.MouseButton.RightButton: elif event.button() == Qt.MouseButton.RightButton:
self.right_clicked.emit(self.index, event.globalPosition().toPoint()) self.right_clicked.emit(self.index, event.globalPosition().toPoint())
def mouseReleaseEvent(self, event) -> None: def mouseReleaseEvent(self, event) -> None:
self._drag_start = 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: def mouseDoubleClickEvent(self, event) -> None:
self._drag_start = None self._drag_start = None
if event.button() == Qt.MouseButton.LeftButton: 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) self.double_clicked.emit(self.index)
@ -436,9 +480,9 @@ class ThumbnailGrid(QScrollArea):
self._last_click_index = -1 # for shift-click range self._last_click_index = -1 # for shift-click range
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.verticalScrollBar().valueChanged.connect(self._check_scroll_bottom) self.verticalScrollBar().valueChanged.connect(self._check_scroll_bottom)
self.viewport().installEventFilter(self)
# Rubber band drag selection # Rubber band drag selection
self._rubber_band: QRubberBand | None = None 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 self._rb_origin: QPoint | None = None
@property @property
@ -466,7 +510,7 @@ class ThumbnailGrid(QScrollArea):
thumb.clicked.connect(self._on_thumb_click) thumb.clicked.connect(self._on_thumb_click)
thumb.double_clicked.connect(self._on_thumb_double_click) thumb.double_clicked.connect(self._on_thumb_double_click)
thumb.right_clicked.connect(self._on_thumb_right_click) thumb.right_clicked.connect(self._on_thumb_right_click)
thumb.installEventFilter(self)
self._flow.add_widget(thumb) self._flow.add_widget(thumb)
self._thumbs.append(thumb) self._thumbs.append(thumb)
@ -481,7 +525,7 @@ class ThumbnailGrid(QScrollArea):
thumb.clicked.connect(self._on_thumb_click) thumb.clicked.connect(self._on_thumb_click)
thumb.double_clicked.connect(self._on_thumb_double_click) thumb.double_clicked.connect(self._on_thumb_double_click)
thumb.right_clicked.connect(self._on_thumb_right_click) thumb.right_clicked.connect(self._on_thumb_right_click)
thumb.installEventFilter(self)
self._flow.add_widget(thumb) self._flow.add_widget(thumb)
self._thumbs.append(thumb) self._thumbs.append(thumb)
new_thumbs.append(thumb) new_thumbs.append(thumb)
@ -571,24 +615,26 @@ class ThumbnailGrid(QScrollArea):
self._rubber_band.show() self._rubber_band.show()
self.clear_selection() self.clear_selection()
def eventFilter(self, obj, event) -> bool: def on_padding_click(self, thumb, local_pos) -> None:
if isinstance(obj, ThumbnailWidget): """Called directly by ThumbnailWidget when a click misses the pixmap."""
if event.type() in (event.Type.MouseButtonPress, event.Type.MouseButtonDblClick): vp_pos = thumb.mapTo(self.viewport(), local_pos)
if event.button() == Qt.MouseButton.LeftButton and not obj._hit_pixmap(event.position().toPoint()): self._rb_pending_origin = vp_pos
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 mouseMoveEvent(self, event: QMouseEvent) -> None: def mousePressEvent(self, event: QMouseEvent) -> None:
if self._rb_origin and self._rubber_band: # Clicks on viewport/flow (gaps, space below thumbs) start rubber band
rb_rect = QRect(self._rb_origin, event.position().toPoint()).normalized() 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) self._rubber_band.setGeometry(rb_rect)
# Select thumbnails that intersect the rubber band
vp_offset = self.widget().mapFrom(self.viewport(), QPoint(0, 0)) vp_offset = self.widget().mapFrom(self.viewport(), QPoint(0, 0))
self._clear_multi() self._clear_multi()
for i, thumb in enumerate(self._thumbs): for i, thumb in enumerate(self._thumbs):
@ -596,15 +642,42 @@ class ThumbnailGrid(QScrollArea):
if rb_rect.intersects(thumb_rect): 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)
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 return
super().mouseMoveEvent(event) super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent) -> None: def mouseReleaseEvent(self, event: QMouseEvent) -> None:
if self._rb_origin and self._rubber_band: if self._rb_origin and self._rubber_band:
self._rubber_band.hide() self._rb_end()
self._rb_origin = None return
if self._rb_pending_origin is not None:
# Click without drag — treat as deselect
self._rb_pending_origin = None
self.clear_selection()
return return
# Reset any stuck cursor from a cancelled drag-and-drop
self.unsetCursor() self.unsetCursor()
super().mouseReleaseEvent(event) super().mouseReleaseEvent(event)