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
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:
parent
8f9f387848
commit
c1ee894e7b
@ -26,7 +26,7 @@ from pyPhotoAlbum.decorators import ribbon_action, numerical_input
|
||||
from pyPhotoAlbum.project import Project, Page
|
||||
from pyPhotoAlbum.async_project_loader import AsyncProjectLoader
|
||||
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.version_manager import format_version_info, CURRENT_DATA_VERSION
|
||||
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")
|
||||
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
|
||||
file_path = self.project.file_path if hasattr(self.project, "file_path") and self.project.file_path else None
|
||||
|
||||
@ -287,8 +287,25 @@ class FileOperationsMixin:
|
||||
if file_path:
|
||||
print(f"Saving project to: {file_path}")
|
||||
|
||||
# Save project to ZIP
|
||||
success, error = save_to_zip(self.project, file_path)
|
||||
# Create loading widget if not exists
|
||||
if not hasattr(self, "_loading_widget"):
|
||||
self._loading_widget = LoadingWidget(self)
|
||||
|
||||
# Show loading widget
|
||||
self._loading_widget.show_loading("Saving project...")
|
||||
|
||||
# Define callbacks for async save
|
||||
def on_progress(progress: int, message: str):
|
||||
"""Update progress display"""
|
||||
if hasattr(self, "_loading_widget"):
|
||||
self._loading_widget.set_progress(progress, 100)
|
||||
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
|
||||
@ -298,8 +315,20 @@ class FileOperationsMixin:
|
||||
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")
|
||||
def heal_assets(self):
|
||||
"""Open the asset healing dialog to reconnect missing images"""
|
||||
|
||||
@ -7,7 +7,8 @@ import json
|
||||
import zipfile
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Optional, Tuple
|
||||
import threading
|
||||
from typing import Optional, Tuple, Callable
|
||||
from pathlib import Path
|
||||
from pyPhotoAlbum.project import Project
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
Load a project from a ZIP file.
|
||||
|
||||
@ -7,10 +7,10 @@ from typing import Optional, List, Tuple
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
|
||||
QLabel, QFileDialog, QDockWidget
|
||||
QLabel, QFileDialog, QDockWidget, QScrollBar
|
||||
)
|
||||
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 pyPhotoAlbum.gl_imports import *
|
||||
@ -20,6 +20,15 @@ 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."""
|
||||
|
||||
@ -61,6 +70,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
|
||||
super().__init__()
|
||||
|
||||
self.thumbnails: List[ThumbnailItem] = []
|
||||
self.date_headers: List[DateHeader] = []
|
||||
self.current_folder: Optional[Path] = None
|
||||
|
||||
# Store reference to main window
|
||||
@ -74,6 +84,14 @@ class ThumbnailGLWidget(QOpenGLWidget):
|
||||
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))
|
||||
|
||||
@ -105,6 +123,9 @@ class ThumbnailGLWidget(QOpenGLWidget):
|
||||
# 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."""
|
||||
@ -118,10 +139,42 @@ class ThumbnailGLWidget(QOpenGLWidget):
|
||||
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()
|
||||
@ -195,6 +248,36 @@ class ThumbnailGLWidget(QOpenGLWidget):
|
||||
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:
|
||||
@ -282,33 +365,140 @@ class ThumbnailGLWidget(QOpenGLWidget):
|
||||
# Build a map of existing thumbnails by path to reuse them
|
||||
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.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):
|
||||
row = idx // columns
|
||||
col = idx % columns
|
||||
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]
|
||||
# Update grid position
|
||||
thumb.grid_row = row
|
||||
thumb.grid_col = col
|
||||
# Recalculate position with horizontal centering
|
||||
thumb.x = h_offset + col * (thumb.thumbnail_size + spacing) + spacing
|
||||
thumb.y = row * (thumb.thumbnail_size + spacing) + spacing
|
||||
thumb.x = thumb_x
|
||||
thumb.y = thumb_y
|
||||
else:
|
||||
# Create new placeholder thumbnail with horizontal centering
|
||||
# Create new placeholder thumbnail
|
||||
thumb = ThumbnailItem(image_path, (row, col))
|
||||
thumb.x = h_offset + col * (thumb.thumbnail_size + spacing) + spacing
|
||||
thumb.y = row * (thumb.thumbnail_size + spacing) + spacing
|
||||
# Request async load (will be skipped if already loading/loaded)
|
||||
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
|
||||
@ -438,6 +628,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
|
||||
self.pan_offset[1] + delta.y()
|
||||
)
|
||||
self.drag_start_pos = event.pos()
|
||||
self._update_scrollbar_position()
|
||||
self.update()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
@ -483,6 +674,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
|
||||
self.pan_offset[1] + scroll_amount
|
||||
)
|
||||
|
||||
self._update_scrollbar_position()
|
||||
self.update()
|
||||
|
||||
|
||||
@ -513,9 +705,54 @@ class ThumbnailBrowserDock(QDockWidget):
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
@ -573,3 +810,96 @@ class ThumbnailBrowserDock(QDockWidget):
|
||||
"""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 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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user