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.
- lazylabel/core/file_manager.py +33 -1
- lazylabel/ui/main_window.py +380 -119
- lazylabel/ui/right_panel.py +29 -2
- lazylabel/utils/fast_file_manager.py +831 -0
- {lazylabel_gui-1.3.1.dist-info → lazylabel_gui-1.3.3.dist-info}/METADATA +1 -1
- {lazylabel_gui-1.3.1.dist-info → lazylabel_gui-1.3.3.dist-info}/RECORD +10 -10
- lazylabel/ui/test_hover.py +0 -48
- {lazylabel_gui-1.3.1.dist-info → lazylabel_gui-1.3.3.dist-info}/WHEEL +0 -0
- {lazylabel_gui-1.3.1.dist-info → lazylabel_gui-1.3.3.dist-info}/entry_points.txt +0 -0
- {lazylabel_gui-1.3.1.dist-info → lazylabel_gui-1.3.3.dist-info}/licenses/LICENSE +0 -0
- {lazylabel_gui-1.3.1.dist-info → lazylabel_gui-1.3.3.dist-info}/top_level.txt +0 -0
@@ -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)
|