lazylabel-gui 1.3.1__py3-none-any.whl → 1.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,831 @@
1
+ """
2
+ Fast file manager with lazy loading, sorting, and efficient navigation
3
+ """
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from PyQt6.QtCore import (
11
+ QAbstractTableModel,
12
+ QModelIndex,
13
+ QSortFilterProxyModel,
14
+ Qt,
15
+ QThread,
16
+ pyqtSignal,
17
+ )
18
+ from PyQt6.QtGui import QPixmap
19
+ from PyQt6.QtWidgets import (
20
+ QComboBox,
21
+ QHBoxLayout,
22
+ QHeaderView,
23
+ QLabel,
24
+ QLineEdit,
25
+ QPushButton,
26
+ QTableView,
27
+ QVBoxLayout,
28
+ QWidget,
29
+ )
30
+
31
+ # Image extensions supported
32
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".tiff", ".tif"}
33
+
34
+
35
+ @dataclass
36
+ class FileInfo:
37
+ """Information about a file"""
38
+
39
+ path: Path
40
+ name: str
41
+ size: int = 0 # Lazy load for speed
42
+ modified: float = 0.0 # Lazy load for speed
43
+ has_npz: bool = False
44
+ has_txt: bool = False
45
+ thumbnail: QPixmap | None = None
46
+
47
+
48
+ class FileScanner(QThread):
49
+ """Background thread for scanning files"""
50
+
51
+ filesFound = pyqtSignal(list) # Emits batches of FileInfo
52
+ scanComplete = pyqtSignal(int) # Total file count
53
+ progress = pyqtSignal(int, int) # Current, total
54
+
55
+ def __init__(self, directory: Path):
56
+ super().__init__()
57
+ self.directory = directory
58
+ self._stop_flag = False
59
+
60
+ def run(self):
61
+ """Scan directory in background - OPTIMIZED FOR SPEED"""
62
+ batch_size = 1000 # Larger batches = fewer UI updates = faster
63
+ batch = []
64
+ total_files = 0
65
+
66
+ try:
67
+ # Use os.scandir() - MUCH faster than Path.iterdir()
68
+ # Single pass to collect all file info
69
+ npz_stems = set()
70
+ txt_stems = set()
71
+ image_entries = []
72
+
73
+ with os.scandir(self.directory) as entries:
74
+ for entry in entries:
75
+ if self._stop_flag:
76
+ break
77
+
78
+ # Check file extension
79
+ name = entry.name
80
+ ext = os.path.splitext(name)[1].lower()
81
+
82
+ if ext == ".npz":
83
+ npz_stems.add(os.path.splitext(name)[0])
84
+ elif ext == ".txt":
85
+ txt_stems.add(os.path.splitext(name)[0])
86
+ elif ext in IMAGE_EXTENSIONS:
87
+ image_entries.append((entry.path, name))
88
+
89
+ # Process images in batches
90
+ total_count = len(image_entries)
91
+
92
+ for i, (path, name) in enumerate(image_entries):
93
+ if self._stop_flag:
94
+ break
95
+
96
+ stem = os.path.splitext(name)[0]
97
+
98
+ # Create FileInfo - NO STAT CALLS for speed!
99
+ file_info = FileInfo(
100
+ path=Path(path),
101
+ name=name,
102
+ has_npz=stem in npz_stems,
103
+ has_txt=stem in txt_stems,
104
+ )
105
+
106
+ batch.append(file_info)
107
+ total_files += 1
108
+
109
+ if len(batch) >= batch_size:
110
+ self.filesFound.emit(batch)
111
+ batch = []
112
+
113
+ # Progress updates less frequently
114
+ if i % 1000 == 0 and i > 0:
115
+ self.progress.emit(i, total_count)
116
+
117
+ # Emit remaining files
118
+ if batch:
119
+ self.filesFound.emit(batch)
120
+
121
+ self.scanComplete.emit(total_files)
122
+
123
+ except Exception as e:
124
+ print(f"Error scanning directory: {e}")
125
+
126
+ def stop(self):
127
+ """Stop the scanning thread"""
128
+ self._stop_flag = True
129
+
130
+
131
+ class FastFileModel(QAbstractTableModel):
132
+ """High-performance file model with background loading"""
133
+
134
+ fileSelected = pyqtSignal(Path)
135
+
136
+ def __init__(self):
137
+ super().__init__()
138
+ self._files: list[FileInfo] = []
139
+ self._path_to_index: dict[str, int] = {} # For O(1) lookups
140
+ self._scanner: FileScanner | None = None
141
+
142
+ def rowCount(self, parent=QModelIndex()):
143
+ return len(self._files)
144
+
145
+ def columnCount(self, parent=QModelIndex()):
146
+ return 5 # Name, Size, Modified, NPZ, TXT
147
+
148
+ def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole):
149
+ if not index.isValid():
150
+ return None
151
+
152
+ file_info = self._files[index.row()]
153
+ col = index.column()
154
+
155
+ if role == Qt.ItemDataRole.DisplayRole:
156
+ if col == 0: # Name
157
+ return file_info.name
158
+ elif col == 1: # Size
159
+ # Lazy load size only when displayed
160
+ if file_info.size == 0:
161
+ try:
162
+ file_info.size = file_info.path.stat().st_size
163
+ except OSError:
164
+ file_info.size = -1 # Mark as error
165
+ return self._format_size(file_info.size) if file_info.size >= 0 else "-"
166
+ elif col == 2: # Modified
167
+ # Lazy load modified time only when displayed
168
+ if file_info.modified == 0.0:
169
+ try:
170
+ file_info.modified = file_info.path.stat().st_mtime
171
+ except OSError:
172
+ file_info.modified = -1 # Mark as error
173
+ return (
174
+ datetime.fromtimestamp(file_info.modified).strftime(
175
+ "%Y-%m-%d %H:%M"
176
+ )
177
+ if file_info.modified > 0
178
+ else "-"
179
+ )
180
+ elif col == 3: # NPZ
181
+ return "✓" if file_info.has_npz else ""
182
+ elif col == 4: # TXT
183
+ return "✓" if file_info.has_txt else ""
184
+ elif role == Qt.ItemDataRole.UserRole:
185
+ # Return the FileInfo object for custom access
186
+ return file_info
187
+ elif role == Qt.ItemDataRole.TextAlignmentRole and col in [
188
+ 3,
189
+ 4,
190
+ ]: # Center checkmarks
191
+ return Qt.AlignmentFlag.AlignCenter
192
+
193
+ return None
194
+
195
+ def headerData(self, section, orientation, role):
196
+ if (
197
+ orientation == Qt.Orientation.Horizontal
198
+ and role == Qt.ItemDataRole.DisplayRole
199
+ ):
200
+ headers = ["Name", "Size", "Modified", "NPZ", "TXT"]
201
+ return headers[section]
202
+ return None
203
+
204
+ def _format_size(self, size: int) -> str:
205
+ """Format file size in human readable format"""
206
+ for unit in ["B", "KB", "MB", "GB"]:
207
+ if size < 1024:
208
+ return f"{size:.1f} {unit}"
209
+ size /= 1024
210
+ return f"{size:.1f} TB"
211
+
212
+ def setDirectory(self, directory: Path):
213
+ """Set directory to scan"""
214
+ # Stop previous scanner if running
215
+ if self._scanner and self._scanner.isRunning():
216
+ self._scanner.stop()
217
+ self._scanner.wait()
218
+
219
+ # Clear current files
220
+ self.beginResetModel()
221
+ self._files.clear()
222
+ self._path_to_index.clear()
223
+ self.endResetModel()
224
+
225
+ # Start new scan
226
+ self._scanner = FileScanner(directory)
227
+ self._scanner.filesFound.connect(self._on_files_found)
228
+ self._scanner.scanComplete.connect(self._on_scan_complete)
229
+ self._scanner.start()
230
+
231
+ def _on_files_found(self, files: list[FileInfo]):
232
+ """Handle batch of files found"""
233
+ start_row = len(self._files)
234
+ end_row = start_row + len(files) - 1
235
+ self.beginInsertRows(QModelIndex(), start_row, end_row)
236
+
237
+ # Add files and update path-to-index mapping
238
+ for i, file_info in enumerate(files):
239
+ idx = start_row + i
240
+ self._files.append(file_info)
241
+ self._path_to_index[str(file_info.path)] = idx
242
+
243
+ self.endInsertRows()
244
+
245
+ def _on_scan_complete(self, total: int):
246
+ """Handle scan completion"""
247
+ print(f"Scan complete: {total} files found")
248
+
249
+ def getFileInfo(self, index: int) -> FileInfo | None:
250
+ """Get file info at index"""
251
+ if 0 <= index < len(self._files):
252
+ return self._files[index]
253
+ return None
254
+
255
+ def updateNpzStatus(self, image_path: Path):
256
+ """Update NPZ status for a specific image file"""
257
+ image_path_str = str(image_path)
258
+ npz_path = image_path.with_suffix(".npz")
259
+ has_npz = npz_path.exists()
260
+
261
+ # Find and update the file info
262
+ for i, file_info in enumerate(self._files):
263
+ if str(file_info.path) == image_path_str:
264
+ old_has_npz = file_info.has_npz
265
+ file_info.has_npz = has_npz
266
+
267
+ # Only emit dataChanged if status actually changed
268
+ if old_has_npz != has_npz:
269
+ index = self.index(i, 3) # NPZ column
270
+ self.dataChanged.emit(index, index)
271
+ break
272
+
273
+ def updateFileStatus(self, image_path: Path):
274
+ """Update both NPZ and TXT status for a specific image file"""
275
+ image_path_str = str(image_path)
276
+ npz_path = image_path.with_suffix(".npz")
277
+ txt_path = image_path.with_suffix(".txt")
278
+ has_npz = npz_path.exists()
279
+ has_txt = txt_path.exists()
280
+
281
+ # O(1) lookup using path-to-index mapping
282
+ if image_path_str not in self._path_to_index:
283
+ return # File not in current view
284
+
285
+ i = self._path_to_index[image_path_str]
286
+ file_info = self._files[i]
287
+
288
+ # Update status and emit changes only if needed
289
+ old_has_npz = file_info.has_npz
290
+ old_has_txt = file_info.has_txt
291
+ file_info.has_npz = has_npz
292
+ file_info.has_txt = has_txt
293
+
294
+ # Emit dataChanged for NPZ column if status changed
295
+ if old_has_npz != has_npz:
296
+ index = self.index(i, 3) # NPZ column
297
+ self.dataChanged.emit(index, index)
298
+
299
+ # Emit dataChanged for TXT column if status changed
300
+ if old_has_txt != has_txt:
301
+ index = self.index(i, 4) # TXT column
302
+ self.dataChanged.emit(index, index)
303
+
304
+ def getFileIndex(self, path: Path) -> int:
305
+ """Get index of file by path"""
306
+ return self._path_to_index.get(str(path), -1)
307
+
308
+ def batchUpdateFileStatus(self, image_paths: list[Path]):
309
+ """Batch update file status for multiple files"""
310
+ if not image_paths:
311
+ return
312
+
313
+ changed_indices = []
314
+
315
+ for image_path in image_paths:
316
+ image_path_str = str(image_path)
317
+
318
+ # O(1) lookup using path-to-index mapping
319
+ if image_path_str not in self._path_to_index:
320
+ continue # File not in current view
321
+
322
+ i = self._path_to_index[image_path_str]
323
+ file_info = self._files[i]
324
+
325
+ # Check file existence
326
+ npz_path = image_path.with_suffix(".npz")
327
+ txt_path = image_path.with_suffix(".txt")
328
+ has_npz = npz_path.exists()
329
+ has_txt = txt_path.exists()
330
+
331
+ # Update status and track changes
332
+ old_has_npz = file_info.has_npz
333
+ old_has_txt = file_info.has_txt
334
+ file_info.has_npz = has_npz
335
+ file_info.has_txt = has_txt
336
+
337
+ # Track changed indices for batch emission
338
+ if old_has_npz != has_npz:
339
+ changed_indices.append((i, 3)) # NPZ column
340
+ if old_has_txt != has_txt:
341
+ changed_indices.append((i, 4)) # TXT column
342
+
343
+ # Batch emit dataChanged signals
344
+ for i, col in changed_indices:
345
+ index = self.index(i, col)
346
+ self.dataChanged.emit(index, index)
347
+
348
+
349
+ class FileSortProxyModel(QSortFilterProxyModel):
350
+ """Custom proxy model for proper sorting of file data."""
351
+
352
+ def lessThan(self, left: QModelIndex, right: QModelIndex) -> bool:
353
+ """Custom sorting comparison."""
354
+ # Get the file info objects
355
+ left_info = self.sourceModel().getFileInfo(left.row())
356
+ right_info = self.sourceModel().getFileInfo(right.row())
357
+
358
+ if not left_info or not right_info:
359
+ return False
360
+
361
+ col = left.column()
362
+
363
+ # Sort based on column
364
+ if col == 0: # Name
365
+ return left_info.name.lower() < right_info.name.lower()
366
+ elif col == 1: # Size
367
+ # Lazy load size if needed for sorting
368
+ if left_info.size == 0:
369
+ try:
370
+ left_info.size = left_info.path.stat().st_size
371
+ except OSError:
372
+ left_info.size = -1
373
+ if right_info.size == 0:
374
+ try:
375
+ right_info.size = right_info.path.stat().st_size
376
+ except OSError:
377
+ right_info.size = -1
378
+ return left_info.size < right_info.size
379
+ elif col == 2: # Modified
380
+ # Lazy load modified time if needed for sorting
381
+ if left_info.modified == 0.0:
382
+ try:
383
+ left_info.modified = left_info.path.stat().st_mtime
384
+ except OSError:
385
+ left_info.modified = -1
386
+ if right_info.modified == 0.0:
387
+ try:
388
+ right_info.modified = right_info.path.stat().st_mtime
389
+ except OSError:
390
+ right_info.modified = -1
391
+ return left_info.modified < right_info.modified
392
+ elif col == 3: # NPZ
393
+ return left_info.has_npz < right_info.has_npz
394
+ elif col == 4: # TXT
395
+ return left_info.has_txt < right_info.has_txt
396
+
397
+ return False
398
+
399
+
400
+ class FastFileManager(QWidget):
401
+ """Main file manager widget with improved performance"""
402
+
403
+ fileSelected = pyqtSignal(Path)
404
+
405
+ def __init__(self):
406
+ super().__init__()
407
+ self._current_directory = None
408
+ self._init_ui()
409
+
410
+ def _init_ui(self):
411
+ """Initialize the UI"""
412
+ layout = QVBoxLayout(self)
413
+ layout.setContentsMargins(0, 0, 0, 0)
414
+
415
+ # Header with controls
416
+ header = self._create_header()
417
+ layout.addWidget(header)
418
+
419
+ # File table view
420
+ self._table_view = QTableView()
421
+ self._table_view.setAlternatingRowColors(True)
422
+ self._table_view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
423
+ self._table_view.setSelectionMode(QTableView.SelectionMode.SingleSelection)
424
+ self._table_view.setSortingEnabled(False) # We'll handle sorting manually
425
+ self._table_view.setEditTriggers(QTableView.EditTrigger.NoEditTriggers)
426
+
427
+ # Set up model and proxy
428
+ self._model = FastFileModel()
429
+ self._model.fileSelected.connect(self.fileSelected)
430
+
431
+ # Set up custom sorting proxy
432
+ self._proxy_model = FileSortProxyModel()
433
+ self._proxy_model.setSourceModel(self._model)
434
+ self._table_view.setModel(self._proxy_model)
435
+
436
+ # Enable sorting
437
+ self._table_view.setSortingEnabled(True)
438
+ self._table_view.sortByColumn(0, Qt.SortOrder.AscendingOrder)
439
+
440
+ # Configure headers
441
+ header = self._table_view.horizontalHeader()
442
+ header.setSectionResizeMode(
443
+ 0, QHeaderView.ResizeMode.Stretch
444
+ ) # Name column stretches
445
+ header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # Size
446
+ header.setSectionResizeMode(
447
+ 2, QHeaderView.ResizeMode.ResizeToContents
448
+ ) # Modified
449
+ header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) # NPZ
450
+ header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) # TXT
451
+ header.resizeSection(3, 50)
452
+ header.resizeSection(4, 50)
453
+
454
+ # Style the table to match the existing UI
455
+ self._table_view.setStyleSheet("""
456
+ QTableView {
457
+ background-color: transparent;
458
+ alternate-background-color: rgba(255, 255, 255, 0.03);
459
+ gridline-color: rgba(255, 255, 255, 0.1);
460
+ color: #E0E0E0;
461
+ }
462
+ QTableView::item {
463
+ padding: 2px;
464
+ color: #E0E0E0;
465
+ }
466
+ QTableView::item:selected {
467
+ background-color: rgba(100, 100, 200, 0.5);
468
+ }
469
+ QHeaderView::section {
470
+ background-color: rgba(60, 60, 60, 0.5);
471
+ color: #E0E0E0;
472
+ padding: 4px;
473
+ border: 1px solid rgba(80, 80, 80, 0.4);
474
+ font-weight: bold;
475
+ }
476
+ """)
477
+
478
+ # Connect selection
479
+ self._table_view.clicked.connect(self._on_item_clicked)
480
+ self._table_view.doubleClicked.connect(self._on_item_double_clicked)
481
+
482
+ layout.addWidget(self._table_view)
483
+
484
+ # Status bar
485
+ self._status_label = QLabel("No folder selected")
486
+ self._status_label.setStyleSheet(
487
+ "padding: 5px; background: rgba(60, 60, 60, 0.3); color: #E0E0E0;"
488
+ )
489
+ layout.addWidget(self._status_label)
490
+
491
+ def _create_header(self) -> QWidget:
492
+ """Create header with controls"""
493
+ header = QWidget()
494
+ header.setStyleSheet("background: rgba(60, 60, 60, 0.3); padding: 5px;")
495
+ layout = QHBoxLayout(header)
496
+ layout.setContentsMargins(5, 5, 5, 5)
497
+
498
+ # Search box
499
+ self._search_box = QLineEdit()
500
+ self._search_box.setPlaceholderText("Search files...")
501
+ self._search_box.textChanged.connect(self._on_search_changed)
502
+ self._search_box.setStyleSheet("""
503
+ QLineEdit {
504
+ background-color: rgba(50, 50, 50, 0.5);
505
+ border: 1px solid rgba(80, 80, 80, 0.4);
506
+ color: #E0E0E0;
507
+ padding: 4px;
508
+ border-radius: 3px;
509
+ }
510
+ """)
511
+ layout.addWidget(self._search_box)
512
+
513
+ # Sort dropdown
514
+ sort_label = QLabel("Sort:")
515
+ sort_label.setStyleSheet("color: #E0E0E0;")
516
+ layout.addWidget(sort_label)
517
+
518
+ self._sort_combo = QComboBox()
519
+ self._sort_combo.addItems(
520
+ [
521
+ "Name (A-Z)",
522
+ "Name (Z-A)",
523
+ "Date (Oldest)",
524
+ "Date (Newest)",
525
+ "Size (Smallest)",
526
+ "Size (Largest)",
527
+ ]
528
+ )
529
+ self._sort_combo.currentIndexChanged.connect(self._on_sort_changed)
530
+ self._sort_combo.setStyleSheet("""
531
+ QComboBox {
532
+ background-color: rgba(50, 50, 50, 0.5);
533
+ border: 1px solid rgba(80, 80, 80, 0.4);
534
+ color: #E0E0E0;
535
+ padding: 4px;
536
+ border-radius: 3px;
537
+ }
538
+ QComboBox::drop-down {
539
+ border: none;
540
+ }
541
+ QComboBox::down-arrow {
542
+ width: 12px;
543
+ height: 12px;
544
+ }
545
+ QComboBox QAbstractItemView {
546
+ background-color: rgba(50, 50, 50, 0.9);
547
+ border: 1px solid rgba(80, 80, 80, 0.4);
548
+ color: #E0E0E0;
549
+ selection-background-color: rgba(100, 100, 200, 0.5);
550
+ }
551
+ """)
552
+ layout.addWidget(self._sort_combo)
553
+
554
+ # Refresh button
555
+ refresh_btn = QPushButton("Refresh")
556
+ refresh_btn.clicked.connect(self._refresh)
557
+ refresh_btn.setStyleSheet("""
558
+ QPushButton {
559
+ background-color: rgba(70, 70, 70, 0.6);
560
+ border: 1px solid rgba(80, 80, 80, 0.4);
561
+ color: #E0E0E0;
562
+ padding: 4px 8px;
563
+ border-radius: 3px;
564
+ }
565
+ QPushButton:hover {
566
+ background-color: rgba(90, 90, 90, 0.8);
567
+ }
568
+ QPushButton:pressed {
569
+ background-color: rgba(50, 50, 50, 0.8);
570
+ }
571
+ """)
572
+ layout.addWidget(refresh_btn)
573
+
574
+ layout.addStretch()
575
+
576
+ return header
577
+
578
+ def setDirectory(self, directory: Path):
579
+ """Set the directory to display"""
580
+ self._current_directory = directory
581
+ self._model.setDirectory(directory)
582
+ self._update_status(f"Loading: {directory.name}")
583
+
584
+ # Connect to scan complete signal
585
+ if self._model._scanner:
586
+ self._model._scanner.scanComplete.connect(
587
+ lambda count: self._update_status(f"{count} files in {directory.name}")
588
+ )
589
+
590
+ def _on_search_changed(self, text: str):
591
+ """Handle search text change"""
592
+ self._proxy_model.setFilterFixedString(text)
593
+ self._proxy_model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
594
+ self._proxy_model.setFilterKeyColumn(0) # Filter on name column
595
+
596
+ def _on_sort_changed(self, index: int):
597
+ """Handle sort order change"""
598
+ # Map combo index to column and order
599
+ column_map = {
600
+ 0: 0,
601
+ 1: 0,
602
+ 2: 2,
603
+ 3: 2,
604
+ 4: 1,
605
+ 5: 1,
606
+ } # Name, Name, Date, Date, Size, Size
607
+ order_map = {
608
+ 0: Qt.SortOrder.AscendingOrder,
609
+ 1: Qt.SortOrder.DescendingOrder,
610
+ 2: Qt.SortOrder.AscendingOrder,
611
+ 3: Qt.SortOrder.DescendingOrder,
612
+ 4: Qt.SortOrder.AscendingOrder,
613
+ 5: Qt.SortOrder.DescendingOrder,
614
+ }
615
+
616
+ column = column_map.get(index, 0)
617
+ order = order_map.get(index, Qt.SortOrder.AscendingOrder)
618
+
619
+ self._table_view.sortByColumn(column, order)
620
+
621
+ def _refresh(self):
622
+ """Refresh current directory"""
623
+ if self._current_directory:
624
+ self.setDirectory(self._current_directory)
625
+
626
+ def updateNpzStatus(self, image_path: Path):
627
+ """Update NPZ status for a specific image file"""
628
+ self._model.updateNpzStatus(image_path)
629
+
630
+ def updateFileStatus(self, image_path: Path):
631
+ """Update both NPZ and TXT status for a specific image file"""
632
+ self._model.updateFileStatus(image_path)
633
+
634
+ def refreshFile(self, image_path: Path):
635
+ """Refresh status for a specific file"""
636
+ self.updateFileStatus(image_path)
637
+
638
+ def batchUpdateFileStatus(self, image_paths: list[Path]):
639
+ """Batch update file status for multiple files"""
640
+ self._model.batchUpdateFileStatus(image_paths)
641
+
642
+ def getSurroundingFiles(self, current_path: Path, count: int) -> list[Path]:
643
+ """Get files in current sorted/filtered order surrounding the given path"""
644
+ files = []
645
+
646
+ # Find current file in proxy model order
647
+ current_index = -1
648
+ for row in range(self._proxy_model.rowCount()):
649
+ proxy_index = self._proxy_model.index(row, 0)
650
+ source_index = self._proxy_model.mapToSource(proxy_index)
651
+ file_info = self._model.getFileInfo(source_index.row())
652
+ if file_info and file_info.path == current_path:
653
+ current_index = row
654
+ break
655
+
656
+ if current_index == -1:
657
+ return [] # File not found in current view
658
+
659
+ # Get surrounding files in proxy order
660
+ for i in range(count):
661
+ row = current_index + i
662
+ if row < self._proxy_model.rowCount():
663
+ proxy_index = self._proxy_model.index(row, 0)
664
+ source_index = self._proxy_model.mapToSource(proxy_index)
665
+ file_info = self._model.getFileInfo(source_index.row())
666
+ if file_info:
667
+ files.append(file_info.path)
668
+ else:
669
+ files.append(None)
670
+ else:
671
+ files.append(None)
672
+
673
+ return files
674
+
675
+ def getPreviousFiles(self, current_path: Path, count: int) -> list[Path]:
676
+ """Get previous files in current sorted/filtered order before the given path"""
677
+ files = []
678
+
679
+ # Find current file in proxy model order
680
+ current_index = -1
681
+ for row in range(self._proxy_model.rowCount()):
682
+ proxy_index = self._proxy_model.index(row, 0)
683
+ source_index = self._proxy_model.mapToSource(proxy_index)
684
+ file_info = self._model.getFileInfo(source_index.row())
685
+ if file_info and file_info.path == current_path:
686
+ current_index = row
687
+ break
688
+
689
+ if current_index == -1:
690
+ return [] # File not found in current view
691
+
692
+ # Get previous files going backward from current position
693
+ start_row = current_index - count
694
+ if start_row < 0:
695
+ start_row = 0
696
+
697
+ # Get consecutive files starting from start_row
698
+ for i in range(count):
699
+ row = start_row + i
700
+ if row < current_index and row >= 0:
701
+ proxy_index = self._proxy_model.index(row, 0)
702
+ source_index = self._proxy_model.mapToSource(proxy_index)
703
+ file_info = self._model.getFileInfo(source_index.row())
704
+ if file_info:
705
+ files.append(file_info.path)
706
+ else:
707
+ files.append(None)
708
+ else:
709
+ files.append(None)
710
+
711
+ return files
712
+
713
+ def _on_item_clicked(self, index: QModelIndex):
714
+ """Handle item click"""
715
+ # Map proxy index to source index
716
+ source_index = self._proxy_model.mapToSource(index)
717
+ file_info = self._model.getFileInfo(source_index.row())
718
+ if file_info:
719
+ self.fileSelected.emit(file_info.path)
720
+
721
+ def _on_item_double_clicked(self, index: QModelIndex):
722
+ """Handle item double click"""
723
+ # Map proxy index to source index
724
+ source_index = self._proxy_model.mapToSource(index)
725
+ file_info = self._model.getFileInfo(source_index.row())
726
+ if file_info:
727
+ self.fileSelected.emit(file_info.path)
728
+
729
+ def _update_status(self, text: str):
730
+ """Update status label"""
731
+ self._status_label.setText(text)
732
+
733
+ def selectFile(self, path: Path):
734
+ """Select a specific file in the view"""
735
+ index = self._model.getFileIndex(path)
736
+ if index >= 0:
737
+ source_index = self._model.index(index, 0)
738
+ proxy_index = self._proxy_model.mapFromSource(source_index)
739
+ self._table_view.setCurrentIndex(proxy_index)
740
+ self._table_view.scrollTo(proxy_index)
741
+ # Select the entire row
742
+ selection_model = self._table_view.selectionModel()
743
+ selection_model.select(
744
+ proxy_index,
745
+ selection_model.SelectionFlag.ClearAndSelect
746
+ | selection_model.SelectionFlag.Rows,
747
+ )
748
+
749
+ def getSelectedFile(self) -> Path | None:
750
+ """Get currently selected file"""
751
+ index = self._table_view.currentIndex()
752
+ if index.isValid():
753
+ source_index = self._proxy_model.mapToSource(index)
754
+ file_info = self._model.getFileInfo(source_index.row())
755
+ if file_info:
756
+ return file_info.path
757
+ return None
758
+
759
+ def navigateNext(self):
760
+ """Navigate to next file"""
761
+ current = self._table_view.currentIndex()
762
+
763
+ # If no current selection and we have files, select first
764
+ if not current.isValid() and self._proxy_model.rowCount() > 0:
765
+ first_index = self._proxy_model.index(0, 0)
766
+ self._table_view.setCurrentIndex(first_index)
767
+ selection_model = self._table_view.selectionModel()
768
+ selection_model.select(
769
+ first_index,
770
+ selection_model.SelectionFlag.ClearAndSelect
771
+ | selection_model.SelectionFlag.Rows,
772
+ )
773
+ # Emit file selection
774
+ source_index = self._proxy_model.mapToSource(first_index)
775
+ file_info = self._model.getFileInfo(source_index.row())
776
+ if file_info:
777
+ self.fileSelected.emit(file_info.path)
778
+ return
779
+
780
+ if current.isValid() and current.row() < self._proxy_model.rowCount() - 1:
781
+ next_index = self._proxy_model.index(current.row() + 1, 0)
782
+ self._table_view.setCurrentIndex(next_index)
783
+ # Select entire row
784
+ selection_model = self._table_view.selectionModel()
785
+ selection_model.select(
786
+ next_index,
787
+ selection_model.SelectionFlag.ClearAndSelect
788
+ | selection_model.SelectionFlag.Rows,
789
+ )
790
+ # Emit file selection
791
+ source_index = self._proxy_model.mapToSource(next_index)
792
+ file_info = self._model.getFileInfo(source_index.row())
793
+ if file_info:
794
+ self.fileSelected.emit(file_info.path)
795
+
796
+ def navigatePrevious(self):
797
+ """Navigate to previous file"""
798
+ current = self._table_view.currentIndex()
799
+
800
+ # If no current selection and we have files, select last
801
+ if not current.isValid() and self._proxy_model.rowCount() > 0:
802
+ last_index = self._proxy_model.index(self._proxy_model.rowCount() - 1, 0)
803
+ self._table_view.setCurrentIndex(last_index)
804
+ selection_model = self._table_view.selectionModel()
805
+ selection_model.select(
806
+ last_index,
807
+ selection_model.SelectionFlag.ClearAndSelect
808
+ | selection_model.SelectionFlag.Rows,
809
+ )
810
+ # Emit file selection
811
+ source_index = self._proxy_model.mapToSource(last_index)
812
+ file_info = self._model.getFileInfo(source_index.row())
813
+ if file_info:
814
+ self.fileSelected.emit(file_info.path)
815
+ return
816
+
817
+ if current.isValid() and current.row() > 0:
818
+ prev_index = self._proxy_model.index(current.row() - 1, 0)
819
+ self._table_view.setCurrentIndex(prev_index)
820
+ # Select entire row
821
+ selection_model = self._table_view.selectionModel()
822
+ selection_model.select(
823
+ prev_index,
824
+ selection_model.SelectionFlag.ClearAndSelect
825
+ | selection_model.SelectionFlag.Rows,
826
+ )
827
+ # Emit file selection
828
+ source_index = self._proxy_model.mapToSource(prev_index)
829
+ file_info = self._model.getFileInfo(source_index.row())
830
+ if file_info:
831
+ self.fileSelected.emit(file_info.path)