713 lines
29 KiB
Python

"""Thumbnail grid widget for the Qt6 GUI."""
from __future__ import annotations
from pathlib import Path
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 (
QWidget,
QScrollArea,
QMenu,
QApplication,
QRubberBand,
)
from ..core.api.base import Post
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._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) -> None:
self._pixmap = pixmap.scaled(
THUMB_SIZE - 4, THUMB_SIZE - 4,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
self._thumb_opacity = 0.0
self._fade_anim = QPropertyAnimation(self, b"thumbOpacity")
self._fade_anim.setDuration(200)
self._fade_anim.setStartValue(0.0)
self._fade_anim.setEndValue(1.0)
self._fade_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
self._fade_anim.start()
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)
mid = self._idle_color
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:
# 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 mousePressEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self._drag_start = event.position().toPoint()
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
def mouseDoubleClickEvent(self, event) -> None:
self._drag_start = None
if event.button() == Qt.MouseButton.LeftButton:
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:
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)
self.viewport().installEventFilter(self)
# Rubber band drag selection
self._rubber_band: QRubberBand | None = None
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)
thumb.installEventFilter(self)
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)
thumb.installEventFilter(self)
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 _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 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 mouseMoveEvent(self, event: QMouseEvent) -> None:
if self._rb_origin and self._rubber_band:
rb_rect = QRect(self._rb_origin, event.position().toPoint()).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):
thumb_rect = thumb.geometry().translated(vp_offset)
if rb_rect.intersects(thumb_rect):
self._multi_selected.add(i)
thumb.set_multi_selected(True)
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
return
# Reset any stuck cursor from a cancelled drag-and-drop
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()
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())