Coverage for pyPhotoAlbum/thumbnail_browser.py: 43%
517 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2Thumbnail Browser Widget - displays thumbnails from a folder for drag-and-drop into album.
3"""
5import os
6from pathlib import Path
7from typing import Optional, List, Tuple
9from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFileDialog, QDockWidget, QScrollBar
10from PyQt6.QtCore import Qt, QSize, QMimeData, QUrl, QPoint
11from PyQt6.QtGui import QDrag, QCursor, QPainter, QFont, QColor
12from PyQt6.QtOpenGLWidgets import QOpenGLWidget
14from pyPhotoAlbum.gl_imports import *
15from pyPhotoAlbum.mixins.viewport import ViewportMixin
17IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"]
20class DateHeader:
21 """Represents a date separator header in the thumbnail list."""
23 def __init__(self, date_text: str, y_position: float):
24 self.date_text = date_text
25 self.y = y_position
26 self.height = 30.0 # Height of the header bar
29class ThumbnailItem:
30 """Represents a thumbnail with position and path information."""
32 def __init__(self, image_path: str, grid_pos: Tuple[int, int], thumbnail_size: float = 100.0):
33 self.image_path = image_path
34 self.grid_row, self.grid_col = grid_pos
35 self.thumbnail_size = thumbnail_size
36 self.is_used_in_project = False # Will be updated when checking against project
38 # Position in mm (will be calculated based on grid)
39 spacing = 10.0 # mm spacing between thumbnails
40 self.x = self.grid_col * (self.thumbnail_size + spacing) + spacing
41 self.y = self.grid_row * (self.thumbnail_size + spacing) + spacing
43 # Texture info (loaded async)
44 self._texture_id = None
45 self._pending_pil_image = None
46 self._async_loading = False
47 self._img_width = None
48 self._img_height = None
50 def get_bounds(self) -> Tuple[float, float, float, float]:
51 """Return (x, y, width, height) bounds."""
52 return (self.x, self.y, self.thumbnail_size, self.thumbnail_size)
54 def contains_point(self, x: float, y: float) -> bool:
55 """Check if point is inside this thumbnail."""
56 return self.x <= x <= self.x + self.thumbnail_size and self.y <= y <= self.y + self.thumbnail_size
59class ThumbnailGLWidget(QOpenGLWidget):
60 """
61 OpenGL widget that displays thumbnails in a grid.
62 Uses the same async loading and texture system as the main canvas.
63 """
65 def __init__(self, main_window=None):
66 super().__init__()
68 self.thumbnails: List[ThumbnailItem] = []
69 self.date_headers: List[DateHeader] = []
70 self.current_folder: Optional[Path] = None
72 # Store reference to main window
73 self._main_window = main_window
75 # Viewport state
76 self.zoom_level = 1.0
77 self.pan_offset = (0, 0)
79 # Dragging state
80 self.drag_start_pos = None
81 self.dragging_thumbnail = None
83 # Scrollbar (created but managed by parent)
84 self.scrollbar = None
85 self._updating_scrollbar = False # Flag to prevent circular updates
87 # Sort mode (set by parent dock)
88 self.sort_mode = "name"
89 self._get_image_date_func = None # Function to get date from parent
91 # Enable OpenGL
92 self.setMinimumSize(QSize(250, 300))
94 def window(self):
95 """Override window() to return stored main_window reference."""
96 return self._main_window if self._main_window else super().window()
98 def update(self):
99 """Override update to batch repaints for better performance."""
100 # Just schedule the update - Qt will automatically batch multiple
101 # update() calls into a single paintGL() invocation
102 super().update()
104 def initializeGL(self):
105 """Initialize OpenGL context."""
106 glClearColor(0.95, 0.95, 0.95, 1.0) # Light gray background
107 glEnable(GL_BLEND)
108 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
109 glEnable(GL_TEXTURE_2D)
111 def resizeGL(self, w, h):
112 """Handle resize events."""
113 glViewport(0, 0, w, h)
114 glMatrixMode(GL_PROJECTION)
115 glLoadIdentity()
116 glOrtho(0, w, h, 0, -1, 1) # 2D orthographic projection
117 glMatrixMode(GL_MODELVIEW)
119 # Rearrange thumbnails to fit new width
120 if hasattr(self, "image_files") and self.image_files:
121 self._arrange_thumbnails()
122 else:
123 # Still update scrollbar even if no thumbnails
124 self._update_scrollbar_range()
126 def paintGL(self):
127 """Render thumbnails."""
128 glClear(GL_COLOR_BUFFER_BIT)
129 glLoadIdentity()
131 if not self.thumbnails:
132 return
134 # Apply zoom and pan
135 glTranslatef(self.pan_offset[0], self.pan_offset[1], 0)
136 glScalef(self.zoom_level, self.zoom_level, 1.0)
138 # Render date headers first (so they appear behind thumbnails)
139 for header in self.date_headers:
140 self._render_date_header(header)
142 # Render each thumbnail (placeholders or textures)
143 for thumb in self.thumbnails:
144 self._render_thumbnail(thumb)
146 def paintEvent(self, event):
147 """Override paintEvent to add text labels after OpenGL rendering."""
148 # Call the default OpenGL paint
149 super().paintEvent(event)
151 # Draw text labels for date headers using QPainter
152 if self.date_headers and self.sort_mode == "date":
153 painter = QPainter(self)
154 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
156 # Set font for date labels
157 font = QFont("Arial", 11, QFont.Weight.Bold)
158 painter.setFont(font)
159 painter.setPen(QColor(255, 255, 255)) # White text
161 for header in self.date_headers:
162 # Transform header position to screen coordinates
163 screen_y = header.y * self.zoom_level + self.pan_offset[1]
164 screen_h = header.height * self.zoom_level
166 # Only draw if header is visible
167 if screen_y + screen_h >= 0 and screen_y <= self.height():
168 # Draw text centered vertically in the header bar
169 text_y = int(screen_y + screen_h / 2)
170 painter.drawText(10, text_y + 5, header.date_text)
172 painter.end()
174 def _render_thumbnail(self, thumb: ThumbnailItem):
175 """Render a single thumbnail using placeholder pattern."""
176 x, y, w, h = thumb.get_bounds()
178 # If we have a pending image, convert it to texture (happens once per image)
179 if hasattr(thumb, "_pending_pil_image") and thumb._pending_pil_image is not None:
180 self._create_texture_for_thumbnail(thumb)
182 # Render based on state: texture, loading placeholder, or empty placeholder
183 if thumb._texture_id:
184 # Calculate aspect-ratio-corrected dimensions
185 if hasattr(thumb, "_img_width") and hasattr(thumb, "_img_height"):
186 img_aspect = thumb._img_width / thumb._img_height
187 thumb_aspect = w / h
189 if img_aspect > thumb_aspect:
190 # Image is wider - fit to width
191 render_w = w
192 render_h = w / img_aspect
193 render_x = x
194 render_y = y + (h - render_h) / 2
195 else:
196 # Image is taller - fit to height
197 render_h = h
198 render_w = h * img_aspect
199 render_x = x + (w - render_w) / 2
200 render_y = y
201 else:
202 # No aspect ratio info, use full bounds
203 render_x, render_y, render_w, render_h = x, y, w, h
205 # Render actual texture
206 glEnable(GL_TEXTURE_2D)
207 glBindTexture(GL_TEXTURE_2D, thumb._texture_id)
209 # If used in project, desaturate by tinting grey
210 if thumb.is_used_in_project:
211 glColor4f(0.5, 0.5, 0.5, 0.6) # Grey tint + partial transparency
212 else:
213 glColor4f(1.0, 1.0, 1.0, 1.0)
215 glBegin(GL_QUADS)
216 glTexCoord2f(0.0, 0.0)
217 glVertex2f(render_x, render_y)
218 glTexCoord2f(1.0, 0.0)
219 glVertex2f(render_x + render_w, render_y)
220 glTexCoord2f(1.0, 1.0)
221 glVertex2f(render_x + render_w, render_y + render_h)
222 glTexCoord2f(0.0, 1.0)
223 glVertex2f(render_x, render_y + render_h)
224 glEnd()
226 glDisable(GL_TEXTURE_2D)
227 else:
228 # Render placeholder (grey box while loading or if load failed)
229 glColor3f(0.8, 0.8, 0.8)
230 glBegin(GL_QUADS)
231 glVertex2f(x, y)
232 glVertex2f(x + w, y)
233 glVertex2f(x + w, y + h)
234 glVertex2f(x, y + h)
235 glEnd()
237 # Border
238 glColor3f(0.5, 0.5, 0.5)
239 glLineWidth(1.0)
240 glBegin(GL_LINE_LOOP)
241 glVertex2f(x, y)
242 glVertex2f(x + w, y)
243 glVertex2f(x + w, y + h)
244 glVertex2f(x, y + h)
245 glEnd()
247 def _render_date_header(self, header: DateHeader):
248 """Render a date separator header."""
249 # Calculate full width bar
250 widget_width = self.width() / self.zoom_level
251 x = 0
252 y = header.y
253 w = widget_width
254 h = header.height
256 # Draw background bar (dark blue-gray)
257 glColor3f(0.3, 0.4, 0.5)
258 glBegin(GL_QUADS)
259 glVertex2f(x, y)
260 glVertex2f(x + w, y)
261 glVertex2f(x + w, y + h)
262 glVertex2f(x, y + h)
263 glEnd()
265 # Draw bottom border
266 glColor3f(0.2, 0.3, 0.4)
267 glLineWidth(2.0)
268 glBegin(GL_LINES)
269 glVertex2f(x, y + h)
270 glVertex2f(x + w, y + h)
271 glEnd()
273 # Note: Text rendering would require QPainter overlay
274 # For now, the colored bar serves as a visual separator
275 # Text will be added using QPainter in a future enhancement
277 def _create_texture_for_thumbnail(self, thumb: ThumbnailItem):
278 """Create OpenGL texture from pending PIL image."""
279 if not thumb._pending_pil_image:
280 return False
282 try:
283 pil_image = thumb._pending_pil_image
285 # Ensure RGBA
286 if pil_image.mode != "RGBA":
287 pil_image = pil_image.convert("RGBA")
289 # Delete old texture
290 if thumb._texture_id:
291 glDeleteTextures([thumb._texture_id])
293 # Create texture
294 img_data = pil_image.tobytes()
295 texture_id = glGenTextures(1)
296 glBindTexture(GL_TEXTURE_2D, texture_id)
297 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
298 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
299 glTexImage2D(
300 GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
301 )
303 thumb._texture_id = texture_id
304 thumb._img_width = pil_image.width
305 thumb._img_height = pil_image.height
306 thumb._pending_pil_image = None
308 return True
310 except Exception as e:
311 print(f"Error creating texture for thumbnail: {e}")
312 thumb._pending_pil_image = None
313 return False
315 def load_folder(self, folder_path: Path):
316 """Load thumbnails from a folder."""
317 self.current_folder = folder_path
319 # Find all image files
320 self.image_files: list[Path] = []
321 for ext in IMAGE_EXTENSIONS:
322 self.image_files.extend(folder_path.glob(f"*{ext}"))
323 self.image_files.extend(folder_path.glob(f"*{ext.upper()}"))
325 self.image_files.sort()
327 # Arrange thumbnails based on current widget size and zoom
328 self._arrange_thumbnails()
330 # Update which images are already in use
331 self.update_used_images()
333 self.update()
335 def _arrange_thumbnails(self):
336 """Arrange thumbnails in a grid based on widget width and zoom level."""
337 if not hasattr(self, "image_files") or not self.image_files:
338 self.thumbnails.clear()
339 return
341 # Calculate number of columns that fit
342 widget_width = self.width()
343 if widget_width <= 0:
344 widget_width = 250 # Default minimum width
346 # Thumbnail size in screen pixels (affected by zoom)
347 thumb_size_screen = 100.0 * self.zoom_level
348 spacing_screen = 10.0 * self.zoom_level
350 # Calculate columns
351 columns = max(1, int((widget_width - spacing_screen) / (thumb_size_screen + spacing_screen)))
353 # Calculate total grid width to center it
354 spacing = 10.0
355 grid_width = columns * (100.0 + spacing) - spacing # Total width in base units
356 # Horizontal offset to center the grid
357 h_offset = max(0, (widget_width / self.zoom_level - grid_width) / 2)
359 # Build a map of existing thumbnails by path to reuse them
360 existing_thumbs = {thumb.image_path: thumb for thumb in self.thumbnails}
362 # Clear lists but reuse thumbnail objects
363 self.thumbnails.clear()
364 self.date_headers.clear()
366 # For date mode: track current date and positioning
367 current_date_str = None
368 section_start_y = spacing
369 row_in_section = 0
370 col = 0
372 for idx, image_file in enumerate(self.image_files):
373 image_path = str(image_file)
375 # Check if we need a date header (only in date sort mode)
376 if self.sort_mode == "date" and self._get_image_date_func:
377 from datetime import datetime
379 timestamp = self._get_image_date_func(image_file)
380 date_obj = datetime.fromtimestamp(timestamp)
381 date_str = date_obj.strftime("%B %d, %Y") # e.g., "December 13, 2025"
383 if date_str != current_date_str:
384 # Starting a new date section
385 if current_date_str is not None:
386 # Not the first section - calculate where this section starts
387 # It should start after the last thumbnail of the previous section
388 if self.thumbnails:
389 last_thumb = self.thumbnails[-1]
390 # Start after the last row of previous section
391 last_row_y = last_thumb.y + last_thumb.thumbnail_size
392 section_start_y = last_row_y + spacing * 2 # Extra spacing between sections
394 # Add header at section start
395 header = DateHeader(date_str, section_start_y)
396 self.date_headers.append(header)
398 # Update section_start_y to after the header
399 section_start_y += header.height + spacing
401 current_date_str = date_str
402 row_in_section = 0
403 col = 0
405 # Calculate position
406 if self.sort_mode == "date":
407 # In date mode: position relative to section start
408 row = row_in_section
409 thumb_y = section_start_y + row * (100.0 + spacing)
410 else:
411 # In other modes: simple grid based on overall index
412 row = idx // columns
413 thumb_y = row * (100.0 + spacing) + spacing
415 # Calculate X position (always centered)
416 thumb_x = h_offset + col * (100.0 + spacing) + spacing
418 # Reuse existing thumbnail if available, otherwise create new
419 if image_path in existing_thumbs:
420 thumb = existing_thumbs[image_path]
421 thumb.grid_row = row
422 thumb.grid_col = col
423 thumb.x = thumb_x
424 thumb.y = thumb_y
425 else:
426 # Create new placeholder thumbnail
427 thumb = ThumbnailItem(image_path, (row, col))
428 thumb.x = thumb_x
429 thumb.y = thumb_y
430 # Request async load
431 self._request_thumbnail_load(thumb)
433 self.thumbnails.append(thumb)
435 # Update column and row counters
436 col += 1
437 if col >= columns:
438 col = 0
439 row_in_section += 1
441 # Update scrollbar range after arranging
442 self._update_scrollbar_range()
444 def _update_scrollbar_range(self):
445 """Update scrollbar range based on content height."""
446 if not self.scrollbar or self._updating_scrollbar:
447 return
449 if not self.thumbnails:
450 self.scrollbar.setRange(0, 0)
451 self.scrollbar.setPageStep(self.height())
452 return
454 # Calculate total content height
455 if self.thumbnails:
456 # Find the maximum Y position
457 max_y = max(thumb.y + thumb.thumbnail_size for thumb in self.thumbnails)
458 content_height = max_y * self.zoom_level
459 else:
460 content_height = 0
462 # Visible height
463 visible_height = self.height()
465 # Scrollable range
466 scroll_range = max(0, int(content_height - visible_height))
468 self._updating_scrollbar = True
469 self.scrollbar.setRange(0, scroll_range)
470 self.scrollbar.setPageStep(visible_height)
471 self.scrollbar.setSingleStep(int(visible_height / 10)) # 10% of visible height per step
473 # Update scrollbar position based on current pan
474 scroll_pos = int(-self.pan_offset[1])
475 self.scrollbar.setValue(scroll_pos)
476 self._updating_scrollbar = False
478 def _on_scrollbar_changed(self, value):
479 """Handle scrollbar value change."""
480 if self._updating_scrollbar:
481 return
483 # Update pan offset based on scrollbar value
484 self.pan_offset = (0, -value)
485 self.update()
487 def _update_scrollbar_position(self):
488 """Update scrollbar position based on current pan offset."""
489 if not self.scrollbar or self._updating_scrollbar:
490 return
492 self._updating_scrollbar = True
493 scroll_pos = int(-self.pan_offset[1])
494 self.scrollbar.setValue(scroll_pos)
495 self._updating_scrollbar = False
497 def update_used_images(self):
498 """Update which thumbnails are already used in the project."""
499 # Get reference to main window's project
500 main_window = self.window()
501 if not hasattr(main_window, "project") or not main_window.project:
502 return
504 project = main_window.project
506 # Collect all image paths used in the project
507 used_paths = set()
508 for page in project.pages:
509 from pyPhotoAlbum.models import ImageData
511 for element in page.layout.elements:
512 if isinstance(element, ImageData) and element.image_path:
513 # Resolve to absolute path for comparison
514 abs_path = element.resolve_image_path()
515 if abs_path:
516 used_paths.add(abs_path)
518 # Mark thumbnails as used
519 for thumb in self.thumbnails:
520 thumb.is_used_in_project = thumb.image_path in used_paths
522 def _request_thumbnail_load(self, thumb: ThumbnailItem):
523 """Request async load for a thumbnail using main window's loader."""
524 # Skip if already loading or loaded
525 if thumb._async_loading or thumb._texture_id:
526 return
528 # Get main window's async loader
529 main_window = self.window()
530 if not main_window or not hasattr(main_window, "_gl_widget"):
531 return
533 gl_widget = main_window._gl_widget
534 if not hasattr(gl_widget, "async_image_loader"):
535 return
537 from pyPhotoAlbum.async_backend import LoadPriority
539 try:
540 # Mark as loading to prevent duplicate requests
541 thumb._async_loading = True
543 # Request load through main window's async loader
544 # Use LOW priority for thumbnails to not interfere with main canvas
545 gl_widget.async_image_loader.request_load(
546 Path(thumb.image_path),
547 priority=LoadPriority.LOW,
548 target_size=(200, 200), # Small thumbnails
549 user_data=thumb,
550 )
551 except RuntimeError:
552 thumb._async_loading = False # Reset on error
554 def _on_image_loaded(self, path: Path, image, user_data):
555 """Handle async image loaded - sets pending image on the placeholder."""
556 if isinstance(user_data, ThumbnailItem):
557 # Store the loaded image in the placeholder
558 user_data._pending_pil_image = image
559 user_data._img_width = image.width
560 user_data._img_height = image.height
561 user_data._async_loading = False
563 # Schedule a repaint (will be batched if many images load quickly)
564 self.update()
566 def _on_image_load_failed(self, path: Path, error_msg: str, user_data):
567 """Handle async image load failure."""
568 pass # Silently ignore load failures for thumbnails
570 def screen_to_viewport(self, screen_x: int, screen_y: int) -> Tuple[float, float]:
571 """Convert screen coordinates to viewport coordinates (accounting for zoom/pan)."""
572 vp_x = (screen_x - self.pan_offset[0]) / self.zoom_level
573 vp_y = (screen_y - self.pan_offset[1]) / self.zoom_level
574 return vp_x, vp_y
576 def get_thumbnail_at(self, screen_x: int, screen_y: int) -> Optional[ThumbnailItem]:
577 """Get thumbnail at screen position."""
578 vp_x, vp_y = self.screen_to_viewport(screen_x, screen_y)
580 for thumb in self.thumbnails:
581 if thumb.contains_point(vp_x, vp_y):
582 return thumb
584 return None
586 def mousePressEvent(self, event):
587 """Handle mouse press for drag."""
588 if event.button() == Qt.MouseButton.LeftButton:
589 self.drag_start_pos = event.pos()
590 self.dragging_thumbnail = self.get_thumbnail_at(event.pos().x(), event.pos().y())
592 def mouseMoveEvent(self, event):
593 """Handle mouse move for drag or pan."""
594 if not (event.buttons() & Qt.MouseButton.LeftButton):
595 return
597 if self.drag_start_pos is None:
598 return
600 # Check if we should start dragging a thumbnail
601 if self.dragging_thumbnail:
602 # Start drag operation
603 drag = QDrag(self)
604 mime_data = QMimeData()
606 # Set file URL for the drag
607 url = QUrl.fromLocalFile(self.dragging_thumbnail.image_path)
608 mime_data.setUrls([url])
610 drag.setMimeData(mime_data)
612 # Execute drag (this blocks until drop or cancel)
613 drag.exec(Qt.DropAction.CopyAction)
615 # Reset drag state
616 self.drag_start_pos = None
617 self.dragging_thumbnail = None
618 else:
619 # Pan the view (right-click or middle-click drag)
620 # Only allow vertical panning - grid is always horizontally centered
621 delta = event.pos() - self.drag_start_pos
622 self.pan_offset = (0, self.pan_offset[1] + delta.y()) # No horizontal pan - grid is centered
623 self.drag_start_pos = event.pos()
624 self._update_scrollbar_position()
625 self.update()
627 def mouseReleaseEvent(self, event):
628 """Handle mouse release."""
629 self.drag_start_pos = None
630 self.dragging_thumbnail = None
632 def wheelEvent(self, event):
633 """Handle mouse wheel for scrolling (or zooming with Ctrl)."""
634 delta = event.angleDelta().y()
636 # Check if Ctrl is pressed for zooming
637 if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
638 # Zoom mode
639 mouse_y = event.position().y()
641 zoom_factor = 1.1 if delta > 0 else 0.9
643 # Calculate vertical world position before zoom
644 world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
646 # Apply zoom
647 old_zoom = self.zoom_level
648 self.zoom_level *= zoom_factor
649 self.zoom_level = max(0.1, min(5.0, self.zoom_level)) # Clamp
651 # Rearrange thumbnails if zoom level changed significantly
652 # This recalculates horizontal centering
653 if abs(self.zoom_level - old_zoom) > 0.01:
654 self._arrange_thumbnails()
656 # Adjust vertical pan to keep mouse position fixed
657 # Keep horizontal pan at 0 (grid is always horizontally centered)
658 self.pan_offset = (
659 0, # No horizontal pan - grid is centered in _arrange_thumbnails
660 mouse_y - world_y * self.zoom_level,
661 )
662 else:
663 # Scroll mode - scroll vertically only
664 scroll_amount = delta * 0.5 # Adjust sensitivity
665 self.pan_offset = (0, self.pan_offset[1] + scroll_amount) # No horizontal pan
667 self._update_scrollbar_position()
668 self.update()
671class ThumbnailBrowserDock(QDockWidget):
672 """
673 Dockable widget containing the thumbnail browser.
674 """
676 def __init__(self, parent=None):
677 super().__init__("Image Browser", parent)
679 # Create main widget
680 main_widget = QWidget()
681 layout = QVBoxLayout(main_widget)
682 layout.setContentsMargins(5, 5, 5, 5)
683 layout.setSpacing(5)
685 # Header with folder selection
686 header_layout = QHBoxLayout()
688 self.folder_label = QLabel("No folder selected")
689 self.folder_label.setStyleSheet("font-weight: bold; padding: 5px;")
690 header_layout.addWidget(self.folder_label)
692 self.select_folder_btn = QPushButton("Select Folder...")
693 self.select_folder_btn.clicked.connect(self._select_folder)
694 header_layout.addWidget(self.select_folder_btn)
696 layout.addLayout(header_layout)
698 # Sort toolbar
699 sort_layout = QHBoxLayout()
700 sort_layout.setContentsMargins(5, 0, 5, 5)
702 sort_label = QLabel("Sort by:")
703 sort_layout.addWidget(sort_label)
705 self.sort_name_btn = QPushButton("Name")
706 self.sort_name_btn.setCheckable(True)
707 self.sort_name_btn.setChecked(True) # Default sort
708 self.sort_name_btn.clicked.connect(lambda: self._sort_by("name"))
709 sort_layout.addWidget(self.sort_name_btn)
711 self.sort_date_btn = QPushButton("Date")
712 self.sort_date_btn.setCheckable(True)
713 self.sort_date_btn.clicked.connect(lambda: self._sort_by("date"))
714 sort_layout.addWidget(self.sort_date_btn)
716 self.sort_camera_btn = QPushButton("Camera")
717 self.sort_camera_btn.setCheckable(True)
718 self.sort_camera_btn.clicked.connect(lambda: self._sort_by("camera"))
719 sort_layout.addWidget(self.sort_camera_btn)
721 sort_layout.addStretch()
723 layout.addLayout(sort_layout)
725 # Track current sort mode
726 self.current_sort = "name"
728 # Create horizontal layout for GL widget and scrollbar
729 browser_layout = QHBoxLayout()
730 browser_layout.setContentsMargins(0, 0, 0, 0)
731 browser_layout.setSpacing(0)
733 # GL Widget for thumbnails
734 self.gl_widget = ThumbnailGLWidget(main_window=parent)
735 browser_layout.addWidget(self.gl_widget)
737 # Vertical scrollbar
738 self.scrollbar = QScrollBar(Qt.Orientation.Vertical)
739 self.scrollbar.valueChanged.connect(self.gl_widget._on_scrollbar_changed)
740 browser_layout.addWidget(self.scrollbar)
742 # Connect scrollbar to GL widget
743 self.gl_widget.scrollbar = self.scrollbar
745 layout.addLayout(browser_layout)
747 self.setWidget(main_widget)
749 # Dock settings
750 self.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea)
751 self.setFeatures(
752 QDockWidget.DockWidgetFeature.DockWidgetClosable
753 | QDockWidget.DockWidgetFeature.DockWidgetMovable
754 | QDockWidget.DockWidgetFeature.DockWidgetFloatable
755 )
757 # Connect to main window's async loader when shown
758 self._connect_async_loader()
760 def _connect_async_loader(self):
761 """Connect to main window's async image loader."""
762 main_window = self.window()
763 if not hasattr(main_window, "_gl_widget"):
764 return
766 gl_widget = main_window._gl_widget
767 if not hasattr(gl_widget, "async_image_loader"):
768 return
770 # Avoid duplicate connections
771 if hasattr(self, "_async_connected") and self._async_connected:
772 return
774 try:
775 # Connect signals
776 gl_widget.async_image_loader.image_loaded.connect(self.gl_widget._on_image_loaded)
777 gl_widget.async_image_loader.load_failed.connect(self.gl_widget._on_image_load_failed)
778 self._async_connected = True
779 except Exception:
780 pass # Silently handle connection errors
782 def showEvent(self, event):
783 """Handle show event."""
784 super().showEvent(event)
785 # Ensure async loader is connected when shown
786 self._connect_async_loader()
788 def _select_folder(self):
789 """Open dialog to select folder."""
790 folder_path = QFileDialog.getExistingDirectory(
791 self,
792 "Select Image Folder",
793 str(self.gl_widget.current_folder) if self.gl_widget.current_folder else str(Path.home()),
794 QFileDialog.Option.ShowDirsOnly,
795 )
797 if folder_path:
798 self.load_folder(Path(folder_path))
800 def load_folder(self, folder_path: Path):
801 """Load thumbnails from folder."""
802 self.folder_label.setText(f"Folder: {folder_path.name}")
803 self.gl_widget.load_folder(folder_path)
804 # Apply current sort after loading
805 self._apply_sort()
806 self.gl_widget._arrange_thumbnails()
807 self.gl_widget.update_used_images()
808 self.gl_widget.update()
810 def _sort_by(self, sort_mode: str):
811 """Sort thumbnails by the specified mode."""
812 # Update button states (only one can be checked)
813 self.sort_name_btn.setChecked(sort_mode == "name")
814 self.sort_date_btn.setChecked(sort_mode == "date")
815 self.sort_camera_btn.setChecked(sort_mode == "camera")
817 self.current_sort = sort_mode
819 # Re-sort the image files in the GL widget
820 if hasattr(self.gl_widget, "image_files") and self.gl_widget.image_files:
821 self._apply_sort()
822 # Re-arrange thumbnails with new order
823 self.gl_widget._arrange_thumbnails()
824 self.gl_widget.update_used_images()
825 self.gl_widget.update()
827 def _apply_sort(self):
828 """Apply current sort mode to image files."""
829 if not hasattr(self.gl_widget, "image_files") or not self.gl_widget.image_files:
830 return
831 if self.current_sort == "name":
832 # Sort by filename only (not full path)
833 self.gl_widget.image_files.sort(key=lambda p: p.name.lower())
834 # Clear date headers for non-date sorts
835 self.gl_widget.date_headers.clear()
836 # Reset sort mode in GL widget
837 self.gl_widget.sort_mode = "name"
838 self.gl_widget._get_image_date_func = None
839 elif self.current_sort == "date":
840 # Sort by file modification time (or EXIF date if available)
841 self.gl_widget.image_files.sort(key=self._get_image_date)
842 # Date headers will be created during _arrange_thumbnails
843 self.gl_widget.sort_mode = "date"
844 self.gl_widget._get_image_date_func = self._get_image_date
845 elif self.current_sort == "camera":
846 # Sort by camera model from EXIF
847 self.gl_widget.image_files.sort(key=self._get_camera_model)
848 # Clear date headers for non-date sorts
849 self.gl_widget.date_headers.clear()
850 # Reset sort mode in GL widget
851 self.gl_widget.sort_mode = "camera"
852 self.gl_widget._get_image_date_func = None
854 def _get_image_date(self, image_path: Path) -> float:
855 """Get image date from EXIF or file modification time."""
856 try:
857 from PIL import Image
858 from PIL.ExifTags import TAGS
860 with Image.open(image_path) as img:
861 exif = img.getexif()
862 if exif:
863 # Look for DateTimeOriginal (when photo was taken)
864 for tag_id, value in exif.items():
865 tag = TAGS.get(tag_id, tag_id)
866 if tag == "DateTimeOriginal":
867 # Convert EXIF date format "2023:12:13 14:30:00" to timestamp
868 from datetime import datetime
870 try:
871 dt = datetime.strptime(value, "%Y:%m:%d %H:%M:%S")
872 return dt.timestamp()
873 except:
874 pass
875 except Exception:
876 pass
878 # Fallback to file modification time
879 return image_path.stat().st_mtime
881 def _get_camera_model(self, image_path: Path) -> str:
882 """Get camera model from EXIF metadata."""
883 try:
884 from PIL import Image
885 from PIL.ExifTags import TAGS
887 with Image.open(image_path) as img:
888 exif = img.getexif()
889 if exif:
890 # Look for camera model
891 for tag_id, value in exif.items():
892 tag = TAGS.get(tag_id, tag_id)
893 if tag == "Model":
894 return str(value).strip()
895 except Exception:
896 pass
898 # Fallback to filename if no EXIF data
899 return image_path.name.lower()