All checks were successful
Python CI / test (push) Successful in 3m12s
Lint / lint (push) Successful in 1m38s
Tests / test (3.11) (push) Successful in 2m26s
Tests / test (3.12) (push) Successful in 3m13s
Tests / test (3.13) (push) Successful in 3m9s
Tests / test (3.14) (push) Successful in 1m20s
908 lines
33 KiB
Python
908 lines
33 KiB
Python
"""
|
|
Thumbnail Browser Widget - displays thumbnails from a folder for drag-and-drop into album.
|
|
"""
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Optional, List, Tuple
|
|
|
|
from PyQt6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
|
|
QLabel, QFileDialog, QDockWidget, QScrollBar
|
|
)
|
|
from PyQt6.QtCore import Qt, QSize, QMimeData, QUrl, QPoint
|
|
from PyQt6.QtGui import QDrag, QCursor, QPainter, QFont, QColor
|
|
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
|
|
|
|
from pyPhotoAlbum.gl_imports import *
|
|
from pyPhotoAlbum.mixins.viewport import ViewportMixin
|
|
|
|
|
|
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"]
|
|
|
|
|
|
class DateHeader:
|
|
"""Represents a date separator header in the thumbnail list."""
|
|
|
|
def __init__(self, date_text: str, y_position: float):
|
|
self.date_text = date_text
|
|
self.y = y_position
|
|
self.height = 30.0 # Height of the header bar
|
|
|
|
|
|
class ThumbnailItem:
|
|
"""Represents a thumbnail with position and path information."""
|
|
|
|
def __init__(self, image_path: str, grid_pos: Tuple[int, int], thumbnail_size: float = 100.0):
|
|
self.image_path = image_path
|
|
self.grid_row, self.grid_col = grid_pos
|
|
self.thumbnail_size = thumbnail_size
|
|
self.is_used_in_project = False # Will be updated when checking against project
|
|
|
|
# Position in mm (will be calculated based on grid)
|
|
spacing = 10.0 # mm spacing between thumbnails
|
|
self.x = self.grid_col * (self.thumbnail_size + spacing) + spacing
|
|
self.y = self.grid_row * (self.thumbnail_size + spacing) + spacing
|
|
|
|
# Texture info (loaded async)
|
|
self._texture_id = None
|
|
self._pending_pil_image = None
|
|
self._async_loading = False
|
|
self._img_width = None
|
|
self._img_height = None
|
|
|
|
def get_bounds(self) -> Tuple[float, float, float, float]:
|
|
"""Return (x, y, width, height) bounds."""
|
|
return (self.x, self.y, self.thumbnail_size, self.thumbnail_size)
|
|
|
|
def contains_point(self, x: float, y: float) -> bool:
|
|
"""Check if point is inside this thumbnail."""
|
|
return (self.x <= x <= self.x + self.thumbnail_size and
|
|
self.y <= y <= self.y + self.thumbnail_size)
|
|
|
|
|
|
class ThumbnailGLWidget(QOpenGLWidget):
|
|
"""
|
|
OpenGL widget that displays thumbnails in a grid.
|
|
Uses the same async loading and texture system as the main canvas.
|
|
"""
|
|
|
|
def __init__(self, main_window=None):
|
|
super().__init__()
|
|
|
|
self.thumbnails: List[ThumbnailItem] = []
|
|
self.date_headers: List[DateHeader] = []
|
|
self.current_folder: Optional[Path] = None
|
|
|
|
# Store reference to main window
|
|
self._main_window = main_window
|
|
|
|
# Viewport state
|
|
self.zoom_level = 1.0
|
|
self.pan_offset = (0, 0)
|
|
|
|
# Dragging state
|
|
self.drag_start_pos = None
|
|
self.dragging_thumbnail = None
|
|
|
|
# Scrollbar (created but managed by parent)
|
|
self.scrollbar = None
|
|
self._updating_scrollbar = False # Flag to prevent circular updates
|
|
|
|
# Sort mode (set by parent dock)
|
|
self.sort_mode = "name"
|
|
self._get_image_date_func = None # Function to get date from parent
|
|
|
|
# Enable OpenGL
|
|
self.setMinimumSize(QSize(250, 300))
|
|
|
|
def window(self):
|
|
"""Override window() to return stored main_window reference."""
|
|
return self._main_window if self._main_window else super().window()
|
|
|
|
def update(self):
|
|
"""Override update to batch repaints for better performance."""
|
|
# Just schedule the update - Qt will automatically batch multiple
|
|
# update() calls into a single paintGL() invocation
|
|
super().update()
|
|
|
|
def initializeGL(self):
|
|
"""Initialize OpenGL context."""
|
|
glClearColor(0.95, 0.95, 0.95, 1.0) # Light gray background
|
|
glEnable(GL_BLEND)
|
|
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
|
glEnable(GL_TEXTURE_2D)
|
|
|
|
def resizeGL(self, w, h):
|
|
"""Handle resize events."""
|
|
glViewport(0, 0, w, h)
|
|
glMatrixMode(GL_PROJECTION)
|
|
glLoadIdentity()
|
|
glOrtho(0, w, h, 0, -1, 1) # 2D orthographic projection
|
|
glMatrixMode(GL_MODELVIEW)
|
|
|
|
# Rearrange thumbnails to fit new width
|
|
if hasattr(self, 'image_files') and self.image_files:
|
|
self._arrange_thumbnails()
|
|
else:
|
|
# Still update scrollbar even if no thumbnails
|
|
self._update_scrollbar_range()
|
|
|
|
def paintGL(self):
|
|
"""Render thumbnails."""
|
|
glClear(GL_COLOR_BUFFER_BIT)
|
|
glLoadIdentity()
|
|
|
|
if not self.thumbnails:
|
|
return
|
|
|
|
# Apply zoom and pan
|
|
glTranslatef(self.pan_offset[0], self.pan_offset[1], 0)
|
|
glScalef(self.zoom_level, self.zoom_level, 1.0)
|
|
|
|
# Render date headers first (so they appear behind thumbnails)
|
|
for header in self.date_headers:
|
|
self._render_date_header(header)
|
|
|
|
# Render each thumbnail (placeholders or textures)
|
|
for thumb in self.thumbnails:
|
|
self._render_thumbnail(thumb)
|
|
|
|
def paintEvent(self, event):
|
|
"""Override paintEvent to add text labels after OpenGL rendering."""
|
|
# Call the default OpenGL paint
|
|
super().paintEvent(event)
|
|
|
|
# Draw text labels for date headers using QPainter
|
|
if self.date_headers and self.sort_mode == "date":
|
|
painter = QPainter(self)
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
|
|
# Set font for date labels
|
|
font = QFont("Arial", 11, QFont.Weight.Bold)
|
|
painter.setFont(font)
|
|
painter.setPen(QColor(255, 255, 255)) # White text
|
|
|
|
for header in self.date_headers:
|
|
# Transform header position to screen coordinates
|
|
screen_y = header.y * self.zoom_level + self.pan_offset[1]
|
|
screen_h = header.height * self.zoom_level
|
|
|
|
# Only draw if header is visible
|
|
if screen_y + screen_h >= 0 and screen_y <= self.height():
|
|
# Draw text centered vertically in the header bar
|
|
text_y = int(screen_y + screen_h / 2)
|
|
painter.drawText(10, text_y + 5, header.date_text)
|
|
|
|
painter.end()
|
|
|
|
def _render_thumbnail(self, thumb: ThumbnailItem):
|
|
"""Render a single thumbnail using placeholder pattern."""
|
|
x, y, w, h = thumb.get_bounds()
|
|
|
|
# If we have a pending image, convert it to texture (happens once per image)
|
|
if hasattr(thumb, "_pending_pil_image") and thumb._pending_pil_image is not None:
|
|
self._create_texture_for_thumbnail(thumb)
|
|
|
|
# Render based on state: texture, loading placeholder, or empty placeholder
|
|
if thumb._texture_id:
|
|
# Calculate aspect-ratio-corrected dimensions
|
|
if hasattr(thumb, '_img_width') and hasattr(thumb, '_img_height'):
|
|
img_aspect = thumb._img_width / thumb._img_height
|
|
thumb_aspect = w / h
|
|
|
|
if img_aspect > thumb_aspect:
|
|
# Image is wider - fit to width
|
|
render_w = w
|
|
render_h = w / img_aspect
|
|
render_x = x
|
|
render_y = y + (h - render_h) / 2
|
|
else:
|
|
# Image is taller - fit to height
|
|
render_h = h
|
|
render_w = h * img_aspect
|
|
render_x = x + (w - render_w) / 2
|
|
render_y = y
|
|
else:
|
|
# No aspect ratio info, use full bounds
|
|
render_x, render_y, render_w, render_h = x, y, w, h
|
|
|
|
# Render actual texture
|
|
glEnable(GL_TEXTURE_2D)
|
|
glBindTexture(GL_TEXTURE_2D, thumb._texture_id)
|
|
|
|
# If used in project, desaturate by tinting grey
|
|
if thumb.is_used_in_project:
|
|
glColor4f(0.5, 0.5, 0.5, 0.6) # Grey tint + partial transparency
|
|
else:
|
|
glColor4f(1.0, 1.0, 1.0, 1.0)
|
|
|
|
glBegin(GL_QUADS)
|
|
glTexCoord2f(0.0, 0.0)
|
|
glVertex2f(render_x, render_y)
|
|
glTexCoord2f(1.0, 0.0)
|
|
glVertex2f(render_x + render_w, render_y)
|
|
glTexCoord2f(1.0, 1.0)
|
|
glVertex2f(render_x + render_w, render_y + render_h)
|
|
glTexCoord2f(0.0, 1.0)
|
|
glVertex2f(render_x, render_y + render_h)
|
|
glEnd()
|
|
|
|
glDisable(GL_TEXTURE_2D)
|
|
else:
|
|
# Render placeholder (grey box while loading or if load failed)
|
|
glColor3f(0.8, 0.8, 0.8)
|
|
glBegin(GL_QUADS)
|
|
glVertex2f(x, y)
|
|
glVertex2f(x + w, y)
|
|
glVertex2f(x + w, y + h)
|
|
glVertex2f(x, y + h)
|
|
glEnd()
|
|
|
|
# Border
|
|
glColor3f(0.5, 0.5, 0.5)
|
|
glLineWidth(1.0)
|
|
glBegin(GL_LINE_LOOP)
|
|
glVertex2f(x, y)
|
|
glVertex2f(x + w, y)
|
|
glVertex2f(x + w, y + h)
|
|
glVertex2f(x, y + h)
|
|
glEnd()
|
|
|
|
def _render_date_header(self, header: DateHeader):
|
|
"""Render a date separator header."""
|
|
# Calculate full width bar
|
|
widget_width = self.width() / self.zoom_level
|
|
x = 0
|
|
y = header.y
|
|
w = widget_width
|
|
h = header.height
|
|
|
|
# Draw background bar (dark blue-gray)
|
|
glColor3f(0.3, 0.4, 0.5)
|
|
glBegin(GL_QUADS)
|
|
glVertex2f(x, y)
|
|
glVertex2f(x + w, y)
|
|
glVertex2f(x + w, y + h)
|
|
glVertex2f(x, y + h)
|
|
glEnd()
|
|
|
|
# Draw bottom border
|
|
glColor3f(0.2, 0.3, 0.4)
|
|
glLineWidth(2.0)
|
|
glBegin(GL_LINES)
|
|
glVertex2f(x, y + h)
|
|
glVertex2f(x + w, y + h)
|
|
glEnd()
|
|
|
|
# Note: Text rendering would require QPainter overlay
|
|
# For now, the colored bar serves as a visual separator
|
|
# Text will be added using QPainter in a future enhancement
|
|
|
|
def _create_texture_for_thumbnail(self, thumb: ThumbnailItem):
|
|
"""Create OpenGL texture from pending PIL image."""
|
|
if not thumb._pending_pil_image:
|
|
return False
|
|
|
|
try:
|
|
pil_image = thumb._pending_pil_image
|
|
|
|
# Ensure RGBA
|
|
if pil_image.mode != "RGBA":
|
|
pil_image = pil_image.convert("RGBA")
|
|
|
|
# Delete old texture
|
|
if thumb._texture_id:
|
|
glDeleteTextures([thumb._texture_id])
|
|
|
|
# Create texture
|
|
img_data = pil_image.tobytes()
|
|
texture_id = glGenTextures(1)
|
|
glBindTexture(GL_TEXTURE_2D, texture_id)
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
|
|
glTexImage2D(
|
|
GL_TEXTURE_2D, 0, GL_RGBA,
|
|
pil_image.width, pil_image.height,
|
|
0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
|
|
)
|
|
|
|
thumb._texture_id = texture_id
|
|
thumb._img_width = pil_image.width
|
|
thumb._img_height = pil_image.height
|
|
thumb._pending_pil_image = None
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"Error creating texture for thumbnail: {e}")
|
|
thumb._pending_pil_image = None
|
|
return False
|
|
|
|
def load_folder(self, folder_path: Path):
|
|
"""Load thumbnails from a folder."""
|
|
self.current_folder = folder_path
|
|
|
|
# Find all image files
|
|
self.image_files = []
|
|
for ext in IMAGE_EXTENSIONS:
|
|
self.image_files.extend(folder_path.glob(f"*{ext}"))
|
|
self.image_files.extend(folder_path.glob(f"*{ext.upper()}"))
|
|
|
|
self.image_files.sort()
|
|
|
|
# Arrange thumbnails based on current widget size and zoom
|
|
self._arrange_thumbnails()
|
|
|
|
# Update which images are already in use
|
|
self.update_used_images()
|
|
|
|
self.update()
|
|
|
|
def _arrange_thumbnails(self):
|
|
"""Arrange thumbnails in a grid based on widget width and zoom level."""
|
|
if not hasattr(self, 'image_files') or not self.image_files:
|
|
self.thumbnails.clear()
|
|
return
|
|
|
|
# Calculate number of columns that fit
|
|
widget_width = self.width()
|
|
if widget_width <= 0:
|
|
widget_width = 250 # Default minimum width
|
|
|
|
# Thumbnail size in screen pixels (affected by zoom)
|
|
thumb_size_screen = 100.0 * self.zoom_level
|
|
spacing_screen = 10.0 * self.zoom_level
|
|
|
|
# Calculate columns
|
|
columns = max(1, int((widget_width - spacing_screen) / (thumb_size_screen + spacing_screen)))
|
|
|
|
# Calculate total grid width to center it
|
|
spacing = 10.0
|
|
grid_width = columns * (100.0 + spacing) - spacing # Total width in base units
|
|
# Horizontal offset to center the grid
|
|
h_offset = max(0, (widget_width / self.zoom_level - grid_width) / 2)
|
|
|
|
# Build a map of existing thumbnails by path to reuse them
|
|
existing_thumbs = {thumb.image_path: thumb for thumb in self.thumbnails}
|
|
|
|
# Clear lists but reuse thumbnail objects
|
|
self.thumbnails.clear()
|
|
self.date_headers.clear()
|
|
|
|
# For date mode: track current date and positioning
|
|
current_date_str = None
|
|
section_start_y = spacing
|
|
row_in_section = 0
|
|
col = 0
|
|
|
|
for idx, image_file in enumerate(self.image_files):
|
|
image_path = str(image_file)
|
|
|
|
# Check if we need a date header (only in date sort mode)
|
|
if self.sort_mode == "date" and self._get_image_date_func:
|
|
from datetime import datetime
|
|
timestamp = self._get_image_date_func(image_file)
|
|
date_obj = datetime.fromtimestamp(timestamp)
|
|
date_str = date_obj.strftime("%B %d, %Y") # e.g., "December 13, 2025"
|
|
|
|
if date_str != current_date_str:
|
|
# Starting a new date section
|
|
if current_date_str is not None:
|
|
# Not the first section - calculate where this section starts
|
|
# It should start after the last thumbnail of the previous section
|
|
if self.thumbnails:
|
|
last_thumb = self.thumbnails[-1]
|
|
# Start after the last row of previous section
|
|
last_row_y = last_thumb.y + last_thumb.thumbnail_size
|
|
section_start_y = last_row_y + spacing * 2 # Extra spacing between sections
|
|
|
|
# Add header at section start
|
|
header = DateHeader(date_str, section_start_y)
|
|
self.date_headers.append(header)
|
|
|
|
# Update section_start_y to after the header
|
|
section_start_y += header.height + spacing
|
|
|
|
current_date_str = date_str
|
|
row_in_section = 0
|
|
col = 0
|
|
|
|
# Calculate position
|
|
if self.sort_mode == "date":
|
|
# In date mode: position relative to section start
|
|
row = row_in_section
|
|
thumb_y = section_start_y + row * (100.0 + spacing)
|
|
else:
|
|
# In other modes: simple grid based on overall index
|
|
row = idx // columns
|
|
thumb_y = row * (100.0 + spacing) + spacing
|
|
|
|
# Calculate X position (always centered)
|
|
thumb_x = h_offset + col * (100.0 + spacing) + spacing
|
|
|
|
# Reuse existing thumbnail if available, otherwise create new
|
|
if image_path in existing_thumbs:
|
|
thumb = existing_thumbs[image_path]
|
|
thumb.grid_row = row
|
|
thumb.grid_col = col
|
|
thumb.x = thumb_x
|
|
thumb.y = thumb_y
|
|
else:
|
|
# Create new placeholder thumbnail
|
|
thumb = ThumbnailItem(image_path, (row, col))
|
|
thumb.x = thumb_x
|
|
thumb.y = thumb_y
|
|
# Request async load
|
|
self._request_thumbnail_load(thumb)
|
|
|
|
self.thumbnails.append(thumb)
|
|
|
|
# Update column and row counters
|
|
col += 1
|
|
if col >= columns:
|
|
col = 0
|
|
row_in_section += 1
|
|
|
|
# Update scrollbar range after arranging
|
|
self._update_scrollbar_range()
|
|
|
|
def _update_scrollbar_range(self):
|
|
"""Update scrollbar range based on content height."""
|
|
if not self.scrollbar or self._updating_scrollbar:
|
|
return
|
|
|
|
if not self.thumbnails:
|
|
self.scrollbar.setRange(0, 0)
|
|
self.scrollbar.setPageStep(self.height())
|
|
return
|
|
|
|
# Calculate total content height
|
|
if self.thumbnails:
|
|
# Find the maximum Y position
|
|
max_y = max(thumb.y + thumb.thumbnail_size for thumb in self.thumbnails)
|
|
content_height = max_y * self.zoom_level
|
|
else:
|
|
content_height = 0
|
|
|
|
# Visible height
|
|
visible_height = self.height()
|
|
|
|
# Scrollable range
|
|
scroll_range = max(0, int(content_height - visible_height))
|
|
|
|
self._updating_scrollbar = True
|
|
self.scrollbar.setRange(0, scroll_range)
|
|
self.scrollbar.setPageStep(visible_height)
|
|
self.scrollbar.setSingleStep(int(visible_height / 10)) # 10% of visible height per step
|
|
|
|
# Update scrollbar position based on current pan
|
|
scroll_pos = int(-self.pan_offset[1])
|
|
self.scrollbar.setValue(scroll_pos)
|
|
self._updating_scrollbar = False
|
|
|
|
def _on_scrollbar_changed(self, value):
|
|
"""Handle scrollbar value change."""
|
|
if self._updating_scrollbar:
|
|
return
|
|
|
|
# Update pan offset based on scrollbar value
|
|
self.pan_offset = (0, -value)
|
|
self.update()
|
|
|
|
def _update_scrollbar_position(self):
|
|
"""Update scrollbar position based on current pan offset."""
|
|
if not self.scrollbar or self._updating_scrollbar:
|
|
return
|
|
|
|
self._updating_scrollbar = True
|
|
scroll_pos = int(-self.pan_offset[1])
|
|
self.scrollbar.setValue(scroll_pos)
|
|
self._updating_scrollbar = False
|
|
|
|
def update_used_images(self):
|
|
"""Update which thumbnails are already used in the project."""
|
|
# Get reference to main window's project
|
|
main_window = self.window()
|
|
if not hasattr(main_window, 'project') or not main_window.project:
|
|
return
|
|
|
|
project = main_window.project
|
|
|
|
# Collect all image paths used in the project
|
|
used_paths = set()
|
|
for page in project.pages:
|
|
from pyPhotoAlbum.models import ImageData
|
|
for element in page.layout.elements:
|
|
if isinstance(element, ImageData) and element.image_path:
|
|
# Resolve to absolute path for comparison
|
|
abs_path = element.resolve_image_path()
|
|
if abs_path:
|
|
used_paths.add(abs_path)
|
|
|
|
# Mark thumbnails as used
|
|
for thumb in self.thumbnails:
|
|
thumb.is_used_in_project = thumb.image_path in used_paths
|
|
|
|
def _request_thumbnail_load(self, thumb: ThumbnailItem):
|
|
"""Request async load for a thumbnail using main window's loader."""
|
|
# Skip if already loading or loaded
|
|
if thumb._async_loading or thumb._texture_id:
|
|
return
|
|
|
|
# Get main window's async loader
|
|
main_window = self.window()
|
|
if not main_window or not hasattr(main_window, '_gl_widget'):
|
|
return
|
|
|
|
gl_widget = main_window._gl_widget
|
|
if not hasattr(gl_widget, 'async_image_loader'):
|
|
return
|
|
|
|
from pyPhotoAlbum.async_backend import LoadPriority
|
|
|
|
try:
|
|
# Mark as loading to prevent duplicate requests
|
|
thumb._async_loading = True
|
|
|
|
# Request load through main window's async loader
|
|
# Use LOW priority for thumbnails to not interfere with main canvas
|
|
gl_widget.async_image_loader.request_load(
|
|
Path(thumb.image_path),
|
|
priority=LoadPriority.LOW,
|
|
target_size=(200, 200), # Small thumbnails
|
|
user_data=thumb
|
|
)
|
|
except RuntimeError:
|
|
thumb._async_loading = False # Reset on error
|
|
|
|
def _on_image_loaded(self, path: Path, image, user_data):
|
|
"""Handle async image loaded - sets pending image on the placeholder."""
|
|
if isinstance(user_data, ThumbnailItem):
|
|
# Store the loaded image in the placeholder
|
|
user_data._pending_pil_image = image
|
|
user_data._img_width = image.width
|
|
user_data._img_height = image.height
|
|
user_data._async_loading = False
|
|
|
|
# Schedule a repaint (will be batched if many images load quickly)
|
|
self.update()
|
|
|
|
def _on_image_load_failed(self, path: Path, error_msg: str, user_data):
|
|
"""Handle async image load failure."""
|
|
pass # Silently ignore load failures for thumbnails
|
|
|
|
def screen_to_viewport(self, screen_x: int, screen_y: int) -> Tuple[float, float]:
|
|
"""Convert screen coordinates to viewport coordinates (accounting for zoom/pan)."""
|
|
vp_x = (screen_x - self.pan_offset[0]) / self.zoom_level
|
|
vp_y = (screen_y - self.pan_offset[1]) / self.zoom_level
|
|
return vp_x, vp_y
|
|
|
|
def get_thumbnail_at(self, screen_x: int, screen_y: int) -> Optional[ThumbnailItem]:
|
|
"""Get thumbnail at screen position."""
|
|
vp_x, vp_y = self.screen_to_viewport(screen_x, screen_y)
|
|
|
|
for thumb in self.thumbnails:
|
|
if thumb.contains_point(vp_x, vp_y):
|
|
return thumb
|
|
|
|
return None
|
|
|
|
def mousePressEvent(self, event):
|
|
"""Handle mouse press for drag."""
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
self.drag_start_pos = event.pos()
|
|
self.dragging_thumbnail = self.get_thumbnail_at(event.pos().x(), event.pos().y())
|
|
|
|
def mouseMoveEvent(self, event):
|
|
"""Handle mouse move for drag or pan."""
|
|
if not (event.buttons() & Qt.MouseButton.LeftButton):
|
|
return
|
|
|
|
if self.drag_start_pos is None:
|
|
return
|
|
|
|
# Check if we should start dragging a thumbnail
|
|
if self.dragging_thumbnail:
|
|
# Start drag operation
|
|
drag = QDrag(self)
|
|
mime_data = QMimeData()
|
|
|
|
# Set file URL for the drag
|
|
url = QUrl.fromLocalFile(self.dragging_thumbnail.image_path)
|
|
mime_data.setUrls([url])
|
|
|
|
drag.setMimeData(mime_data)
|
|
|
|
# Execute drag (this blocks until drop or cancel)
|
|
drag.exec(Qt.DropAction.CopyAction)
|
|
|
|
# Reset drag state
|
|
self.drag_start_pos = None
|
|
self.dragging_thumbnail = None
|
|
else:
|
|
# Pan the view (right-click or middle-click drag)
|
|
# Only allow vertical panning - grid is always horizontally centered
|
|
delta = event.pos() - self.drag_start_pos
|
|
self.pan_offset = (
|
|
0, # No horizontal pan - grid is centered
|
|
self.pan_offset[1] + delta.y()
|
|
)
|
|
self.drag_start_pos = event.pos()
|
|
self._update_scrollbar_position()
|
|
self.update()
|
|
|
|
def mouseReleaseEvent(self, event):
|
|
"""Handle mouse release."""
|
|
self.drag_start_pos = None
|
|
self.dragging_thumbnail = None
|
|
|
|
def wheelEvent(self, event):
|
|
"""Handle mouse wheel for scrolling (or zooming with Ctrl)."""
|
|
delta = event.angleDelta().y()
|
|
|
|
# Check if Ctrl is pressed for zooming
|
|
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
# Zoom mode
|
|
mouse_y = event.position().y()
|
|
|
|
zoom_factor = 1.1 if delta > 0 else 0.9
|
|
|
|
# Calculate vertical world position before zoom
|
|
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
|
|
|
|
# Apply zoom
|
|
old_zoom = self.zoom_level
|
|
self.zoom_level *= zoom_factor
|
|
self.zoom_level = max(0.1, min(5.0, self.zoom_level)) # Clamp
|
|
|
|
# Rearrange thumbnails if zoom level changed significantly
|
|
# This recalculates horizontal centering
|
|
if abs(self.zoom_level - old_zoom) > 0.01:
|
|
self._arrange_thumbnails()
|
|
|
|
# Adjust vertical pan to keep mouse position fixed
|
|
# Keep horizontal pan at 0 (grid is always horizontally centered)
|
|
self.pan_offset = (
|
|
0, # No horizontal pan - grid is centered in _arrange_thumbnails
|
|
mouse_y - world_y * self.zoom_level
|
|
)
|
|
else:
|
|
# Scroll mode - scroll vertically only
|
|
scroll_amount = delta * 0.5 # Adjust sensitivity
|
|
self.pan_offset = (
|
|
0, # No horizontal pan
|
|
self.pan_offset[1] + scroll_amount
|
|
)
|
|
|
|
self._update_scrollbar_position()
|
|
self.update()
|
|
|
|
|
|
class ThumbnailBrowserDock(QDockWidget):
|
|
"""
|
|
Dockable widget containing the thumbnail browser.
|
|
"""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__("Image Browser", parent)
|
|
|
|
# Create main widget
|
|
main_widget = QWidget()
|
|
layout = QVBoxLayout(main_widget)
|
|
layout.setContentsMargins(5, 5, 5, 5)
|
|
layout.setSpacing(5)
|
|
|
|
# Header with folder selection
|
|
header_layout = QHBoxLayout()
|
|
|
|
self.folder_label = QLabel("No folder selected")
|
|
self.folder_label.setStyleSheet("font-weight: bold; padding: 5px;")
|
|
header_layout.addWidget(self.folder_label)
|
|
|
|
self.select_folder_btn = QPushButton("Select Folder...")
|
|
self.select_folder_btn.clicked.connect(self._select_folder)
|
|
header_layout.addWidget(self.select_folder_btn)
|
|
|
|
layout.addLayout(header_layout)
|
|
|
|
# Sort toolbar
|
|
sort_layout = QHBoxLayout()
|
|
sort_layout.setContentsMargins(5, 0, 5, 5)
|
|
|
|
sort_label = QLabel("Sort by:")
|
|
sort_layout.addWidget(sort_label)
|
|
|
|
self.sort_name_btn = QPushButton("Name")
|
|
self.sort_name_btn.setCheckable(True)
|
|
self.sort_name_btn.setChecked(True) # Default sort
|
|
self.sort_name_btn.clicked.connect(lambda: self._sort_by("name"))
|
|
sort_layout.addWidget(self.sort_name_btn)
|
|
|
|
self.sort_date_btn = QPushButton("Date")
|
|
self.sort_date_btn.setCheckable(True)
|
|
self.sort_date_btn.clicked.connect(lambda: self._sort_by("date"))
|
|
sort_layout.addWidget(self.sort_date_btn)
|
|
|
|
self.sort_camera_btn = QPushButton("Camera")
|
|
self.sort_camera_btn.setCheckable(True)
|
|
self.sort_camera_btn.clicked.connect(lambda: self._sort_by("camera"))
|
|
sort_layout.addWidget(self.sort_camera_btn)
|
|
|
|
sort_layout.addStretch()
|
|
|
|
layout.addLayout(sort_layout)
|
|
|
|
# Track current sort mode
|
|
self.current_sort = "name"
|
|
|
|
# Create horizontal layout for GL widget and scrollbar
|
|
browser_layout = QHBoxLayout()
|
|
browser_layout.setContentsMargins(0, 0, 0, 0)
|
|
browser_layout.setSpacing(0)
|
|
|
|
# GL Widget for thumbnails
|
|
self.gl_widget = ThumbnailGLWidget(main_window=parent)
|
|
browser_layout.addWidget(self.gl_widget)
|
|
|
|
# Vertical scrollbar
|
|
self.scrollbar = QScrollBar(Qt.Orientation.Vertical)
|
|
self.scrollbar.valueChanged.connect(self.gl_widget._on_scrollbar_changed)
|
|
browser_layout.addWidget(self.scrollbar)
|
|
|
|
# Connect scrollbar to GL widget
|
|
self.gl_widget.scrollbar = self.scrollbar
|
|
|
|
layout.addLayout(browser_layout)
|
|
|
|
self.setWidget(main_widget)
|
|
|
|
# Dock settings
|
|
self.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea |
|
|
Qt.DockWidgetArea.RightDockWidgetArea)
|
|
self.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable |
|
|
QDockWidget.DockWidgetFeature.DockWidgetMovable |
|
|
QDockWidget.DockWidgetFeature.DockWidgetFloatable)
|
|
|
|
# Connect to main window's async loader when shown
|
|
self._connect_async_loader()
|
|
|
|
def _connect_async_loader(self):
|
|
"""Connect to main window's async image loader."""
|
|
main_window = self.window()
|
|
if not hasattr(main_window, '_gl_widget'):
|
|
return
|
|
|
|
gl_widget = main_window._gl_widget
|
|
if not hasattr(gl_widget, 'async_image_loader'):
|
|
return
|
|
|
|
# Avoid duplicate connections
|
|
if hasattr(self, '_async_connected') and self._async_connected:
|
|
return
|
|
|
|
try:
|
|
# Connect signals
|
|
gl_widget.async_image_loader.image_loaded.connect(self.gl_widget._on_image_loaded)
|
|
gl_widget.async_image_loader.load_failed.connect(self.gl_widget._on_image_load_failed)
|
|
self._async_connected = True
|
|
except Exception:
|
|
pass # Silently handle connection errors
|
|
|
|
def showEvent(self, event):
|
|
"""Handle show event."""
|
|
super().showEvent(event)
|
|
# Ensure async loader is connected when shown
|
|
self._connect_async_loader()
|
|
|
|
def _select_folder(self):
|
|
"""Open dialog to select folder."""
|
|
folder_path = QFileDialog.getExistingDirectory(
|
|
self,
|
|
"Select Image Folder",
|
|
str(self.gl_widget.current_folder) if self.gl_widget.current_folder else str(Path.home()),
|
|
QFileDialog.Option.ShowDirsOnly
|
|
)
|
|
|
|
if folder_path:
|
|
self.load_folder(Path(folder_path))
|
|
|
|
def load_folder(self, folder_path: Path):
|
|
"""Load thumbnails from folder."""
|
|
self.folder_label.setText(f"Folder: {folder_path.name}")
|
|
self.gl_widget.load_folder(folder_path)
|
|
# Apply current sort after loading
|
|
self._apply_sort()
|
|
self.gl_widget._arrange_thumbnails()
|
|
self.gl_widget.update_used_images()
|
|
self.gl_widget.update()
|
|
|
|
def _sort_by(self, sort_mode: str):
|
|
"""Sort thumbnails by the specified mode."""
|
|
# Update button states (only one can be checked)
|
|
self.sort_name_btn.setChecked(sort_mode == "name")
|
|
self.sort_date_btn.setChecked(sort_mode == "date")
|
|
self.sort_camera_btn.setChecked(sort_mode == "camera")
|
|
|
|
self.current_sort = sort_mode
|
|
|
|
# Re-sort the image files in the GL widget
|
|
if hasattr(self.gl_widget, 'image_files') and self.gl_widget.image_files:
|
|
self._apply_sort()
|
|
# Re-arrange thumbnails with new order
|
|
self.gl_widget._arrange_thumbnails()
|
|
self.gl_widget.update_used_images()
|
|
self.gl_widget.update()
|
|
|
|
def _apply_sort(self):
|
|
"""Apply current sort mode to image files."""
|
|
if not hasattr(self.gl_widget, 'image_files') or not self.gl_widget.image_files:
|
|
return
|
|
if self.current_sort == "name":
|
|
# Sort by filename only (not full path)
|
|
self.gl_widget.image_files.sort(key=lambda p: p.name.lower())
|
|
# Clear date headers for non-date sorts
|
|
self.gl_widget.date_headers.clear()
|
|
# Reset sort mode in GL widget
|
|
self.gl_widget.sort_mode = "name"
|
|
self.gl_widget._get_image_date_func = None
|
|
elif self.current_sort == "date":
|
|
# Sort by file modification time (or EXIF date if available)
|
|
self.gl_widget.image_files.sort(key=self._get_image_date)
|
|
# Date headers will be created during _arrange_thumbnails
|
|
self.gl_widget.sort_mode = "date"
|
|
self.gl_widget._get_image_date_func = self._get_image_date
|
|
elif self.current_sort == "camera":
|
|
# Sort by camera model from EXIF
|
|
self.gl_widget.image_files.sort(key=self._get_camera_model)
|
|
# Clear date headers for non-date sorts
|
|
self.gl_widget.date_headers.clear()
|
|
# Reset sort mode in GL widget
|
|
self.gl_widget.sort_mode = "camera"
|
|
self.gl_widget._get_image_date_func = None
|
|
|
|
def _get_image_date(self, image_path: Path) -> float:
|
|
"""Get image date from EXIF or file modification time."""
|
|
try:
|
|
from PIL import Image
|
|
from PIL.ExifTags import TAGS
|
|
|
|
with Image.open(image_path) as img:
|
|
exif = img.getexif()
|
|
if exif:
|
|
# Look for DateTimeOriginal (when photo was taken)
|
|
for tag_id, value in exif.items():
|
|
tag = TAGS.get(tag_id, tag_id)
|
|
if tag == "DateTimeOriginal":
|
|
# Convert EXIF date format "2023:12:13 14:30:00" to timestamp
|
|
from datetime import datetime
|
|
try:
|
|
dt = datetime.strptime(value, "%Y:%m:%d %H:%M:%S")
|
|
return dt.timestamp()
|
|
except:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback to file modification time
|
|
return image_path.stat().st_mtime
|
|
|
|
def _get_camera_model(self, image_path: Path) -> str:
|
|
"""Get camera model from EXIF metadata."""
|
|
try:
|
|
from PIL import Image
|
|
from PIL.ExifTags import TAGS
|
|
|
|
with Image.open(image_path) as img:
|
|
exif = img.getexif()
|
|
if exif:
|
|
# Look for camera model
|
|
for tag_id, value in exif.items():
|
|
tag = TAGS.get(tag_id, tag_id)
|
|
if tag == "Model":
|
|
return str(value).strip()
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback to filename if no EXIF data
|
|
return image_path.name.lower()
|