Qt Wayland buffer goes stale after compositor-driven resize events (Hyprland tiled geometry change). FlowLayout reflowed thumbs but the viewport skipped paint until a scroll or click invalidated it, leaving the grid blank. ThumbnailGrid.resizeEvent now calls viewport().update() after reflowing so the buffer stays in sync.
876 lines
35 KiB
Python
876 lines
35 KiB
Python
"""Thumbnail grid widget for the Qt6 GUI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
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, QColor, QPen, QKeyEvent, QWheelEvent, QDrag, QMouseEvent
|
|
from PySide6.QtWidgets import (
|
|
QWidget,
|
|
QScrollArea,
|
|
QRubberBand,
|
|
)
|
|
|
|
THUMB_SIZE = 180
|
|
THUMB_SPACING = 2
|
|
BORDER_WIDTH = 2
|
|
|
|
|
|
class ThumbnailWidget(QWidget):
|
|
"""Single clickable thumbnail cell."""
|
|
|
|
clicked = Signal(int, object) # index, QMouseEvent
|
|
double_clicked = Signal(int)
|
|
right_clicked = Signal(int, object) # index, QPoint
|
|
|
|
# QSS-controllable dot colors
|
|
_saved_color = QColor("#22cc22")
|
|
_bookmarked_color = QColor("#ffcc00")
|
|
|
|
def _get_saved_color(self): return self._saved_color
|
|
def _set_saved_color(self, c): self._saved_color = QColor(c) if isinstance(c, str) else c
|
|
savedColor = Property(QColor, _get_saved_color, _set_saved_color)
|
|
|
|
def _get_bookmarked_color(self): return self._bookmarked_color
|
|
def _set_bookmarked_color(self, c): self._bookmarked_color = QColor(c) if isinstance(c, str) else c
|
|
bookmarkedColor = Property(QColor, _get_bookmarked_color, _set_bookmarked_color)
|
|
|
|
# QSS-controllable selection paint colors. Defaults are read from the
|
|
# palette in __init__ so non-themed environments still pick up the
|
|
# system Highlight color, but a custom.qss can override any of them
|
|
# via `ThumbnailWidget { qproperty-selectionColor: ${accent}; }`.
|
|
_selection_color = QColor("#3399ff")
|
|
_multi_select_color = QColor("#226699")
|
|
_hover_color = QColor("#66bbff")
|
|
_idle_color = QColor("#444444")
|
|
|
|
def _get_selection_color(self): return self._selection_color
|
|
def _set_selection_color(self, c): self._selection_color = QColor(c) if isinstance(c, str) else c
|
|
selectionColor = Property(QColor, _get_selection_color, _set_selection_color)
|
|
|
|
def _get_multi_select_color(self): return self._multi_select_color
|
|
def _set_multi_select_color(self, c): self._multi_select_color = QColor(c) if isinstance(c, str) else c
|
|
multiSelectColor = Property(QColor, _get_multi_select_color, _set_multi_select_color)
|
|
|
|
def _get_hover_color(self): return self._hover_color
|
|
def _set_hover_color(self, c): self._hover_color = QColor(c) if isinstance(c, str) else c
|
|
hoverColor = Property(QColor, _get_hover_color, _set_hover_color)
|
|
|
|
def _get_idle_color(self): return self._idle_color
|
|
def _set_idle_color(self, c): self._idle_color = QColor(c) if isinstance(c, str) else c
|
|
idleColor = Property(QColor, _get_idle_color, _set_idle_color)
|
|
|
|
# Thumbnail fade-in opacity (0.0 → 1.0 on pixmap arrival)
|
|
def _get_thumb_opacity(self): return self._thumb_opacity
|
|
def _set_thumb_opacity(self, v):
|
|
self._thumb_opacity = v
|
|
self.update()
|
|
thumbOpacity = Property(float, _get_thumb_opacity, _set_thumb_opacity)
|
|
|
|
def __init__(self, index: int, parent: QWidget | None = None) -> None:
|
|
super().__init__(parent)
|
|
self.index = index
|
|
self._pixmap: QPixmap | None = None
|
|
self._source_path: str | None = None # on-disk path, for re-scaling on size change
|
|
self._selected = False
|
|
self._multi_selected = False
|
|
self._bookmarked = False
|
|
self._saved_locally = False
|
|
self._hover = False
|
|
self._drag_start: QPoint | None = None
|
|
self._cached_path: str | None = None
|
|
self._prefetch_progress: float = -1 # -1 = not prefetching, 0-1 = progress
|
|
self._thumb_opacity: float = 0.0
|
|
# Seed selection colors from the palette so non-themed environments
|
|
# (no custom.qss) automatically use the system highlight color.
|
|
# The qproperty setters above override these later when the QSS is
|
|
# polished, so any theme can repaint via `qproperty-selectionColor`.
|
|
from PySide6.QtGui import QPalette
|
|
pal = self.palette()
|
|
self._selection_color = pal.color(QPalette.ColorRole.Highlight)
|
|
self._multi_select_color = self._selection_color.darker(150)
|
|
self._hover_color = self._selection_color.lighter(150)
|
|
self._idle_color = pal.color(QPalette.ColorRole.Mid)
|
|
self.setFixedSize(THUMB_SIZE, THUMB_SIZE)
|
|
self.setMouseTracking(True)
|
|
|
|
def set_pixmap(self, pixmap: QPixmap, path: str | None = None) -> None:
|
|
if path is not None:
|
|
self._source_path = path
|
|
self._pixmap = pixmap.scaled(
|
|
THUMB_SIZE - 4, THUMB_SIZE - 4,
|
|
Qt.AspectRatioMode.KeepAspectRatio,
|
|
Qt.TransformationMode.SmoothTransformation,
|
|
)
|
|
self._thumb_opacity = 0.0
|
|
anim = QPropertyAnimation(self, b"thumbOpacity")
|
|
anim.setDuration(80)
|
|
anim.setStartValue(0.0)
|
|
anim.setEndValue(1.0)
|
|
anim.setEasingCurve(QEasingCurve.Type.OutCubic)
|
|
anim.finished.connect(lambda: self._on_fade_done(anim))
|
|
self._fade_anim = anim
|
|
anim.start()
|
|
|
|
def _on_fade_done(self, anim: QPropertyAnimation) -> None:
|
|
"""Clear the reference then schedule deletion."""
|
|
if self._fade_anim is anim:
|
|
self._fade_anim = None
|
|
anim.deleteLater()
|
|
|
|
def set_selected(self, selected: bool) -> None:
|
|
self._selected = selected
|
|
self.update()
|
|
|
|
def set_multi_selected(self, selected: bool) -> None:
|
|
self._multi_selected = selected
|
|
self.update()
|
|
|
|
def set_bookmarked(self, bookmarked: bool) -> None:
|
|
self._bookmarked = bookmarked
|
|
self.update()
|
|
|
|
def set_saved_locally(self, saved: bool) -> None:
|
|
self._saved_locally = saved
|
|
self.update()
|
|
|
|
def set_prefetch_progress(self, progress: float) -> None:
|
|
"""Set prefetch progress: -1 = hide, 0.0-1.0 = progress."""
|
|
self._prefetch_progress = progress
|
|
self.update()
|
|
|
|
def paintEvent(self, event) -> None:
|
|
# Ensure QSS is applied so palette picks up custom colors
|
|
self.ensurePolished()
|
|
p = QPainter(self)
|
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
pal = self.palette()
|
|
# State colors come from Qt Properties so QSS can override them.
|
|
# Defaults were seeded from the palette in __init__.
|
|
highlight = self._selection_color
|
|
base = pal.color(pal.ColorRole.Base)
|
|
window = pal.color(pal.ColorRole.Window)
|
|
|
|
# Fill entire cell with window color
|
|
p.fillRect(self.rect(), window)
|
|
|
|
# Content rect hugs the pixmap
|
|
if self._pixmap:
|
|
pw, ph = self._pixmap.width(), self._pixmap.height()
|
|
cx = (self.width() - pw) // 2
|
|
cy = (self.height() - ph) // 2
|
|
content = QRect(cx - BORDER_WIDTH, cy - BORDER_WIDTH,
|
|
pw + BORDER_WIDTH * 2, ph + BORDER_WIDTH * 2)
|
|
else:
|
|
content = self.rect()
|
|
|
|
# Background (content area only)
|
|
if self._multi_selected:
|
|
p.fillRect(content, self._multi_select_color.darker(200))
|
|
elif self._hover:
|
|
p.fillRect(content, window.lighter(130))
|
|
|
|
# Border (content area only). Pen-width-aware geometry: a QPen
|
|
# centered on a QRect's geometric edge spills half a pixel out on
|
|
# each side, which on AA-on rendering blends with the cell
|
|
# background and makes the border read as thinner than the pen
|
|
# width. Inset by half the pen width into a QRectF so the full
|
|
# pen width sits cleanly inside the content rect.
|
|
# All four state colors are QSS-controllable Qt Properties on
|
|
# ThumbnailWidget — see selectionColor, multiSelectColor,
|
|
# hoverColor, idleColor at the top of this class.
|
|
if self._selected:
|
|
pen_width = 3
|
|
pen_color = self._selection_color
|
|
elif self._multi_selected:
|
|
pen_width = 3
|
|
pen_color = self._multi_select_color
|
|
elif self._hover:
|
|
pen_width = 1
|
|
pen_color = self._hover_color
|
|
else:
|
|
pen_width = 1
|
|
pen_color = self._idle_color
|
|
half = pen_width / 2.0
|
|
border_rect = QRectF(content).adjusted(half, half, -half, -half)
|
|
|
|
# Draw the thumbnail FIRST so the selection border z-orders on top.
|
|
# No clip path: the border is square and the pixmap is square, so
|
|
# there's nothing to round and nothing to mismatch.
|
|
if self._pixmap:
|
|
x = (self.width() - self._pixmap.width()) // 2
|
|
y = (self.height() - self._pixmap.height()) // 2
|
|
if self._thumb_opacity < 1.0:
|
|
p.setOpacity(self._thumb_opacity)
|
|
p.drawPixmap(x, y, self._pixmap)
|
|
if self._thumb_opacity < 1.0:
|
|
p.setOpacity(1.0)
|
|
|
|
# Border drawn AFTER the pixmap. Plain rectangle (no rounding) so
|
|
# it lines up exactly with the pixmap's square edges — no corner
|
|
# cut-off triangles where window color would peek through.
|
|
pen = QPen(pen_color, pen_width)
|
|
p.setPen(pen)
|
|
p.setBrush(Qt.BrushStyle.NoBrush)
|
|
p.drawRect(border_rect)
|
|
|
|
# Indicators (top-right of content rect): bookmark on the left,
|
|
# saved dot on the right. Both share a fixed-size box so
|
|
# they're vertically and horizontally aligned. The right anchor
|
|
# is fixed regardless of which indicators are visible, so the
|
|
# rightmost slot stays in the same place whether the cell has
|
|
# one indicator or two.
|
|
from PySide6.QtGui import QFont
|
|
slot_size = 9
|
|
slot_gap = 2
|
|
slot_y = content.top() + 3
|
|
right_anchor = content.right() - 3
|
|
|
|
# Build the row right-to-left so we can decrement x as we draw.
|
|
# Right slot (drawn first): the saved-locally dot.
|
|
# Left slot (drawn second): the bookmark star.
|
|
draw_order: list[tuple[str, QColor]] = []
|
|
if self._saved_locally:
|
|
draw_order.append(('dot', self._saved_color))
|
|
if self._bookmarked:
|
|
draw_order.append(('star', self._bookmarked_color))
|
|
|
|
x = right_anchor - slot_size
|
|
for kind, color in draw_order:
|
|
slot = QRect(x, slot_y, slot_size, slot_size)
|
|
if kind == 'dot':
|
|
p.setPen(Qt.PenStyle.NoPen)
|
|
p.setBrush(color)
|
|
# 1px inset so the circle doesn't kiss the slot edge —
|
|
# makes it look slightly less stamped-on at small sizes.
|
|
p.drawEllipse(slot.adjusted(1, 1, -1, -1))
|
|
elif kind == 'star':
|
|
p.setPen(color)
|
|
p.setBrush(Qt.BrushStyle.NoBrush)
|
|
p.setFont(QFont(p.font().family(), 9))
|
|
p.drawText(slot, int(Qt.AlignmentFlag.AlignCenter), "\u2605")
|
|
x -= (slot_size + slot_gap)
|
|
|
|
# Multi-select checkmark
|
|
if self._multi_selected:
|
|
cx, cy = content.left() + 4, content.top() + 4
|
|
p.setPen(Qt.PenStyle.NoPen)
|
|
p.setBrush(highlight)
|
|
p.drawEllipse(cx, cy, 12, 12)
|
|
p.setPen(QPen(base, 2))
|
|
p.drawLine(cx + 3, cy + 6, cx + 5, cy + 9)
|
|
p.drawLine(cx + 5, cy + 9, cx + 10, cy + 3)
|
|
|
|
# Prefetch progress bar
|
|
if self._prefetch_progress >= 0:
|
|
bar_h = 3
|
|
bar_y = content.bottom() - bar_h - 1
|
|
bar_w = int((content.width() - 8) * self._prefetch_progress)
|
|
p.setPen(Qt.PenStyle.NoPen)
|
|
p.setBrush(QColor(100, 100, 100, 120))
|
|
p.drawRect(content.left() + 4, bar_y, content.width() - 8, bar_h)
|
|
p.setBrush(highlight)
|
|
p.drawRect(content.left() + 4, bar_y, bar_w, bar_h)
|
|
|
|
p.end()
|
|
|
|
def leaveEvent(self, event) -> None:
|
|
if self._hover:
|
|
self._hover = False
|
|
self.setCursor(Qt.CursorShape.ArrowCursor)
|
|
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:
|
|
self._hover = over
|
|
self.setCursor(Qt.CursorShape.PointingHandCursor if over else Qt.CursorShape.ArrowCursor)
|
|
self.update()
|
|
if (self._drag_start and self._cached_path
|
|
and (event.position().toPoint() - self._drag_start).manhattanLength() > 10):
|
|
drag = QDrag(self)
|
|
mime = QMimeData()
|
|
mime.setUrls([QUrl.fromLocalFile(self._cached_path)])
|
|
drag.setMimeData(mime)
|
|
if self._pixmap:
|
|
drag.setPixmap(self._pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio))
|
|
drag.exec(Qt.DropAction.CopyAction)
|
|
self._drag_start = None
|
|
self.setCursor(Qt.CursorShape.ArrowCursor)
|
|
return
|
|
|
|
def _hit_pixmap(self, pos) -> bool:
|
|
"""True if pos is within the drawn pixmap area."""
|
|
if not self._pixmap:
|
|
return False
|
|
px = (self.width() - self._pixmap.width()) // 2
|
|
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:
|
|
pos = event.position().toPoint()
|
|
if not self._hit_pixmap(pos):
|
|
grid = self._grid()
|
|
if grid:
|
|
grid.on_padding_click(self, pos)
|
|
event.accept()
|
|
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.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)
|
|
|
|
|
|
class FlowLayout(QWidget):
|
|
"""A widget that arranges children in a wrapping flow."""
|
|
|
|
def __init__(self, parent: QWidget | None = None) -> None:
|
|
super().__init__(parent)
|
|
self._items: list[QWidget] = []
|
|
|
|
def add_widget(self, widget: QWidget) -> None:
|
|
widget.setParent(self)
|
|
self._items.append(widget)
|
|
self._do_layout()
|
|
|
|
def clear(self) -> None:
|
|
for w in self._items:
|
|
if hasattr(w, '_fade_anim') and w._fade_anim is not None:
|
|
w._fade_anim.stop()
|
|
w.setParent(None) # type: ignore
|
|
w.deleteLater()
|
|
self._items.clear()
|
|
self.setMinimumHeight(0)
|
|
|
|
def resizeEvent(self, event) -> None:
|
|
self._do_layout()
|
|
|
|
def _do_layout(self) -> None:
|
|
"""Position children in a deterministic grid.
|
|
|
|
Uses the THUMB_SIZE / THUMB_SPACING constants instead of each
|
|
widget's actual `width()` so the layout is independent of per-
|
|
widget size variance. This matters because:
|
|
|
|
1. ThumbnailWidget calls `setFixedSize(THUMB_SIZE, THUMB_SIZE)`
|
|
in `__init__`, capturing the constant at construction time.
|
|
If `THUMB_SIZE` is later mutated (`_apply_settings` writes
|
|
`grid_mod.THUMB_SIZE = new_size` in main_window.py:2953),
|
|
existing thumbs keep their old fixed size while new ones
|
|
(e.g. from infinite-scroll backfill via `append_posts`) get
|
|
the new one. Mixed widths break a width-summing wrap loop.
|
|
|
|
2. The previous wrap loop walked each thumb summing
|
|
`widget.width() + THUMB_SPACING` and wrapped on
|
|
`x + item_w > self.width()`. At column boundaries
|
|
(window width within a few pixels of `N * step + margin`)
|
|
the boundary depends on every per-widget width, and any
|
|
sub-pixel or mid-mutation drift could collapse the column
|
|
count by 1.
|
|
|
|
Now: compute the column count once from the container width
|
|
and the constant step, then position thumbs by `(col, row)`
|
|
index. The layout is a function of `self.width()` and the
|
|
constants only — no per-widget reads.
|
|
"""
|
|
if not self._items:
|
|
return
|
|
width = self.width() or 800
|
|
step = THUMB_SIZE + THUMB_SPACING
|
|
# Account for the leading THUMB_SPACING margin: a row that fits
|
|
# N thumbs needs `THUMB_SPACING + N * step` pixels minimum, not
|
|
# `N * step`. The previous formula `w // step` overcounted by 1
|
|
# at the boundary (e.g. width=1135 returned 6 columns where the
|
|
# actual fit is 5).
|
|
cols = max(1, (width - THUMB_SPACING) // step)
|
|
|
|
for i, widget in enumerate(self._items):
|
|
col = i % cols
|
|
row = i // cols
|
|
x = THUMB_SPACING + col * step
|
|
y = THUMB_SPACING + row * step
|
|
widget.move(x, y)
|
|
widget.show()
|
|
|
|
rows = (len(self._items) + cols - 1) // cols
|
|
self.setMinimumHeight(THUMB_SPACING + rows * step)
|
|
|
|
@property
|
|
def columns(self) -> int:
|
|
"""Same formula as `_do_layout`'s column count.
|
|
|
|
Both must agree exactly so callers (e.g. main_window's
|
|
keyboard Up/Down nav step) get the value the visual layout
|
|
actually used. The previous version was off-by-one because it
|
|
omitted the leading THUMB_SPACING from the calculation.
|
|
"""
|
|
if not self._items:
|
|
return 1
|
|
# Use parent viewport width if inside a QScrollArea
|
|
parent = self.parentWidget()
|
|
if parent and hasattr(parent, 'viewport'):
|
|
w = parent.viewport().width()
|
|
else:
|
|
w = self.width() or 800
|
|
step = THUMB_SIZE + THUMB_SPACING
|
|
return max(1, (w - THUMB_SPACING) // step)
|
|
|
|
|
|
class ThumbnailGrid(QScrollArea):
|
|
"""Scrollable grid of thumbnail widgets with keyboard nav, context menu, and multi-select."""
|
|
|
|
post_selected = Signal(int)
|
|
post_activated = Signal(int)
|
|
context_requested = Signal(int, object) # index, QPoint
|
|
multi_context_requested = Signal(list, object) # list[int], QPoint
|
|
reached_bottom = Signal() # emitted when scrolled to the bottom
|
|
reached_top = Signal() # emitted when scrolled to the top
|
|
nav_past_end = Signal() # nav past last post (keyboard or scroll tilt)
|
|
nav_before_start = Signal() # nav before first post (keyboard or scroll tilt)
|
|
|
|
def __init__(self, parent: QWidget | None = None) -> None:
|
|
super().__init__(parent)
|
|
self._flow = FlowLayout()
|
|
self.setWidget(self._flow)
|
|
self.setWidgetResizable(True)
|
|
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
self._thumbs: list[ThumbnailWidget] = []
|
|
self._selected_index = -1
|
|
self._multi_selected: set[int] = set()
|
|
self._last_click_index = -1 # for shift-click range
|
|
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
self.verticalScrollBar().valueChanged.connect(self._check_scroll_bottom)
|
|
# 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
|
|
def selected_index(self) -> int:
|
|
return self._selected_index
|
|
|
|
@property
|
|
def selected_indices(self) -> list[int]:
|
|
"""Return all multi-selected indices, or just the single selected one."""
|
|
if self._multi_selected:
|
|
return sorted(self._multi_selected)
|
|
if self._selected_index >= 0:
|
|
return [self._selected_index]
|
|
return []
|
|
|
|
def set_posts(self, count: int) -> list[ThumbnailWidget]:
|
|
self._flow.clear()
|
|
self._thumbs.clear()
|
|
self._selected_index = -1
|
|
self._multi_selected.clear()
|
|
self._last_click_index = -1
|
|
|
|
for i in range(count):
|
|
thumb = ThumbnailWidget(i)
|
|
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)
|
|
|
|
self._flow.add_widget(thumb)
|
|
self._thumbs.append(thumb)
|
|
|
|
return self._thumbs
|
|
|
|
def append_posts(self, count: int) -> list[ThumbnailWidget]:
|
|
"""Add more thumbnails to the existing grid."""
|
|
start = len(self._thumbs)
|
|
new_thumbs = []
|
|
for i in range(start, start + count):
|
|
thumb = ThumbnailWidget(i)
|
|
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)
|
|
|
|
self._flow.add_widget(thumb)
|
|
self._thumbs.append(thumb)
|
|
new_thumbs.append(thumb)
|
|
return new_thumbs
|
|
|
|
def _clear_multi(self) -> None:
|
|
for idx in self._multi_selected:
|
|
if 0 <= idx < len(self._thumbs):
|
|
self._thumbs[idx].set_multi_selected(False)
|
|
self._multi_selected.clear()
|
|
|
|
def clear_selection(self) -> None:
|
|
"""Deselect everything."""
|
|
self._clear_multi()
|
|
if 0 <= self._selected_index < len(self._thumbs):
|
|
self._thumbs[self._selected_index].set_selected(False)
|
|
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:
|
|
if index < 0 or index >= len(self._thumbs):
|
|
return
|
|
self._clear_multi()
|
|
if 0 <= self._selected_index < len(self._thumbs):
|
|
self._thumbs[self._selected_index].set_selected(False)
|
|
self._selected_index = index
|
|
self._last_click_index = index
|
|
self._thumbs[index].set_selected(True)
|
|
self.ensureWidgetVisible(self._thumbs[index])
|
|
self.post_selected.emit(index)
|
|
|
|
def _toggle_multi(self, index: int) -> None:
|
|
"""Ctrl+click: toggle one item in/out of multi-selection."""
|
|
# First ctrl+click: add the currently single-selected item too
|
|
if not self._multi_selected and self._selected_index >= 0:
|
|
self._multi_selected.add(self._selected_index)
|
|
self._thumbs[self._selected_index].set_multi_selected(True)
|
|
|
|
if index in self._multi_selected:
|
|
self._multi_selected.discard(index)
|
|
self._thumbs[index].set_multi_selected(False)
|
|
else:
|
|
self._multi_selected.add(index)
|
|
self._thumbs[index].set_multi_selected(True)
|
|
self._last_click_index = index
|
|
|
|
def _range_select(self, index: int) -> None:
|
|
"""Shift+click: select range from last click to this one."""
|
|
start = self._last_click_index if self._last_click_index >= 0 else 0
|
|
lo, hi = min(start, index), max(start, index)
|
|
self._clear_multi()
|
|
for i in range(lo, hi + 1):
|
|
self._multi_selected.add(i)
|
|
self._thumbs[i].set_multi_selected(True)
|
|
|
|
def _on_thumb_click(self, index: int, event) -> None:
|
|
mods = event.modifiers()
|
|
if mods & Qt.KeyboardModifier.ControlModifier:
|
|
self._toggle_multi(index)
|
|
elif mods & Qt.KeyboardModifier.ShiftModifier:
|
|
self._range_select(index)
|
|
else:
|
|
self._select(index)
|
|
|
|
def _on_thumb_double_click(self, index: int) -> None:
|
|
self._select(index)
|
|
self.post_activated.emit(index)
|
|
|
|
def _on_thumb_right_click(self, index: int, pos) -> None:
|
|
if self._multi_selected and index in self._multi_selected:
|
|
self.multi_context_requested.emit(sorted(self._multi_selected), pos)
|
|
else:
|
|
# Select visually but don't activate (no preview change)
|
|
self._clear_multi()
|
|
if 0 <= self._selected_index < len(self._thumbs):
|
|
self._thumbs[self._selected_index].set_selected(False)
|
|
self._selected_index = index
|
|
self._thumbs[index].set_selected(True)
|
|
self.ensureWidgetVisible(self._thumbs[index])
|
|
self.context_requested.emit(index, pos)
|
|
|
|
def _start_rubber_band(self, pos: QPoint) -> None:
|
|
"""Start a rubber band selection and deselect."""
|
|
self._rb_origin = pos
|
|
if not self._rubber_band:
|
|
self._rubber_band = QRubberBand(QRubberBand.Shape.Rectangle, self.viewport())
|
|
self._rubber_band.setGeometry(QRect(self._rb_origin, QSize()))
|
|
self._rubber_band.show()
|
|
self.clear_selection()
|
|
|
|
def on_padding_click(self, thumb, local_pos) -> None:
|
|
"""Called directly by ThumbnailWidget when a click misses the pixmap."""
|
|
self._clear_stale_rubber_band()
|
|
vp_pos = thumb.mapTo(self.viewport(), local_pos)
|
|
self._rb_pending_origin = vp_pos
|
|
|
|
def mousePressEvent(self, event: QMouseEvent) -> None:
|
|
# Clicks on viewport/flow (gaps, space below thumbs) start rubber band
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
self._clear_stale_rubber_band()
|
|
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)
|
|
# 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))
|
|
rb_widget = rb_rect.translated(vp_offset)
|
|
self._clear_multi()
|
|
for i, thumb in enumerate(self._thumbs):
|
|
if rb_widget.intersects(thumb.geometry()):
|
|
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._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
|
|
self.unsetCursor()
|
|
super().mouseReleaseEvent(event)
|
|
|
|
def leaveEvent(self, event) -> None:
|
|
# Clear stuck hover states — Wayland doesn't always fire
|
|
# leaveEvent on individual child widgets when the mouse
|
|
# exits the scroll area quickly.
|
|
for thumb in self._thumbs:
|
|
if thumb._hover:
|
|
thumb._hover = False
|
|
thumb.update()
|
|
super().leaveEvent(event)
|
|
|
|
def select_all(self) -> None:
|
|
self._clear_multi()
|
|
for i in range(len(self._thumbs)):
|
|
self._multi_selected.add(i)
|
|
self._thumbs[i].set_multi_selected(True)
|
|
|
|
def keyPressEvent(self, event: QKeyEvent) -> None:
|
|
cols = self._flow.columns
|
|
idx = self._selected_index
|
|
|
|
key = event.key()
|
|
mods = event.modifiers()
|
|
|
|
# Ctrl+A = select all
|
|
if key == Qt.Key.Key_A and mods & Qt.KeyboardModifier.ControlModifier:
|
|
self.select_all()
|
|
return
|
|
|
|
if key in (Qt.Key.Key_Right, Qt.Key.Key_L):
|
|
self._nav_horizontal(1)
|
|
elif key in (Qt.Key.Key_Left, Qt.Key.Key_H):
|
|
self._nav_horizontal(-1)
|
|
elif key in (Qt.Key.Key_Down, Qt.Key.Key_J):
|
|
target = idx + cols
|
|
if target >= len(self._thumbs):
|
|
# If there are posts ahead in the last row, go to the last one
|
|
if idx < len(self._thumbs) - 1:
|
|
self._select(len(self._thumbs) - 1)
|
|
else:
|
|
self.nav_past_end.emit()
|
|
else:
|
|
self._select(target)
|
|
elif key in (Qt.Key.Key_Up, Qt.Key.Key_K):
|
|
target = idx - cols
|
|
if target < 0:
|
|
if idx > 0:
|
|
self._select(0)
|
|
else:
|
|
self.nav_before_start.emit()
|
|
else:
|
|
self._select(target)
|
|
elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter:
|
|
if 0 <= idx < len(self._thumbs):
|
|
self.post_activated.emit(idx)
|
|
elif key == Qt.Key.Key_Escape:
|
|
self.clear_selection()
|
|
elif key == Qt.Key.Key_Home:
|
|
self._select(0)
|
|
elif key == Qt.Key.Key_End:
|
|
self._select(len(self._thumbs) - 1)
|
|
else:
|
|
super().keyPressEvent(event)
|
|
|
|
def scroll_to_top(self) -> None:
|
|
self.verticalScrollBar().setValue(0)
|
|
|
|
def scroll_to_bottom(self) -> None:
|
|
self.verticalScrollBar().setValue(self.verticalScrollBar().maximum())
|
|
|
|
def _check_scroll_bottom(self, value: int) -> None:
|
|
sb = self.verticalScrollBar()
|
|
# Trigger when within 3 rows of the bottom for early prefetch
|
|
threshold = (THUMB_SIZE + THUMB_SPACING) * 3
|
|
if sb.maximum() > 0 and value >= sb.maximum() - threshold:
|
|
self.reached_bottom.emit()
|
|
if value <= 0 and sb.maximum() > 0:
|
|
self.reached_top.emit()
|
|
self._recycle_offscreen()
|
|
|
|
def _recycle_offscreen(self) -> None:
|
|
"""Release decoded pixmaps for thumbnails far from the viewport.
|
|
|
|
Thumbnails within the visible area plus a buffer zone keep their
|
|
pixmaps. Thumbnails outside that zone have their pixmap set to
|
|
None to free decoded-image memory. When they scroll back into
|
|
view, the pixmap is re-decoded from the on-disk thumbnail cache
|
|
via ``_source_path``.
|
|
|
|
This caps decoded-thumbnail memory to roughly (visible + buffer)
|
|
widgets instead of every widget ever created during infinite scroll.
|
|
"""
|
|
if not self._thumbs:
|
|
return
|
|
step = THUMB_SIZE + THUMB_SPACING
|
|
if step == 0:
|
|
return
|
|
cols = self._flow.columns
|
|
vp_top = self.verticalScrollBar().value()
|
|
vp_height = self.viewport().height()
|
|
|
|
# Row range that's visible (0-based row indices)
|
|
first_visible_row = max(0, (vp_top - THUMB_SPACING) // step)
|
|
last_visible_row = (vp_top + vp_height) // step
|
|
|
|
# Buffer: keep ±5 rows of decoded pixmaps beyond the viewport
|
|
buffer_rows = 5
|
|
keep_first = max(0, first_visible_row - buffer_rows)
|
|
keep_last = last_visible_row + buffer_rows
|
|
|
|
keep_start = keep_first * cols
|
|
keep_end = min(len(self._thumbs), (keep_last + 1) * cols)
|
|
|
|
for i, thumb in enumerate(self._thumbs):
|
|
if keep_start <= i < keep_end:
|
|
# Inside keep zone — restore if missing
|
|
if thumb._pixmap is None and thumb._source_path:
|
|
pix = QPixmap(thumb._source_path)
|
|
if not pix.isNull():
|
|
thumb._pixmap = pix.scaled(
|
|
THUMB_SIZE - 4, THUMB_SIZE - 4,
|
|
Qt.AspectRatioMode.KeepAspectRatio,
|
|
Qt.TransformationMode.SmoothTransformation,
|
|
)
|
|
thumb._thumb_opacity = 1.0
|
|
thumb.update()
|
|
else:
|
|
# Outside keep zone — release
|
|
if thumb._pixmap is not None:
|
|
thumb._pixmap = None
|
|
|
|
def _nav_horizontal(self, direction: int) -> None:
|
|
"""Move selection one cell left (-1) or right (+1); emit edge signals at boundaries."""
|
|
idx = self._selected_index
|
|
target = idx + direction
|
|
if target < 0:
|
|
self.nav_before_start.emit()
|
|
elif target >= len(self._thumbs):
|
|
self.nav_past_end.emit()
|
|
else:
|
|
self._select(target)
|
|
|
|
def wheelEvent(self, event: QWheelEvent) -> None:
|
|
delta = event.angleDelta().x()
|
|
if delta > 30:
|
|
self._nav_horizontal(-1)
|
|
elif delta < -30:
|
|
self._nav_horizontal(1)
|
|
else:
|
|
super().wheelEvent(event)
|
|
|
|
def resizeEvent(self, event) -> None:
|
|
super().resizeEvent(event)
|
|
if self._flow:
|
|
self._flow.resize(self.viewport().size().width(), self._flow.minimumHeight())
|
|
# Qt Wayland buffer goes stale after compositor-driven resize
|
|
# (Hyprland tiled geometry change). Thumbs reflow but paint
|
|
# skips until a scroll/click invalidates the viewport. Force
|
|
# repaint so the grid stays visible through tile resizes.
|
|
self.viewport().update()
|