Sort library tab, background saving
All checks were successful
Python CI / test (push) Successful in 1m26s
Lint / lint (push) Successful in 1m13s
Tests / test (3.11) (push) Successful in 1m40s
Tests / test (3.12) (push) Successful in 1m46s
Tests / test (3.13) (push) Successful in 1m36s
Tests / test (3.14) (push) Successful in 1m13s

This commit is contained in:
Duncan Tourolle 2025-12-13 16:53:43 +01:00
parent 8f9f387848
commit c1ee894e7b
3 changed files with 537 additions and 28 deletions

View File

@ -26,7 +26,7 @@ from pyPhotoAlbum.decorators import ribbon_action, numerical_input
from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.async_project_loader import AsyncProjectLoader from pyPhotoAlbum.async_project_loader import AsyncProjectLoader
from pyPhotoAlbum.loading_widget import LoadingWidget from pyPhotoAlbum.loading_widget import LoadingWidget
from pyPhotoAlbum.project_serializer import save_to_zip from pyPhotoAlbum.project_serializer import save_to_zip, save_to_zip_async
from pyPhotoAlbum.models import set_asset_resolution_context from pyPhotoAlbum.models import set_asset_resolution_context
from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
@ -275,7 +275,7 @@ class FileOperationsMixin:
@ribbon_action(label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S") @ribbon_action(label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S")
def save_project(self): def save_project(self):
"""Save the current project""" """Save the current project asynchronously with progress feedback"""
# If project has a file path, use it; otherwise prompt for location # If project has a file path, use it; otherwise prompt for location
file_path = self.project.file_path if hasattr(self.project, "file_path") and self.project.file_path else None file_path = self.project.file_path if hasattr(self.project, "file_path") and self.project.file_path else None
@ -287,18 +287,47 @@ class FileOperationsMixin:
if file_path: if file_path:
print(f"Saving project to: {file_path}") print(f"Saving project to: {file_path}")
# Save project to ZIP # Create loading widget if not exists
success, error = save_to_zip(self.project, file_path) if not hasattr(self, "_loading_widget"):
self._loading_widget = LoadingWidget(self)
if success: # Show loading widget
self.project.file_path = file_path self._loading_widget.show_loading("Saving project...")
self.project.mark_clean()
self.show_status(f"Project saved: {file_path}") # Define callbacks for async save
print(f"Successfully saved project to: {file_path}") def on_progress(progress: int, message: str):
else: """Update progress display"""
error_msg = f"Failed to save project: {error}" if hasattr(self, "_loading_widget"):
self.show_status(error_msg) self._loading_widget.set_progress(progress, 100)
print(error_msg) self._loading_widget.set_status(message)
def on_complete(success: bool, error: str):
"""Handle save completion"""
# Hide loading widget
if hasattr(self, "_loading_widget"):
self._loading_widget.hide_loading()
if success:
self.project.file_path = file_path
self.project.mark_clean()
self.show_status(f"Project saved: {file_path}")
print(f"Successfully saved project to: {file_path}")
else:
error_msg = f"Failed to save project: {error}"
self.show_status(error_msg)
self.show_error("Save Failed", error_msg)
print(error_msg)
# Start async save
save_to_zip_async(
self.project,
file_path,
on_complete=on_complete,
on_progress=on_progress
)
# Show immediate feedback
self.show_status("Saving project in background...", 2000)
@ribbon_action(label="Heal Assets", tooltip="Reconnect missing image assets", tab="Home", group="File") @ribbon_action(label="Heal Assets", tooltip="Reconnect missing image assets", tab="Home", group="File")
def heal_assets(self): def heal_assets(self):

View File

@ -7,7 +7,8 @@ import json
import zipfile import zipfile
import shutil import shutil
import tempfile import tempfile
from typing import Optional, Tuple import threading
from typing import Optional, Tuple, Callable
from pathlib import Path from pathlib import Path
from pyPhotoAlbum.project import Project from pyPhotoAlbum.project import Project
from pyPhotoAlbum.version_manager import ( from pyPhotoAlbum.version_manager import (
@ -171,6 +172,155 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
return False, error_msg return False, error_msg
def save_to_zip_async(
project: Project,
zip_path: str,
on_complete: Optional[Callable[[bool, Optional[str]], None]] = None,
on_progress: Optional[Callable[[int, str], None]] = None,
) -> threading.Thread:
"""
Save a project to a ZIP file asynchronously in a background thread.
This provides instant UI responsiveness by:
1. Immediately serializing project.json to a temp folder (fast)
2. Creating the ZIP file in a background thread (slow)
3. Calling on_complete when done
Args:
project: The Project instance to save
zip_path: Path where the ZIP file should be created
on_complete: Optional callback(success: bool, error_msg: Optional[str])
called when save completes
on_progress: Optional callback(progress: int, message: str) where
progress is 0-100 and message describes current step
Returns:
The background thread (already started)
"""
def _background_save():
"""Background thread function to create the ZIP file."""
temp_dir = None
try:
# Report progress: Starting
if on_progress:
on_progress(0, "Preparing to save...")
# Ensure .ppz extension
final_zip_path = zip_path
if not final_zip_path.lower().endswith(".ppz"):
final_zip_path += ".ppz"
# Check for and import any external images before saving
if on_progress:
on_progress(5, "Checking for external images...")
_import_external_images(project)
# Serialize project to dictionary
if on_progress:
on_progress(10, "Serializing project data...")
project_data = project.serialize()
# Add version information
project_data["serialization_version"] = SERIALIZATION_VERSION
project_data["data_version"] = CURRENT_DATA_VERSION
# Create a temporary directory for staging
if on_progress:
on_progress(15, "Creating temporary staging area...")
temp_dir = tempfile.mkdtemp(prefix="pyPhotoAlbum_save_")
# Write project.json to temp directory
if on_progress:
on_progress(20, "Writing project metadata...")
temp_project_json = os.path.join(temp_dir, "project.json")
with open(temp_project_json, "w") as f:
json.dump(project_data, f, indent=2, sort_keys=True)
# Create temp ZIP file (not final location - for atomic write)
temp_zip_path = os.path.join(temp_dir, "project.ppz")
# Count assets for progress reporting
assets_folder = project.asset_manager.assets_folder
total_files = 1 # project.json
asset_files = []
if os.path.exists(assets_folder):
for root, dirs, files in os.walk(assets_folder):
for file in files:
asset_files.append((root, file))
total_files += 1
if on_progress:
on_progress(25, f"Creating ZIP archive ({total_files} files)...")
# Create ZIP file in temp location
with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
# Write project.json
zipf.write(temp_project_json, "project.json")
# Add all assets with progress reporting
if asset_files:
# Progress from 25% to 90% for assets
progress_range = 90 - 25
for idx, (root, file) in enumerate(asset_files):
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, project.folder_path)
zipf.write(file_path, arcname)
# Report progress every 10 files or at end
if idx % 10 == 0 or idx == len(asset_files) - 1:
progress = 25 + int((idx + 1) / len(asset_files) * progress_range)
if on_progress:
on_progress(
progress,
f"Adding assets... ({idx + 1}/{len(asset_files)})"
)
# Atomic move: move temp ZIP to final location
if on_progress:
on_progress(95, "Finalizing save...")
# Ensure parent directory exists
os.makedirs(os.path.dirname(os.path.abspath(final_zip_path)), exist_ok=True)
# Remove old file if it exists
if os.path.exists(final_zip_path):
os.remove(final_zip_path)
# Move temp ZIP to final location (atomic on same filesystem)
shutil.move(temp_zip_path, final_zip_path)
if on_progress:
on_progress(100, "Save complete!")
print(f"Project saved to {final_zip_path}")
# Call completion callback with success
if on_complete:
on_complete(True, None)
except Exception as e:
error_msg = f"Error saving project: {str(e)}"
print(error_msg)
# Call completion callback with error
if on_complete:
on_complete(False, error_msg)
finally:
# Clean up temp directory
if temp_dir and os.path.exists(temp_dir):
try:
shutil.rmtree(temp_dir)
except Exception:
pass # Ignore cleanup errors
# Start background thread
save_thread = threading.Thread(target=_background_save, daemon=True)
save_thread.start()
return save_thread
def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project: def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
""" """
Load a project from a ZIP file. Load a project from a ZIP file.

View File

@ -7,10 +7,10 @@ from typing import Optional, List, Tuple
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QFileDialog, QDockWidget QLabel, QFileDialog, QDockWidget, QScrollBar
) )
from PyQt6.QtCore import Qt, QSize, QMimeData, QUrl, QPoint from PyQt6.QtCore import Qt, QSize, QMimeData, QUrl, QPoint
from PyQt6.QtGui import QDrag, QCursor from PyQt6.QtGui import QDrag, QCursor, QPainter, QFont, QColor
from PyQt6.QtOpenGLWidgets import QOpenGLWidget from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.gl_imports import * from pyPhotoAlbum.gl_imports import *
@ -20,6 +20,15 @@ from pyPhotoAlbum.mixins.viewport import ViewportMixin
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"] 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: class ThumbnailItem:
"""Represents a thumbnail with position and path information.""" """Represents a thumbnail with position and path information."""
@ -61,6 +70,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
super().__init__() super().__init__()
self.thumbnails: List[ThumbnailItem] = [] self.thumbnails: List[ThumbnailItem] = []
self.date_headers: List[DateHeader] = []
self.current_folder: Optional[Path] = None self.current_folder: Optional[Path] = None
# Store reference to main window # Store reference to main window
@ -74,6 +84,14 @@ class ThumbnailGLWidget(QOpenGLWidget):
self.drag_start_pos = None self.drag_start_pos = None
self.dragging_thumbnail = 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 # Enable OpenGL
self.setMinimumSize(QSize(250, 300)) self.setMinimumSize(QSize(250, 300))
@ -105,6 +123,9 @@ class ThumbnailGLWidget(QOpenGLWidget):
# Rearrange thumbnails to fit new width # Rearrange thumbnails to fit new width
if hasattr(self, 'image_files') and self.image_files: if hasattr(self, 'image_files') and self.image_files:
self._arrange_thumbnails() self._arrange_thumbnails()
else:
# Still update scrollbar even if no thumbnails
self._update_scrollbar_range()
def paintGL(self): def paintGL(self):
"""Render thumbnails.""" """Render thumbnails."""
@ -118,10 +139,42 @@ class ThumbnailGLWidget(QOpenGLWidget):
glTranslatef(self.pan_offset[0], self.pan_offset[1], 0) glTranslatef(self.pan_offset[0], self.pan_offset[1], 0)
glScalef(self.zoom_level, self.zoom_level, 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) # Render each thumbnail (placeholders or textures)
for thumb in self.thumbnails: for thumb in self.thumbnails:
self._render_thumbnail(thumb) 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): def _render_thumbnail(self, thumb: ThumbnailItem):
"""Render a single thumbnail using placeholder pattern.""" """Render a single thumbnail using placeholder pattern."""
x, y, w, h = thumb.get_bounds() x, y, w, h = thumb.get_bounds()
@ -195,6 +248,36 @@ class ThumbnailGLWidget(QOpenGLWidget):
glVertex2f(x, y + h) glVertex2f(x, y + h)
glEnd() 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): def _create_texture_for_thumbnail(self, thumb: ThumbnailItem):
"""Create OpenGL texture from pending PIL image.""" """Create OpenGL texture from pending PIL image."""
if not thumb._pending_pil_image: if not thumb._pending_pil_image:
@ -282,33 +365,140 @@ class ThumbnailGLWidget(QOpenGLWidget):
# Build a map of existing thumbnails by path to reuse them # Build a map of existing thumbnails by path to reuse them
existing_thumbs = {thumb.image_path: thumb for thumb in self.thumbnails} existing_thumbs = {thumb.image_path: thumb for thumb in self.thumbnails}
# Clear list but reuse thumbnail objects # Clear lists but reuse thumbnail objects
self.thumbnails.clear() 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): for idx, image_file in enumerate(self.image_files):
row = idx // columns
col = idx % columns
image_path = str(image_file) 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 # Reuse existing thumbnail if available, otherwise create new
if image_path in existing_thumbs: if image_path in existing_thumbs:
thumb = existing_thumbs[image_path] thumb = existing_thumbs[image_path]
# Update grid position
thumb.grid_row = row thumb.grid_row = row
thumb.grid_col = col thumb.grid_col = col
# Recalculate position with horizontal centering thumb.x = thumb_x
thumb.x = h_offset + col * (thumb.thumbnail_size + spacing) + spacing thumb.y = thumb_y
thumb.y = row * (thumb.thumbnail_size + spacing) + spacing
else: else:
# Create new placeholder thumbnail with horizontal centering # Create new placeholder thumbnail
thumb = ThumbnailItem(image_path, (row, col)) thumb = ThumbnailItem(image_path, (row, col))
thumb.x = h_offset + col * (thumb.thumbnail_size + spacing) + spacing thumb.x = thumb_x
thumb.y = row * (thumb.thumbnail_size + spacing) + spacing thumb.y = thumb_y
# Request async load (will be skipped if already loading/loaded) # Request async load
self._request_thumbnail_load(thumb) self._request_thumbnail_load(thumb)
self.thumbnails.append(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): def update_used_images(self):
"""Update which thumbnails are already used in the project.""" """Update which thumbnails are already used in the project."""
# Get reference to main window's project # Get reference to main window's project
@ -438,6 +628,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
self.pan_offset[1] + delta.y() self.pan_offset[1] + delta.y()
) )
self.drag_start_pos = event.pos() self.drag_start_pos = event.pos()
self._update_scrollbar_position()
self.update() self.update()
def mouseReleaseEvent(self, event): def mouseReleaseEvent(self, event):
@ -483,6 +674,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
self.pan_offset[1] + scroll_amount self.pan_offset[1] + scroll_amount
) )
self._update_scrollbar_position()
self.update() self.update()
@ -513,9 +705,54 @@ class ThumbnailBrowserDock(QDockWidget):
layout.addLayout(header_layout) 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 # GL Widget for thumbnails
self.gl_widget = ThumbnailGLWidget(main_window=parent) self.gl_widget = ThumbnailGLWidget(main_window=parent)
layout.addWidget(self.gl_widget) 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) self.setWidget(main_widget)
@ -573,3 +810,96 @@ class ThumbnailBrowserDock(QDockWidget):
"""Load thumbnails from folder.""" """Load thumbnails from folder."""
self.folder_label.setText(f"Folder: {folder_path.name}") self.folder_label.setText(f"Folder: {folder_path.name}")
self.gl_widget.load_folder(folder_path) 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 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()