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.
- lazylabel/core/file_manager.py +1 -1
- lazylabel/ui/control_panel.py +7 -2
- lazylabel/ui/main_window.py +370 -156
- lazylabel/ui/widgets/channel_threshold_widget.py +8 -9
- lazylabel/ui/widgets/fft_threshold_widget.py +4 -0
- lazylabel/ui/widgets/model_selection_widget.py +9 -0
- lazylabel/utils/fast_file_manager.py +584 -85
- {lazylabel_gui-1.3.2.dist-info → lazylabel_gui-1.3.4.dist-info}/METADATA +1 -1
- {lazylabel_gui-1.3.2.dist-info → lazylabel_gui-1.3.4.dist-info}/RECORD +13 -13
- {lazylabel_gui-1.3.2.dist-info → lazylabel_gui-1.3.4.dist-info}/WHEEL +0 -0
- {lazylabel_gui-1.3.2.dist-info → lazylabel_gui-1.3.4.dist-info}/entry_points.txt +0 -0
- {lazylabel_gui-1.3.2.dist-info → lazylabel_gui-1.3.4.dist-info}/licenses/LICENSE +0 -0
- {lazylabel_gui-1.3.2.dist-info → lazylabel_gui-1.3.4.dist-info}/top_level.txt +0 -0
@@ -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
|
-
|
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
|
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
|
-
|
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
|
361
|
+
if column_name == "Name":
|
156
362
|
return file_info.name
|
157
|
-
elif
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
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
|
180
|
-
|
181
|
-
|
182
|
-
|
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
|
187
|
-
|
188
|
-
|
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
|
-
|
200
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
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
|
-
|
574
|
+
visible_col = left.column()
|
286
575
|
|
287
|
-
#
|
288
|
-
|
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
|
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
|
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
|
612
|
+
elif column_name == "NPZ":
|
317
613
|
return left_info.has_npz < right_info.has_npz
|
318
|
-
elif
|
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.
|
367
|
-
|
368
|
-
|
369
|
-
header
|
370
|
-
|
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
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
"
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
470
|
-
|
471
|
-
|
472
|
-
|
473
|
-
|
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.
|
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
|
556
|
-
self.
|
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)
|