"""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() > 30): 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()