lazylabel-gui 1.3.2__py3-none-any.whl → 1.3.4__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.
@@ -17,21 +17,159 @@ from PyQt6.QtCore import (
17
17
  )
18
18
  from PyQt6.QtGui import QPixmap
19
19
  from PyQt6.QtWidgets import (
20
- QComboBox,
21
20
  QHBoxLayout,
22
21
  QHeaderView,
23
22
  QLabel,
24
23
  QLineEdit,
24
+ QMenu,
25
25
  QPushButton,
26
26
  QTableView,
27
+ QToolButton,
27
28
  QVBoxLayout,
28
29
  QWidget,
29
30
  )
30
31
 
32
+ from ..utils.logger import logger
33
+
31
34
  # Image extensions supported
32
35
  IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".tiff", ".tif"}
33
36
 
34
37
 
38
+ class CustomDropdown(QToolButton):
39
+ """Custom dropdown using QToolButton + QMenu for reliable closing behavior."""
40
+
41
+ activated = pyqtSignal(int)
42
+
43
+ def __init__(self, parent=None):
44
+ super().__init__(parent)
45
+ self.setText("⚏") # Grid/settings icon
46
+ self.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
47
+ self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
48
+
49
+ # Create the menu
50
+ self.menu = QMenu(self)
51
+ self.setMenu(self.menu)
52
+
53
+ # Store items for access
54
+ self.items = []
55
+
56
+ # Style to match app theme (dark theme with consistent colors)
57
+ self.setStyleSheet("""
58
+ QToolButton {
59
+ background-color: rgba(40, 40, 40, 0.8);
60
+ border: 1px solid rgba(80, 80, 80, 0.6);
61
+ border-radius: 6px;
62
+ color: #E0E0E0;
63
+ font-size: 10px;
64
+ padding: 5px 8px;
65
+ text-align: left;
66
+ min-width: 30px;
67
+ max-width: 30px;
68
+ }
69
+ QToolButton:hover {
70
+ background-color: rgba(60, 60, 60, 0.8);
71
+ border-color: rgba(90, 120, 150, 0.8);
72
+ }
73
+ QToolButton:pressed {
74
+ background-color: rgba(70, 100, 130, 0.8);
75
+ }
76
+ QToolButton::menu-indicator {
77
+ subcontrol-origin: padding;
78
+ subcontrol-position: top right;
79
+ width: 16px;
80
+ border-left: 1px solid rgba(80, 80, 80, 0.6);
81
+ }
82
+ QMenu {
83
+ background-color: rgba(50, 50, 50, 0.9);
84
+ border: 1px solid rgba(80, 80, 80, 0.4);
85
+ color: #E0E0E0;
86
+ }
87
+ QMenu::item {
88
+ padding: 4px 8px;
89
+ }
90
+ QMenu::item:selected {
91
+ background-color: rgba(100, 100, 200, 0.5);
92
+ }
93
+ """)
94
+
95
+ def addCheckableItem(self, text, checked=True, data=None):
96
+ """Add a checkable item to the dropdown."""
97
+ action = self.menu.addAction(text)
98
+ action.setCheckable(True)
99
+ action.setChecked(checked)
100
+ action.setData(data)
101
+ self.items.append((text, data, action))
102
+
103
+ # Connect to selection handler
104
+ action.triggered.connect(
105
+ lambda checked_state, idx=len(self.items) - 1: self._on_item_toggled(
106
+ idx, checked_state
107
+ )
108
+ )
109
+
110
+ def clear(self):
111
+ """Clear all items."""
112
+ self.menu.clear()
113
+ self.items.clear()
114
+
115
+ def _on_item_toggled(self, index, checked):
116
+ """Handle item toggle."""
117
+ if 0 <= index < len(self.items):
118
+ self.activated.emit(index)
119
+
120
+ def isItemChecked(self, index):
121
+ """Check if item at index is checked."""
122
+ if 0 <= index < len(self.items):
123
+ return self.items[index][2].isChecked()
124
+ return False
125
+
126
+ def setItemChecked(self, index, checked):
127
+ """Set checked state of item at index."""
128
+ if 0 <= index < len(self.items):
129
+ self.items[index][2].setChecked(checked)
130
+
131
+ def addItem(self, text, data=None):
132
+ """Add a non-checkable item to the dropdown (QComboBox compatibility)."""
133
+ action = self.menu.addAction(text)
134
+ action.setCheckable(False)
135
+ action.setData(data)
136
+ self.items.append((text, data, action))
137
+
138
+ # Connect to selection handler
139
+ action.triggered.connect(lambda: self._on_item_selected(len(self.items) - 1))
140
+
141
+ def _on_item_selected(self, index):
142
+ """Handle item selection (for non-checkable items)."""
143
+ if 0 <= index < len(self.items):
144
+ text, data, action = self.items[index]
145
+ self.setText(text)
146
+ self.activated.emit(index)
147
+
148
+ def count(self):
149
+ """Return number of items (QComboBox compatibility)."""
150
+ return len(self.items)
151
+
152
+ def itemData(self, index):
153
+ """Get data for item at index (QComboBox compatibility)."""
154
+ if 0 <= index < len(self.items):
155
+ return self.items[index][1]
156
+ return None
157
+
158
+ def setCurrentIndex(self, index):
159
+ """Set current selection index (QComboBox compatibility)."""
160
+ if 0 <= index < len(self.items):
161
+ text, data, action = self.items[index]
162
+ self.setText(text)
163
+
164
+ def currentIndex(self):
165
+ """Get current selection index (QComboBox compatibility)."""
166
+ current_text = self.text()
167
+ for i, (text, _data, _action) in enumerate(self.items):
168
+ if text == current_text:
169
+ return i
170
+ return -1
171
+
172
+
35
173
  @dataclass
36
174
  class FileInfo:
37
175
  """Information about a file"""
@@ -121,7 +259,7 @@ class FileScanner(QThread):
121
259
  self.scanComplete.emit(total_files)
122
260
 
123
261
  except Exception as e:
124
- print(f"Error scanning directory: {e}")
262
+ logger.error(f"Error scanning directory: {e}")
125
263
 
126
264
  def stop(self):
127
265
  """Stop the scanning thread"""
@@ -136,33 +274,97 @@ class FastFileModel(QAbstractTableModel):
136
274
  def __init__(self):
137
275
  super().__init__()
138
276
  self._files: list[FileInfo] = []
277
+ self._path_to_index: dict[str, int] = {} # For O(1) lookups
139
278
  self._scanner: FileScanner | None = None
140
279
 
280
+ # Column management - New order: Name, NPZ, TXT, Modified, Size
281
+ self._all_columns = ["Name", "NPZ", "TXT", "Modified", "Size"]
282
+ self._column_map = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4} # logical to physical mapping
283
+ self._visible_columns = [True, True, True, True, True] # Default all visible
284
+
141
285
  def rowCount(self, parent=QModelIndex()):
142
286
  return len(self._files)
143
287
 
144
288
  def columnCount(self, parent=QModelIndex()):
145
- return 5 # Name, Size, Modified, NPZ, TXT
289
+ return sum(self._visible_columns) # Count visible columns
290
+
291
+ def getVisibleColumnIndex(self, logical_column):
292
+ """Convert logical column index to visible column index"""
293
+ if (
294
+ logical_column >= len(self._visible_columns)
295
+ or not self._visible_columns[logical_column]
296
+ ):
297
+ return -1
298
+
299
+ visible_index = 0
300
+ for i in range(logical_column):
301
+ if self._visible_columns[i]:
302
+ visible_index += 1
303
+ return visible_index
304
+
305
+ def getLogicalColumnIndex(self, visible_column):
306
+ """Convert visible column index to logical column index"""
307
+ visible_count = 0
308
+ for i, visible in enumerate(self._visible_columns):
309
+ if visible:
310
+ if visible_count == visible_column:
311
+ return i
312
+ visible_count += 1
313
+ return -1
314
+
315
+ def setColumnVisible(self, column, visible):
316
+ """Set column visibility"""
317
+ if (
318
+ 0 <= column < len(self._visible_columns)
319
+ and self._visible_columns[column] != visible
320
+ ):
321
+ self.beginResetModel()
322
+ self._visible_columns[column] = visible
323
+ self.endResetModel()
324
+
325
+ def isColumnVisible(self, column):
326
+ """Check if column is visible"""
327
+ if 0 <= column < len(self._visible_columns):
328
+ return self._visible_columns[column]
329
+ return False
330
+
331
+ def moveColumn(self, from_column, to_column):
332
+ """Move column to new position"""
333
+ if (
334
+ 0 <= from_column < len(self._all_columns)
335
+ and 0 <= to_column < len(self._all_columns)
336
+ and from_column != to_column
337
+ ):
338
+ self.beginResetModel()
339
+ # Move in all arrays
340
+ self._all_columns.insert(to_column, self._all_columns.pop(from_column))
341
+ self._visible_columns.insert(
342
+ to_column, self._visible_columns.pop(from_column)
343
+ )
344
+ self.endResetModel()
146
345
 
147
346
  def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole):
148
347
  if not index.isValid():
149
348
  return None
150
349
 
151
350
  file_info = self._files[index.row()]
152
- col = index.column()
351
+ visible_col = index.column()
352
+
353
+ # Convert visible column to logical column
354
+ logical_col = self.getLogicalColumnIndex(visible_col)
355
+ if logical_col == -1:
356
+ return None
357
+
358
+ column_name = self._all_columns[logical_col]
153
359
 
154
360
  if role == Qt.ItemDataRole.DisplayRole:
155
- if col == 0: # Name
361
+ if column_name == "Name":
156
362
  return file_info.name
157
- elif col == 1: # Size
158
- # Lazy load size only when displayed
159
- if file_info.size == 0:
160
- try:
161
- file_info.size = file_info.path.stat().st_size
162
- except OSError:
163
- file_info.size = -1 # Mark as error
164
- return self._format_size(file_info.size) if file_info.size >= 0 else "-"
165
- elif col == 2: # Modified
363
+ elif column_name == "NPZ":
364
+ return "✓" if file_info.has_npz else ""
365
+ elif column_name == "TXT":
366
+ return "✓" if file_info.has_txt else ""
367
+ elif column_name == "Modified":
166
368
  # Lazy load modified time only when displayed
167
369
  if file_info.modified == 0.0:
168
370
  try:
@@ -176,16 +378,20 @@ class FastFileModel(QAbstractTableModel):
176
378
  if file_info.modified > 0
177
379
  else "-"
178
380
  )
179
- elif col == 3: # NPZ
180
- return "✓" if file_info.has_npz else ""
181
- elif col == 4: # TXT
182
- return "✓" if file_info.has_txt else ""
381
+ elif column_name == "Size":
382
+ # Lazy load size only when displayed
383
+ if file_info.size == 0:
384
+ try:
385
+ file_info.size = file_info.path.stat().st_size
386
+ except OSError:
387
+ file_info.size = -1 # Mark as error
388
+ return self._format_size(file_info.size) if file_info.size >= 0 else "-"
183
389
  elif role == Qt.ItemDataRole.UserRole:
184
390
  # Return the FileInfo object for custom access
185
391
  return file_info
186
- elif role == Qt.ItemDataRole.TextAlignmentRole and col in [
187
- 3,
188
- 4,
392
+ elif role == Qt.ItemDataRole.TextAlignmentRole and column_name in [
393
+ "NPZ",
394
+ "TXT",
189
395
  ]: # Center checkmarks
190
396
  return Qt.AlignmentFlag.AlignCenter
191
397
 
@@ -196,8 +402,9 @@ class FastFileModel(QAbstractTableModel):
196
402
  orientation == Qt.Orientation.Horizontal
197
403
  and role == Qt.ItemDataRole.DisplayRole
198
404
  ):
199
- headers = ["Name", "Size", "Modified", "NPZ", "TXT"]
200
- return headers[section]
405
+ logical_col = self.getLogicalColumnIndex(section)
406
+ if logical_col >= 0 and logical_col < len(self._all_columns):
407
+ return self._all_columns[logical_col]
201
408
  return None
202
409
 
203
410
  def _format_size(self, size: int) -> str:
@@ -218,6 +425,7 @@ class FastFileModel(QAbstractTableModel):
218
425
  # Clear current files
219
426
  self.beginResetModel()
220
427
  self._files.clear()
428
+ self._path_to_index.clear()
221
429
  self.endResetModel()
222
430
 
223
431
  # Start new scan
@@ -231,12 +439,25 @@ class FastFileModel(QAbstractTableModel):
231
439
  start_row = len(self._files)
232
440
  end_row = start_row + len(files) - 1
233
441
  self.beginInsertRows(QModelIndex(), start_row, end_row)
234
- self._files.extend(files)
442
+
443
+ # Add files and update path-to-index mapping
444
+ for i, file_info in enumerate(files):
445
+ idx = start_row + i
446
+ self._files.append(file_info)
447
+ self._path_to_index[str(file_info.path)] = idx
448
+
235
449
  self.endInsertRows()
236
450
 
237
451
  def _on_scan_complete(self, total: int):
238
452
  """Handle scan completion"""
239
- print(f"Scan complete: {total} files found")
453
+ pass # Scan completion is handled by the UI status update
454
+
455
+ def getFileCounts(self):
456
+ """Get counts of total files, NPZ files, and TXT files"""
457
+ total_files = len(self._files)
458
+ npz_count = sum(1 for file_info in self._files if file_info.has_npz)
459
+ txt_count = sum(1 for file_info in self._files if file_info.has_txt)
460
+ return total_files, npz_count, txt_count
240
461
 
241
462
  def getFileInfo(self, index: int) -> FileInfo | None:
242
463
  """Get file info at index"""
@@ -262,12 +483,80 @@ class FastFileModel(QAbstractTableModel):
262
483
  self.dataChanged.emit(index, index)
263
484
  break
264
485
 
486
+ def updateFileStatus(self, image_path: Path):
487
+ """Update both NPZ and TXT status for a specific image file"""
488
+ image_path_str = str(image_path)
489
+ npz_path = image_path.with_suffix(".npz")
490
+ txt_path = image_path.with_suffix(".txt")
491
+ has_npz = npz_path.exists()
492
+ has_txt = txt_path.exists()
493
+
494
+ # O(1) lookup using path-to-index mapping
495
+ if image_path_str not in self._path_to_index:
496
+ return # File not in current view
497
+
498
+ i = self._path_to_index[image_path_str]
499
+ file_info = self._files[i]
500
+
501
+ # Update status and emit changes only if needed
502
+ old_has_npz = file_info.has_npz
503
+ old_has_txt = file_info.has_txt
504
+ file_info.has_npz = has_npz
505
+ file_info.has_txt = has_txt
506
+
507
+ # Emit dataChanged for NPZ column if status changed
508
+ if old_has_npz != has_npz:
509
+ index = self.index(i, 3) # NPZ column
510
+ self.dataChanged.emit(index, index)
511
+
512
+ # Emit dataChanged for TXT column if status changed
513
+ if old_has_txt != has_txt:
514
+ index = self.index(i, 4) # TXT column
515
+ self.dataChanged.emit(index, index)
516
+
265
517
  def getFileIndex(self, path: Path) -> int:
266
518
  """Get index of file by path"""
267
- for i, file_info in enumerate(self._files):
268
- if file_info.path == path:
269
- return i
270
- return -1
519
+ return self._path_to_index.get(str(path), -1)
520
+
521
+ def batchUpdateFileStatus(self, image_paths: list[Path]):
522
+ """Batch update file status for multiple files"""
523
+ if not image_paths:
524
+ return
525
+
526
+ changed_indices = []
527
+
528
+ for image_path in image_paths:
529
+ image_path_str = str(image_path)
530
+
531
+ # O(1) lookup using path-to-index mapping
532
+ if image_path_str not in self._path_to_index:
533
+ continue # File not in current view
534
+
535
+ i = self._path_to_index[image_path_str]
536
+ file_info = self._files[i]
537
+
538
+ # Check file existence
539
+ npz_path = image_path.with_suffix(".npz")
540
+ txt_path = image_path.with_suffix(".txt")
541
+ has_npz = npz_path.exists()
542
+ has_txt = txt_path.exists()
543
+
544
+ # Update status and track changes
545
+ old_has_npz = file_info.has_npz
546
+ old_has_txt = file_info.has_txt
547
+ file_info.has_npz = has_npz
548
+ file_info.has_txt = has_txt
549
+
550
+ # Track changed indices for batch emission
551
+ if old_has_npz != has_npz:
552
+ changed_indices.append((i, 3)) # NPZ column
553
+ if old_has_txt != has_txt:
554
+ changed_indices.append((i, 4)) # TXT column
555
+
556
+ # Batch emit dataChanged signals
557
+ for i, col in changed_indices:
558
+ index = self.index(i, col)
559
+ self.dataChanged.emit(index, index)
271
560
 
272
561
 
273
562
  class FileSortProxyModel(QSortFilterProxyModel):
@@ -282,12 +571,19 @@ class FileSortProxyModel(QSortFilterProxyModel):
282
571
  if not left_info or not right_info:
283
572
  return False
284
573
 
285
- col = left.column()
574
+ visible_col = left.column()
286
575
 
287
- # Sort based on column
288
- if col == 0: # Name
576
+ # Convert visible column to logical column
577
+ logical_col = self.sourceModel().getLogicalColumnIndex(visible_col)
578
+ if logical_col == -1:
579
+ return False
580
+
581
+ column_name = self.sourceModel()._all_columns[logical_col]
582
+
583
+ # Sort based on column type
584
+ if column_name == "Name":
289
585
  return left_info.name.lower() < right_info.name.lower()
290
- elif col == 1: # Size
586
+ elif column_name == "Size":
291
587
  # Lazy load size if needed for sorting
292
588
  if left_info.size == 0:
293
589
  try:
@@ -300,7 +596,7 @@ class FileSortProxyModel(QSortFilterProxyModel):
300
596
  except OSError:
301
597
  right_info.size = -1
302
598
  return left_info.size < right_info.size
303
- elif col == 2: # Modified
599
+ elif column_name == "Modified":
304
600
  # Lazy load modified time if needed for sorting
305
601
  if left_info.modified == 0.0:
306
602
  try:
@@ -313,9 +609,9 @@ class FileSortProxyModel(QSortFilterProxyModel):
313
609
  except OSError:
314
610
  right_info.modified = -1
315
611
  return left_info.modified < right_info.modified
316
- elif col == 3: # NPZ
612
+ elif column_name == "NPZ":
317
613
  return left_info.has_npz < right_info.has_npz
318
- elif col == 4: # TXT
614
+ elif column_name == "TXT":
319
615
  return left_info.has_txt < right_info.has_txt
320
616
 
321
617
  return False
@@ -361,19 +657,13 @@ class FastFileManager(QWidget):
361
657
  self._table_view.setSortingEnabled(True)
362
658
  self._table_view.sortByColumn(0, Qt.SortOrder.AscendingOrder)
363
659
 
364
- # Configure headers
660
+ # Configure headers with drag-and-drop reordering
365
661
  header = self._table_view.horizontalHeader()
366
- header.setSectionResizeMode(
367
- 0, QHeaderView.ResizeMode.Stretch
368
- ) # Name column stretches
369
- header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # Size
370
- header.setSectionResizeMode(
371
- 2, QHeaderView.ResizeMode.ResizeToContents
372
- ) # Modified
373
- header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) # NPZ
374
- header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) # TXT
375
- header.resizeSection(3, 50)
376
- header.resizeSection(4, 50)
662
+ header.setSectionsMovable(True) # Enable drag-and-drop reordering
663
+ header.sectionMoved.connect(self._on_column_moved)
664
+
665
+ # Initial header sizing (will be updated by _update_header_sizing)
666
+ self._update_header_sizing()
377
667
 
378
668
  # Style the table to match the existing UI
379
669
  self._table_view.setStyleSheet("""
@@ -439,42 +729,95 @@ class FastFileManager(QWidget):
439
729
  sort_label.setStyleSheet("color: #E0E0E0;")
440
730
  layout.addWidget(sort_label)
441
731
 
442
- self._sort_combo = QComboBox()
443
- self._sort_combo.addItems(
444
- [
445
- "Name (A-Z)",
446
- "Name (Z-A)",
447
- "Date (Oldest)",
448
- "Date (Newest)",
449
- "Size (Smallest)",
450
- "Size (Largest)",
451
- ]
452
- )
453
- self._sort_combo.currentIndexChanged.connect(self._on_sort_changed)
454
- self._sort_combo.setStyleSheet("""
455
- QComboBox {
456
- background-color: rgba(50, 50, 50, 0.5);
457
- border: 1px solid rgba(80, 80, 80, 0.4);
458
- color: #E0E0E0;
459
- padding: 4px;
460
- border-radius: 3px;
461
- }
462
- QComboBox::drop-down {
463
- border: none;
464
- }
465
- QComboBox::down-arrow {
466
- width: 12px;
467
- height: 12px;
468
- }
469
- QComboBox QAbstractItemView {
470
- background-color: rgba(50, 50, 50, 0.9);
471
- border: 1px solid rgba(80, 80, 80, 0.4);
472
- color: #E0E0E0;
473
- selection-background-color: rgba(100, 100, 200, 0.5);
474
- }
475
- """)
732
+ # Create custom sort dropdown
733
+ class SortDropdown(QToolButton):
734
+ activated = pyqtSignal(int)
735
+
736
+ def __init__(self, parent=None):
737
+ super().__init__(parent)
738
+ self.setText("Name (A-Z)")
739
+ self.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
740
+ self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
741
+
742
+ self.menu = QMenu(self)
743
+ self.setMenu(self.menu)
744
+ self.items = []
745
+
746
+ # Same style as CustomDropdown
747
+ self.setStyleSheet("""
748
+ QToolButton {
749
+ background-color: rgba(40, 40, 40, 0.8);
750
+ border: 1px solid rgba(80, 80, 80, 0.6);
751
+ border-radius: 6px;
752
+ color: #E0E0E0;
753
+ font-size: 10px;
754
+ padding: 5px 8px;
755
+ text-align: left;
756
+ min-width: 70px;
757
+ max-width: 70px;
758
+ }
759
+ QToolButton:hover {
760
+ background-color: rgba(60, 60, 60, 0.8);
761
+ border-color: rgba(90, 120, 150, 0.8);
762
+ }
763
+ QToolButton:pressed {
764
+ background-color: rgba(70, 100, 130, 0.8);
765
+ }
766
+ QToolButton::menu-indicator {
767
+ subcontrol-origin: padding;
768
+ subcontrol-position: top right;
769
+ width: 16px;
770
+ border-left: 1px solid rgba(80, 80, 80, 0.6);
771
+ }
772
+ QMenu {
773
+ background-color: rgba(50, 50, 50, 0.9);
774
+ border: 1px solid rgba(80, 80, 80, 0.4);
775
+ color: #E0E0E0;
776
+ }
777
+ QMenu::item {
778
+ padding: 4px 8px;
779
+ }
780
+ QMenu::item:selected {
781
+ background-color: rgba(100, 100, 200, 0.5);
782
+ }
783
+ """)
784
+
785
+ def addItem(self, text, data=None):
786
+ action = self.menu.addAction(text)
787
+ action.setData(data)
788
+ self.items.append((text, data))
789
+ action.triggered.connect(
790
+ lambda checked, idx=len(self.items) - 1: self._on_item_selected(idx)
791
+ )
792
+ if len(self.items) == 1:
793
+ self.setText(text)
794
+
795
+ def _on_item_selected(self, index):
796
+ if 0 <= index < len(self.items):
797
+ text, data = self.items[index]
798
+ self.setText(text)
799
+ self.activated.emit(index)
800
+
801
+ self._sort_combo = SortDropdown()
802
+ self._sort_combo.addItem("Name (A-Z)", 0)
803
+ self._sort_combo.addItem("Name (Z-A)", 1)
804
+ self._sort_combo.addItem("Date (Oldest)", 2)
805
+ self._sort_combo.addItem("Date (Newest)", 3)
806
+ self._sort_combo.addItem("Size (Smallest)", 4)
807
+ self._sort_combo.addItem("Size (Largest)", 5)
808
+ self._sort_combo.activated.connect(self._on_sort_changed)
476
809
  layout.addWidget(self._sort_combo)
477
810
 
811
+ # Column visibility dropdown
812
+ self._column_dropdown = CustomDropdown()
813
+ self._column_dropdown.addCheckableItem("Name", True, 0)
814
+ self._column_dropdown.addCheckableItem("NPZ", True, 1)
815
+ self._column_dropdown.addCheckableItem("TXT", True, 2)
816
+ self._column_dropdown.addCheckableItem("Modified", True, 3)
817
+ self._column_dropdown.addCheckableItem("Size", True, 4)
818
+ self._column_dropdown.activated.connect(self._on_column_visibility_changed)
819
+ layout.addWidget(self._column_dropdown)
820
+
478
821
  # Refresh button
479
822
  refresh_btn = QPushButton("Refresh")
480
823
  refresh_btn.clicked.connect(self._refresh)
@@ -508,7 +851,7 @@ class FastFileManager(QWidget):
508
851
  # Connect to scan complete signal
509
852
  if self._model._scanner:
510
853
  self._model._scanner.scanComplete.connect(
511
- lambda count: self._update_status(f"{count} files in {directory.name}")
854
+ lambda count: self._update_detailed_status(directory.name)
512
855
  )
513
856
 
514
857
  def _on_search_changed(self, text: str):
@@ -547,13 +890,148 @@ class FastFileManager(QWidget):
547
890
  if self._current_directory:
548
891
  self.setDirectory(self._current_directory)
549
892
 
893
+ def _on_column_visibility_changed(self, column_index):
894
+ """Handle column visibility toggle"""
895
+ is_checked = self._column_dropdown.isItemChecked(column_index)
896
+ self._model.setColumnVisible(column_index, is_checked)
897
+
898
+ # Update header sizing for visible columns
899
+ self._update_header_sizing()
900
+
901
+ def _update_header_sizing(self):
902
+ """Update header column sizing for visible columns"""
903
+ header = self._table_view.horizontalHeader()
904
+ visible_columns = sum(self._model._visible_columns)
905
+
906
+ if visible_columns == 0:
907
+ return
908
+
909
+ # Set all columns to Interactive mode for manual resizing
910
+ for i in range(visible_columns):
911
+ # Find logical column for this visible index
912
+ logical_col = self._model.getLogicalColumnIndex(i)
913
+ if logical_col >= 0:
914
+ column_name = self._model._all_columns[logical_col]
915
+ # All columns are interactive (manually resizable)
916
+ header.setSectionResizeMode(i, QHeaderView.ResizeMode.Interactive)
917
+
918
+ # Set appropriate default sizes
919
+ if column_name == "Name":
920
+ header.resizeSection(i, 200) # Default name column width
921
+ elif column_name in ["NPZ", "TXT"]:
922
+ header.resizeSection(i, 50) # Compact for checkmarks
923
+ elif column_name == "Modified":
924
+ header.resizeSection(i, 120) # Date needs more space
925
+ elif column_name == "Size":
926
+ header.resizeSection(i, 80) # Size needs moderate space
927
+
928
+ # Disable stretch last section to allow all columns to be manually resized
929
+ header.setStretchLastSection(False)
930
+
931
+ def _on_column_moved(self, logical_index, old_visual_index, new_visual_index):
932
+ """Handle column reordering via drag-and-drop"""
933
+ # For now, just update the header sizing to maintain proper resize modes
934
+ # The QHeaderView handles the visual reordering automatically
935
+ self._update_header_sizing()
936
+
550
937
  def updateNpzStatus(self, image_path: Path):
551
938
  """Update NPZ status for a specific image file"""
552
939
  self._model.updateNpzStatus(image_path)
553
940
 
941
+ def updateFileStatus(self, image_path: Path):
942
+ """Update both NPZ and TXT status for a specific image file"""
943
+ self._model.updateFileStatus(image_path)
944
+ # Update status counts when file status changes
945
+ if self._current_directory:
946
+ self._update_detailed_status(self._current_directory.name)
947
+ # Force table view to repaint immediately
948
+ self._table_view.viewport().update()
949
+ self._table_view.repaint()
950
+
554
951
  def refreshFile(self, image_path: Path):
555
- """Refresh status for a specific file (alias for updateNpzStatus)"""
556
- self.updateNpzStatus(image_path)
952
+ """Refresh status for a specific file"""
953
+ self.updateFileStatus(image_path)
954
+
955
+ def batchUpdateFileStatus(self, image_paths: list[Path]):
956
+ """Batch update file status for multiple files"""
957
+ self._model.batchUpdateFileStatus(image_paths)
958
+ # Update status counts after batch update
959
+ if self._current_directory:
960
+ self._update_detailed_status(self._current_directory.name)
961
+ # Force table view to repaint immediately
962
+ self._table_view.viewport().update()
963
+ self._table_view.repaint()
964
+
965
+ def getSurroundingFiles(self, current_path: Path, count: int) -> list[Path]:
966
+ """Get files in current sorted/filtered order surrounding the given path"""
967
+ files = []
968
+
969
+ # Find current file in proxy model order
970
+ current_index = -1
971
+ for row in range(self._proxy_model.rowCount()):
972
+ proxy_index = self._proxy_model.index(row, 0)
973
+ source_index = self._proxy_model.mapToSource(proxy_index)
974
+ file_info = self._model.getFileInfo(source_index.row())
975
+ if file_info and file_info.path == current_path:
976
+ current_index = row
977
+ break
978
+
979
+ if current_index == -1:
980
+ return [] # File not found in current view
981
+
982
+ # Get surrounding files in proxy order
983
+ for i in range(count):
984
+ row = current_index + i
985
+ if row < self._proxy_model.rowCount():
986
+ proxy_index = self._proxy_model.index(row, 0)
987
+ source_index = self._proxy_model.mapToSource(proxy_index)
988
+ file_info = self._model.getFileInfo(source_index.row())
989
+ if file_info:
990
+ files.append(file_info.path)
991
+ else:
992
+ files.append(None)
993
+ else:
994
+ files.append(None)
995
+
996
+ return files
997
+
998
+ def getPreviousFiles(self, current_path: Path, count: int) -> list[Path]:
999
+ """Get previous files in current sorted/filtered order before the given path"""
1000
+ files = []
1001
+
1002
+ # Find current file in proxy model order
1003
+ current_index = -1
1004
+ for row in range(self._proxy_model.rowCount()):
1005
+ proxy_index = self._proxy_model.index(row, 0)
1006
+ source_index = self._proxy_model.mapToSource(proxy_index)
1007
+ file_info = self._model.getFileInfo(source_index.row())
1008
+ if file_info and file_info.path == current_path:
1009
+ current_index = row
1010
+ break
1011
+
1012
+ if current_index == -1:
1013
+ return [] # File not found in current view
1014
+
1015
+ # Get previous files going backward from current position
1016
+ start_row = current_index - count
1017
+ if start_row < 0:
1018
+ start_row = 0
1019
+
1020
+ # Get consecutive files starting from start_row
1021
+ for i in range(count):
1022
+ row = start_row + i
1023
+ if row < current_index and row >= 0:
1024
+ proxy_index = self._proxy_model.index(row, 0)
1025
+ source_index = self._proxy_model.mapToSource(proxy_index)
1026
+ file_info = self._model.getFileInfo(source_index.row())
1027
+ if file_info:
1028
+ files.append(file_info.path)
1029
+ else:
1030
+ files.append(None)
1031
+ else:
1032
+ files.append(None)
1033
+
1034
+ return files
557
1035
 
558
1036
  def _on_item_clicked(self, index: QModelIndex):
559
1037
  """Handle item click"""
@@ -575,6 +1053,27 @@ class FastFileManager(QWidget):
575
1053
  """Update status label"""
576
1054
  self._status_label.setText(text)
577
1055
 
1056
+ def _update_detailed_status(self, directory_name: str):
1057
+ """Update status label with detailed file counts"""
1058
+ total_files, npz_count, txt_count = self._model.getFileCounts()
1059
+
1060
+ if total_files == 0:
1061
+ status_text = f"No files in {directory_name}"
1062
+ else:
1063
+ # Build the status message parts
1064
+ parts = []
1065
+ parts.append(f"{total_files} image{'s' if total_files != 1 else ''}")
1066
+
1067
+ if npz_count > 0:
1068
+ parts.append(f"{npz_count} npz")
1069
+
1070
+ if txt_count > 0:
1071
+ parts.append(f"{txt_count} txt")
1072
+
1073
+ status_text = f"{', '.join(parts)} in {directory_name}"
1074
+
1075
+ self._status_label.setText(status_text)
1076
+
578
1077
  def selectFile(self, path: Path):
579
1078
  """Select a specific file in the view"""
580
1079
  index = self._model.getFileIndex(path)