1 """

2 Thumbnail Browser Widget - displays thumbnails from a folder for drag-and-drop into album.

3 """

4

5 import os

6 from pathlib import Path

7 from typing import Optional, List, Tuple

8

9 from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFileDialog, QDockWidget, QScrollBar

10 from PyQt6.QtCore import Qt, QSize, QMimeData, QUrl, QPoint

11 from PyQt6.QtGui import QDrag, QCursor, QPainter, QFont, QColor

12 from PyQt6.QtOpenGLWidgets import QOpenGLWidget

13

14 from pyPhotoAlbum.gl_imports import *

15 from pyPhotoAlbum.mixins.viewport import ViewportMixin

16

17 IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"]

18

19

20 class DateHeader:

21 """Represents a date separator header in the thumbnail list."""

22

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

27

28

29 class ThumbnailItem:

30 """Represents a thumbnail with position and path information."""

31

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

37

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

42

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

49

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)

53

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

57

58

59 class 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 """

64

65 def __init__(self, main_window=None):

66 super().__init__()

67

68 self.thumbnails: List[ThumbnailItem] = []

69 self.date_headers: List[DateHeader] = []

70 self.current_folder: Optional[Path] = None

71

72 # Store reference to main window

73 self._main_window = main_window

74

75 # Viewport state

76 self.zoom_level = 1.0

77 self.pan_offset = (0, 0)

78

79 # Dragging state

80 self.drag_start_pos = None

81 self.dragging_thumbnail = None

82

83 # Scrollbar (created but managed by parent)

84 self.scrollbar = None

85 self._updating_scrollbar = False # Flag to prevent circular updates

86

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

90

91 # Enable OpenGL

92 self.setMinimumSize(QSize(250, 300))

93

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

97

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

103

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)

110

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)

118

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

125

126 def paintGL(self):

127 """Render thumbnails."""

128 glClear(GL_COLOR_BUFFER_BIT)

129 glLoadIdentity()

130

131 if not self.thumbnails:

132 return

133

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)

137

138 # Render date headers first (so they appear behind thumbnails)

139 for header in self.date_headers:

140 self._render_date_header(header)

141

142 # Render each thumbnail (placeholders or textures)

143 for thumb in self.thumbnails:

144 self._render_thumbnail(thumb)

145

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)

150

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)

155

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

160

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

165

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)

171

172 painter.end()

173

174 def _render_thumbnail(self, thumb: ThumbnailItem):

175 """Render a single thumbnail using placeholder pattern."""

176 x, y, w, h = thumb.get_bounds()

177

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)

181

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

188

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

204

205 # Render actual texture

206 glEnable(GL_TEXTURE_2D)

207 glBindTexture(GL_TEXTURE_2D, thumb._texture_id)

208

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)

214

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

225

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

236

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

246

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

255

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

264

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

272

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

276

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

281

282 try:

283 pil_image = thumb._pending_pil_image

284

285 # Ensure RGBA

286 if pil_image.mode != "RGBA":

287 pil_image = pil_image.convert("RGBA")

288

289 # Delete old texture

290 if thumb._texture_id:

291 glDeleteTextures([thumb._texture_id])

292

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 )

302

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

307

308 return True

309

310 except Exception as e:

311 print(f"Error creating texture for thumbnail: {e}")

312 thumb._pending_pil_image = None

313 return False

314

315 def load_folder(self, folder_path: Path):

316 """Load thumbnails from a folder."""

317 self.current_folder = folder_path

318

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

324

325 self.image_files.sort()

326

327 # Arrange thumbnails based on current widget size and zoom

328 self._arrange_thumbnails()

329

330 # Update which images are already in use

331 self.update_used_images()

332

333 self.update()

334

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

340

341 # Calculate number of columns that fit

342 widget_width = self.width()

343 if widget_width <= 0:

344 widget_width = 250 # Default minimum width

345

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

349

350 # Calculate columns

351 columns = max(1, int((widget_width - spacing_screen) / (thumb_size_screen + spacing_screen)))

352

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)

358

359 # Build a map of existing thumbnails by path to reuse them

360 existing_thumbs = {thumb.image_path: thumb for thumb in self.thumbnails}

361

362 # Clear lists but reuse thumbnail objects

363 self.thumbnails.clear()

364 self.date_headers.clear()

365

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

371

372 for idx, image_file in enumerate(self.image_files):

373 image_path = str(image_file)

374

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

378

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"

382

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

393

394 # Add header at section start

395 header = DateHeader(date_str, section_start_y)

396 self.date_headers.append(header)

397

398 # Update section_start_y to after the header

399 section_start_y += header.height + spacing

400

401 current_date_str = date_str

402 row_in_section = 0

403 col = 0

404

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

414

415 # Calculate X position (always centered)

416 thumb_x = h_offset + col * (100.0 + spacing) + spacing

417

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)

432

433 self.thumbnails.append(thumb)

434

435 # Update column and row counters

436 col += 1

437 if col >= columns:

438 col = 0

439 row_in_section += 1

440

441 # Update scrollbar range after arranging

442 self._update_scrollbar_range()

443

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

448

449 if not self.thumbnails:

450 self.scrollbar.setRange(0, 0)

451 self.scrollbar.setPageStep(self.height())

452 return

453

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

461

462 # Visible height

463 visible_height = self.height()

464

465 # Scrollable range

466 scroll_range = max(0, int(content_height - visible_height))

467

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

472

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

477

478 def _on_scrollbar_changed(self, value):

479 """Handle scrollbar value change."""

480 if self._updating_scrollbar:

481 return

482

483 # Update pan offset based on scrollbar value

484 self.pan_offset = (0, -value)

485 self.update()

486

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

491

492 self._updating_scrollbar = True

493 scroll_pos = int(-self.pan_offset[1])

494 self.scrollbar.setValue(scroll_pos)

495 self._updating_scrollbar = False

496

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

503

504 project = main_window.project

505

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

510

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)

517

518 # Mark thumbnails as used

519 for thumb in self.thumbnails:

520 thumb.is_used_in_project = thumb.image_path in used_paths

521

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

527

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

532

533 gl_widget = main_window._gl_widget

534 if not hasattr(gl_widget, "async_image_loader"):

535 return

536

537 from pyPhotoAlbum.async_backend import LoadPriority

538

539 try:

540 # Mark as loading to prevent duplicate requests

541 thumb._async_loading = True

542

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

553

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

562

563 # Schedule a repaint (will be batched if many images load quickly)

564 self.update()

565

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

569

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

575

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)

579

580 for thumb in self.thumbnails:

581 if thumb.contains_point(vp_x, vp_y):

582 return thumb

583

584 return None

585

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

591

592 def mouseMoveEvent(self, event):

593 """Handle mouse move for drag or pan."""

594 if not (event.buttons() & Qt.MouseButton.LeftButton):

595 return

596

597 if self.drag_start_pos is None:

598 return

599

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

605

606 # Set file URL for the drag

607 url = QUrl.fromLocalFile(self.dragging_thumbnail.image_path)

608 mime_data.setUrls([url])

609

610 drag.setMimeData(mime_data)

611

612 # Execute drag (this blocks until drop or cancel)

613 drag.exec(Qt.DropAction.CopyAction)

614

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

626

627 def mouseReleaseEvent(self, event):

628 """Handle mouse release."""

629 self.drag_start_pos = None

630 self.dragging_thumbnail = None

631

632 def wheelEvent(self, event):

633 """Handle mouse wheel for scrolling (or zooming with Ctrl)."""

634 delta = event.angleDelta().y()

635

636 # Check if Ctrl is pressed for zooming

637 if event.modifiers() & Qt.KeyboardModifier.ControlModifier:

638 # Zoom mode

639 mouse_y = event.position().y()

640

641 zoom_factor = 1.1 if delta > 0 else 0.9

642

643 # Calculate vertical world position before zoom

644 world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level

645

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

650

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

655

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

666

667 self._update_scrollbar_position()

668 self.update()

669

670

671 class ThumbnailBrowserDock(QDockWidget):

672 """

673 Dockable widget containing the thumbnail browser.

674 """

675

676 def __init__(self, parent=None):

677 super().__init__("Image Browser", parent)

678

679 # Create main widget

680 main_widget = QWidget()

681 layout = QVBoxLayout(main_widget)

682 layout.setContentsMargins(5, 5, 5, 5)

683 layout.setSpacing(5)

684

685 # Header with folder selection

686 header_layout = QHBoxLayout()

687

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)

691

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)

695

696 layout.addLayout(header_layout)

697

698 # Sort toolbar

699 sort_layout = QHBoxLayout()

700 sort_layout.setContentsMargins(5, 0, 5, 5)

701

702 sort_label = QLabel("Sort by:")

703 sort_layout.addWidget(sort_label)

704

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)

710

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)

715

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)

720

721 sort_layout.addStretch()

722

723 layout.addLayout(sort_layout)

724

725 # Track current sort mode

726 self.current_sort = "name"

727

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)

732

733 # GL Widget for thumbnails

734 self.gl_widget = ThumbnailGLWidget(main_window=parent)

735 browser_layout.addWidget(self.gl_widget)

736

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)

741

742 # Connect scrollbar to GL widget

743 self.gl_widget.scrollbar = self.scrollbar

744

745 layout.addLayout(browser_layout)

746

747 self.setWidget(main_widget)

748

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 )

756

757 # Connect to main window's async loader when shown

758 self._connect_async_loader()

759

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

765

766 gl_widget = main_window._gl_widget

767 if not hasattr(gl_widget, "async_image_loader"):

768 return

769

770 # Avoid duplicate connections

771 if hasattr(self, "_async_connected") and self._async_connected:

772 return

773

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

781

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

787

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 )

796

797 if folder_path:

798 self.load_folder(Path(folder_path))

799

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

809

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

816

817 self.current_sort = sort_mode

818

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

826

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

853

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

859

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

869

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

877

878 # Fallback to file modification time

879 return image_path.stat().st_mtime

880

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

886

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

897

898 # Fallback to filename if no EXIF data

899 return image_path.name.lower()