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.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):
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user