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

1""" 

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

3""" 

4 

5import os 

6from pathlib import Path 

7from typing import Optional, List, Tuple 

8 

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 

13 

14from pyPhotoAlbum.gl_imports import * 

15from pyPhotoAlbum.mixins.viewport import ViewportMixin 

16 

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

18 

19 

20class 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 

29class 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 

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

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 

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