pyPhotoAlbum/pyPhotoAlbum/thumbnail_browser.py
Duncan Tourolle 6a791b1397
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
Use md5 to only store unique content
2025-12-31 12:46:01 +01:00

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()