pax becdb2d18e Grid boundary nav: up/down/left/right past edge loads next/prev page
All arrow keys and hjkl now trigger page turns at grid boundaries,
not just left/right in preview mode.
2026-04-04 19:53:07 -05:00

395 lines
14 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, QMimeData, QUrl, QPoint
from PySide6.QtGui import QPixmap, QPainter, QColor, QPen, QKeyEvent, QDrag
from PySide6.QtWidgets import (
QWidget,
QScrollArea,
QMenu,
QApplication,
)
from ..core.api.base import Post
THUMB_SIZE = 180
THUMB_SPACING = 8
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
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._favorited = False
self._saved_locally = False
self._hover = False
self._drag_start: QPoint | None = None
self._cached_path: str | None = None
self.setFixedSize(THUMB_SIZE, THUMB_SIZE)
self.setCursor(Qt.CursorShape.PointingHandCursor)
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.update()
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_favorited(self, favorited: bool) -> None:
self._favorited = favorited
self.update()
def set_saved_locally(self, saved: bool) -> None:
self._saved_locally = saved
self.update()
def paintEvent(self, event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
pal = self.palette()
highlight = pal.color(pal.ColorRole.Highlight)
base = pal.color(pal.ColorRole.Base)
mid = pal.color(pal.ColorRole.Mid)
window = pal.color(pal.ColorRole.Window)
# Background
if self._multi_selected:
bg = highlight.darker(200)
elif self._hover:
bg = window.lighter(130)
else:
bg = window
p.fillRect(self.rect(), bg)
# Border
if self._selected:
pen = QPen(highlight, BORDER_WIDTH)
elif self._multi_selected:
pen = QPen(highlight.darker(150), BORDER_WIDTH)
else:
pen = QPen(mid, 1)
p.setPen(pen)
p.drawRect(self.rect().adjusted(0, 0, -1, -1))
# Thumbnail
if self._pixmap:
x = (self.width() - self._pixmap.width()) // 2
y = (self.height() - self._pixmap.height()) // 2
p.drawPixmap(x, y, self._pixmap)
# Favorite/saved indicator
if self._favorited:
p.setPen(Qt.PenStyle.NoPen)
if self._saved_locally:
p.setBrush(QColor("#22cc22"))
else:
p.setBrush(QColor("#ff4444"))
p.drawEllipse(self.width() - 14, 4, 10, 10)
# Multi-select checkmark
if self._multi_selected:
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(highlight)
p.drawEllipse(4, 4, 12, 12)
p.setPen(QPen(base, 2))
p.drawLine(7, 10, 9, 13)
p.drawLine(9, 13, 14, 7)
p.end()
def enterEvent(self, event) -> None:
self._hover = True
self.update()
def leaveEvent(self, event) -> None:
self._hover = False
self.update()
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 mouseMoveEvent(self, event) -> None:
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
return
super().mouseMoveEvent(event)
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:
if not self._items:
return
x, y = THUMB_SPACING, THUMB_SPACING
row_height = 0
width = self.width() or 800
for widget in self._items:
item_w = widget.width() + THUMB_SPACING
item_h = widget.height() + THUMB_SPACING
if x + item_w > width and x > THUMB_SPACING:
x = THUMB_SPACING
y += row_height
row_height = 0
widget.move(x, y)
widget.show()
x += item_w
row_height = max(row_height, item_h)
self.setMinimumHeight(y + row_height + THUMB_SPACING)
@property
def columns(self) -> int:
if not self._items:
return 1
w = self.width() or 800
return max(1, w // (THUMB_SIZE + THUMB_SPACING))
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() # keyboard nav past last post
nav_before_start = Signal() # keyboard nav before first post
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)
@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 _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 _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:
# Right-click on multi-selected: bulk context menu
self.multi_context_requested.emit(sorted(self._multi_selected), pos)
else:
self._select(index)
self.context_requested.emit(index, pos)
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):
if idx + 1 >= len(self._thumbs):
self.nav_past_end.emit()
else:
self._select(idx + 1)
elif key in (Qt.Key.Key_Left, Qt.Key.Key_H):
if idx - 1 < 0:
self.nav_before_start.emit()
else:
self._select(idx - 1)
elif key in (Qt.Key.Key_Down, Qt.Key.Key_J):
if idx + cols >= len(self._thumbs):
self.nav_past_end.emit()
else:
self._select(idx + cols)
elif key in (Qt.Key.Key_Up, Qt.Key.Key_K):
if idx - cols < 0:
self.nav_before_start.emit()
else:
self._select(idx - cols)
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_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()
if sb.maximum() > 0 and value >= sb.maximum() - 10:
self.reached_bottom.emit()
if value <= 0 and sb.maximum() > 0:
self.reached_top.emit()
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
if self._flow:
self._flow.resize(self.viewport().size().width(), self._flow.minimumHeight())