pixmatch 0.0.1__py3-none-any.whl → 0.0.2__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.

Potentially problematic release.


This version of pixmatch might be problematic. Click here for more details.

pixmatch/gui/__init__.py DELETED
@@ -1,837 +0,0 @@
1
- # TODO: Validate that users don't select overlapping paths...
2
-
3
-
4
- import logging
5
-
6
- from importlib.metadata import version, PackageNotFoundError
7
- from pathlib import Path
8
-
9
- from PySide6 import QtCore, QtGui, QtWidgets
10
-
11
- from pixmatch import ImageMatcher, ImageMatch, NewGroup, NewMatch, Finished, ZipPath
12
- from pixmatch.gui.utils import NO_MARGIN, MAX_SIZE_POLICY
13
- from pixmatch.gui.widgets import DuplicateGroupList, DirFileSystemModel, ImageViewPane, SelectionState
14
- from pixmatch.utils import human_bytes
15
-
16
- ICON_PATH = Path(__file__).resolve().parent / 'pixmatch.ico'
17
- print(f"Icon at {ICON_PATH} : {ICON_PATH.exists()}")
18
-
19
- logger = logging.getLogger(__name__)
20
-
21
-
22
- def ceildiv(a, b):
23
- """The opposite of floordiv, //"""
24
- return -(a // -b)
25
-
26
-
27
- def project_version() -> str:
28
- try:
29
- return version("pixmatch") # e.g. "myapp"
30
- except PackageNotFoundError:
31
- return "0.0.0+unknown"
32
-
33
-
34
- class WorkerSignals(QtCore.QObject):
35
- """
36
- Signals from a running worker thread.
37
-
38
- finished
39
- No data
40
-
41
- error
42
- tuple (exctype, value, traceback.format_exc())
43
-
44
- result
45
- object data returned from processing, anything
46
-
47
- progress
48
- float indicating % progress
49
- """
50
-
51
- new_match = QtCore.Signal(tuple)
52
- new_group = QtCore.Signal(object)
53
- finish = QtCore.Signal()
54
-
55
-
56
- class ProcessorThread(QtCore.QRunnable):
57
-
58
- def __init__(self, processor, *args, **kwargs):
59
- super().__init__()
60
- self.args = args
61
- self.kwargs = kwargs
62
- self.processor = processor
63
- self.signals = WorkerSignals()
64
-
65
- # timer lives on the GUI thread; it polls the library queue
66
- self._poller = QtCore.QTimer()
67
- self._poller.setInterval(250)
68
- self._poller.timeout.connect(self._drain_events)
69
- self._poller.start()
70
-
71
- def _drain_events(self):
72
- while not self.processor.events.empty():
73
- evt = self.processor.events.get_nowait()
74
- if isinstance(evt, NewGroup):
75
- self.signals.new_group.emit(evt.group)
76
- elif isinstance(evt, NewMatch):
77
- self.signals.new_match.emit((evt.group, evt.path))
78
- elif isinstance(evt, Finished):
79
- self._poller.stop()
80
- self.signals.finish.emit()
81
-
82
- def run(self):
83
- self.processor.run(*self.args, **self.kwargs)
84
-
85
-
86
- class MainWindow(QtWidgets.QMainWindow):
87
- """
88
- The main application window emulating VisiPics' primary workflow.
89
-
90
- Key elements:
91
- - Toolbar with folder selection (stub), Load Test Image, Similarity slider
92
- - Central area that shows one group/page at a time
93
- - Prev/Next page navigation
94
- """
95
-
96
- def __init__(self, start_paths=None):
97
- super().__init__()
98
- self.setWindowTitle(f"PixMatch v{project_version()}")
99
- self.resize(1200, 800)
100
-
101
- # State
102
- self.current_page: int = 1
103
- self.processor = None
104
- self.file_states = dict()
105
- self.threadpool = QtCore.QThreadPool()
106
- self._gui_paused = False
107
-
108
- # UI build
109
- self.build_menubar()
110
- self.build_central()
111
- self.build_statusbar()
112
- self.build_extra()
113
-
114
- for start_path in start_paths or []:
115
- self.selected_file_path_display.addItem(str(start_path))
116
-
117
- if start_paths:
118
- self.selected_file_path_display.setCurrentRow(0)
119
-
120
- self.setWindowIcon(QtGui.QIcon(QtGui.QPixmap(ICON_PATH)))
121
-
122
- def build_extra(self):
123
- self.exit_warning = QtWidgets.QMessageBox(self)
124
- self.exit_warning.setIcon(QtWidgets.QMessageBox.Icon.Warning)
125
- self.exit_warning.setWindowTitle("Close?")
126
- self.exit_warning.setText("Are you sure you want to quit?")
127
- self.exit_warning.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No)
128
- self.exit_warning.setDefaultButton(QtWidgets.QMessageBox.StandardButton.No)
129
- self.exit_warning.setEscapeButton(QtWidgets.QMessageBox.StandardButton.No)
130
- self.exit_warning.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
131
-
132
- def build_menubar(self) -> None:
133
- """Creates the top menu bar"""
134
- menu = self.menuBar()
135
-
136
- # region File menu
137
- load_project = QtGui.QAction("Load Project...", self, enabled=False)
138
- save_project = QtGui.QAction("Save Project...", self, enabled=False)
139
- exit_project = QtGui.QAction("Exit", self)
140
- exit_project.triggered.connect(self.on_exit)
141
-
142
- file_menu = menu.addMenu("&File")
143
- file_menu.addAction(load_project)
144
- file_menu.addAction(save_project)
145
- file_menu.addSeparator()
146
- file_menu.addAction(exit_project)
147
- # endregion
148
-
149
- # region Edit menu
150
- # TODO: When adding this, make sure not to allow marking a zip file as delete
151
- mark_delete = QtGui.QAction("Delete", self, enabled=False)
152
- mark_ignore = QtGui.QAction("Ignore", self, enabled=False)
153
- mark_ignore_group = QtGui.QAction("Ignore Group", self, enabled=False)
154
- mark_ignore_folder = QtGui.QAction("Ignore Folder", self, enabled=False)
155
- mark_rename = QtGui.QAction("Rename this file...", self, enabled=False)
156
- mark_move = QtGui.QAction("Move this file...", self, enabled=False)
157
- mark_symlink = QtGui.QAction("Symlink this file...", self, enabled=False)
158
- unmark = QtGui.QAction("Un-select", self, enabled=False)
159
-
160
- edit_menu = menu.addMenu("&Edit")
161
- edit_menu.addAction(mark_delete)
162
- edit_menu.addAction(mark_ignore)
163
- edit_menu.addAction(mark_ignore_group)
164
- edit_menu.addAction(mark_ignore_folder)
165
- edit_menu.addSeparator()
166
- edit_menu.addAction(mark_rename)
167
- edit_menu.addAction(mark_move)
168
- edit_menu.addAction(mark_symlink)
169
- edit_menu.addSeparator()
170
- edit_menu.addAction(unmark)
171
- # endregion
172
-
173
- # region View menu
174
- page_next = QtGui.QAction("Next page", self)
175
- page_next.triggered.connect(self.on_page_up)
176
- page_back = QtGui.QAction("Previous page", self)
177
- page_back.triggered.connect(self.on_page_down)
178
- self.preview_resized = QtGui.QAction("Preview resized", self, checked=True, checkable=True)
179
- preview_full_size = QtGui.QAction("Preview full size", self, checkable=True)
180
-
181
- preview_options_grp = QtGui.QActionGroup(self)
182
- preview_options_grp.setExclusive(True)
183
- preview_options_grp.addAction(preview_full_size)
184
- preview_options_grp.addAction(self.preview_resized)
185
-
186
- # TODO: I'm not sure why we would want to slow display?
187
- # and show differences has never worked for me
188
- # mark_rename = QtGui.QAction("Slow preview display", self)
189
- # mark_move = QtGui.QAction("Show differences", self)
190
-
191
- view_menu = menu.addMenu("&View")
192
- view_menu.addAction(page_next)
193
- view_menu.addAction(page_back)
194
- view_menu.addSeparator()
195
- view_menu.addAction(self.preview_resized)
196
- view_menu.addAction(preview_full_size)
197
- # endregion
198
-
199
- # region Tools menu
200
- autoselect = QtGui.QAction("Auto-select", self)
201
- autoselect.setEnabled(False) # TODO:
202
-
203
- tool_menu = menu.addMenu("&Tools")
204
- tool_menu.addAction(autoselect)
205
- # endregion
206
-
207
- # region Actions menu
208
- run_move = QtGui.QAction("Move", self, enabled=False)
209
- run_delete = QtGui.QAction("Delete", self)
210
- run_delete.triggered.connect(self.on_delete)
211
- run_ignore = QtGui.QAction("Save ignored pictures", self)
212
- run_ignore.triggered.connect(self.on_ignore)
213
-
214
- actions_menu = menu.addMenu("&Actions")
215
- actions_menu.addAction(run_move)
216
- actions_menu.addAction(run_delete)
217
- actions_menu.addAction(run_ignore)
218
- # endregion
219
-
220
- # region Options menu
221
- option_hidden_folders = QtGui.QAction("Show hidden folders", self, checkable=True, enabled=False)
222
- option_subfolders = QtGui.QAction("Include subfolders", self, checkable=True, checked=True, enabled=False)
223
- option_rotations = QtGui.QAction("Scan for rotations", self, checkable=True, checked=True, enabled=False)
224
-
225
- # TODO: I'm not sure what these two do... I'm willing to add them if someone needs them but I don't.
226
- # ... = QtGui.QAction("Between folders only", self)
227
- # ... = QtGui.QAction("Loosen filter automatically", self)
228
-
229
- options_menu = menu.addMenu("&Options")
230
- options_menu.addAction(option_hidden_folders)
231
- options_menu.addAction(option_subfolders)
232
- options_menu.addSeparator()
233
- options_menu.addAction(option_rotations)
234
- # endregion
235
-
236
- def build_central(self) -> None:
237
- """Creates the central stacked widget area for group pages."""
238
- # TODO: Add other setting tabs
239
- style = QtWidgets.QApplication.instance().style()
240
-
241
- # region General controls area (top-right)
242
- # region Control buttons
243
- autoselect_btn = QtWidgets.QPushButton("Auto-select")
244
- autoselect_btn.setEnabled(False) # TODO:
245
-
246
- tools = QtWidgets.QVBoxLayout()
247
- tools.addWidget(autoselect_btn)
248
-
249
- tool_box = QtWidgets.QGroupBox("Tools")
250
- tool_box.setLayout(tools)
251
- tool_box.setMaximumHeight(60)
252
-
253
- move_btn = QtWidgets.QPushButton("Move")
254
- move_btn.setEnabled(False) # TODO:
255
- delete_btn = QtWidgets.QPushButton("Delete")
256
- delete_btn.pressed.connect(self.on_delete)
257
- save_ignored_btn = QtWidgets.QPushButton("Save ignored")
258
- save_ignored_btn.pressed.connect(self.on_ignore)
259
-
260
- actions = QtWidgets.QVBoxLayout()
261
- actions.addWidget(move_btn)
262
- actions.addWidget(delete_btn)
263
- actions.addWidget(save_ignored_btn)
264
-
265
- actions_box = QtWidgets.QGroupBox("Actions")
266
- actions_box.setLayout(actions)
267
- actions_box.setMaximumHeight(20 + 40 * 3)
268
-
269
- general_controls_btns = QtWidgets.QVBoxLayout()
270
- general_controls_btns.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
271
- general_controls_btns.setContentsMargins(NO_MARGIN)
272
- general_controls_btns.addWidget(tool_box)
273
- general_controls_btns.addWidget(actions_box)
274
- # endregion
275
-
276
- # region Run Controls
277
- self.stop_btn = QtWidgets.QPushButton("\u25A0")
278
- self.stop_btn.setSizePolicy(MAX_SIZE_POLICY)
279
- self.stop_btn.setCheckable(True)
280
- self.stop_btn.setChecked(True)
281
- self.stop_btn.setStyleSheet('QPushButton {font-size: 26pt; color: maroon;}')
282
- self.stop_btn.clicked.connect(self.on_pause)
283
-
284
- self.start_btn = QtWidgets.QPushButton("\u25B6")
285
- self.start_btn.setSizePolicy(MAX_SIZE_POLICY)
286
- self.start_btn.setCheckable(True)
287
- self.start_btn.setStyleSheet('QPushButton {font-size: 32pt; color: green;}')
288
- self.start_btn.clicked.connect(self.on_start)
289
-
290
- self.pause_btn = QtWidgets.QPushButton()
291
- self.pause_btn.setSizePolicy(MAX_SIZE_POLICY)
292
- self.pause_btn.setCheckable(True)
293
- self.pause_btn.setEnabled(False)
294
- self.pause_btn.clicked.connect(self.on_pause)
295
- self.pause_btn.setIcon(style.standardIcon(
296
- QtWidgets.QStyle.StandardPixmap.SP_MediaPause,
297
- ))
298
- self.pause_btn.setIconSize(QtCore.QSize(28, 28))
299
-
300
- run_controls_options_grp = QtWidgets.QButtonGroup(self, exclusive=True)
301
- run_controls_options_grp.addButton(self.stop_btn)
302
- run_controls_options_grp.addButton(self.start_btn)
303
- run_controls_options_grp.addButton(self.pause_btn)
304
-
305
- run_controls = QtWidgets.QHBoxLayout()
306
- run_controls.setContentsMargins(NO_MARGIN)
307
- run_controls.addWidget(self.stop_btn)
308
- run_controls.addWidget(self.start_btn)
309
- run_controls.addWidget(self.pause_btn)
310
- # endregion
311
-
312
- labels = QtWidgets.QVBoxLayout()
313
- labels.setContentsMargins(NO_MARGIN)
314
- self._remaining_files_label = QtWidgets.QLabel()
315
- self.set_remaining_files_label(0)
316
- self._loaded_pictures_label = QtWidgets.QLabel()
317
- self.set_loaded_pictures_label(0)
318
- self._dup_pictures_label = QtWidgets.QLabel()
319
- self.set_duplicate_images_label(0)
320
- self._dup_groups_label = QtWidgets.QLabel()
321
- self.set_duplicate_groups_label(0)
322
-
323
- self._timer_label = QtWidgets.QLabel("00:00:00", alignment=QtCore.Qt.AlignmentFlag.AlignHCenter)
324
- self._elapsed_secs = 0
325
- self._run_timer = QtCore.QTimer(self)
326
- self._run_timer.setInterval(1000) # 1s ticks
327
- self._run_timer.timeout.connect(self._on_run_timer_tick)
328
-
329
- self._label_timer = QtCore.QTimer(self)
330
- self._label_timer.setInterval(50)
331
- self._label_timer.timeout.connect(self._on_labels_tick)
332
-
333
- self._progress_bar = QtWidgets.QProgressBar(value=50, textVisible=False)
334
- labels.addWidget(self._remaining_files_label)
335
- labels.addWidget(self._loaded_pictures_label)
336
- labels.addWidget(self._dup_pictures_label)
337
- labels.addWidget(self._dup_groups_label)
338
- labels.addWidget(self._timer_label)
339
- labels.addWidget(self._progress_bar)
340
- labels.addLayout(run_controls)
341
-
342
- # region Settings tabs
343
- # region Filter tab
344
- slider_labels = QtWidgets.QVBoxLayout()
345
- slider_labels.setContentsMargins(NO_MARGIN)
346
- slider_labels.addWidget(QtWidgets.QLabel(text="Strict", alignment=QtCore.Qt.AlignmentFlag.AlignTop))
347
- slider_labels.addWidget(QtWidgets.QLabel(text="Basic", alignment=QtCore.Qt.AlignmentFlag.AlignVCenter))
348
- slider_labels.addWidget(QtWidgets.QLabel(text="Loose", alignment=QtCore.Qt.AlignmentFlag.AlignBottom))
349
-
350
- self.precision_slider = QtWidgets.QSlider(tickPosition=QtWidgets.QSlider.TickPosition.TicksLeft)
351
- self.precision_slider.setMaximum(10)
352
- self.precision_slider.setValue(5)
353
- self.precision_slider.sliderMoved.connect(self.on_precision_adjust)
354
-
355
- filter_tab_main = QtWidgets.QHBoxLayout()
356
- filter_tab_main.setContentsMargins(NO_MARGIN)
357
- filter_tab_main.addLayout(slider_labels)
358
- filter_tab_main.addWidget(self.precision_slider)
359
- filter_tab_main.addWidget(QtWidgets.QLabel(
360
- text="The slider determines\n"
361
- "how strictly the program\n"
362
- "checks for similarities\n"
363
- "between the images.\n"
364
- "Strict means it checks if\n"
365
- "an image is the same or\n"
366
- "slightly different, loose\n"
367
- "allows for a greater\n"
368
- "amount of differences.",
369
- alignment=QtCore.Qt.AlignmentFlag.AlignCenter,
370
- ))
371
-
372
- filter_tab = QtWidgets.QVBoxLayout()
373
- self.hash_match_chkbx = QtWidgets.QCheckBox(" Hash match")
374
- self.hash_match_chkbx.setEnabled(False)
375
- filter_tab.addWidget(self.hash_match_chkbx)
376
- filter_tab.addLayout(filter_tab_main)
377
- # endregion
378
-
379
- such = QtWidgets.QTabWidget()
380
- such.addTab(QtWidgets.QWidget(layout=filter_tab), "Filter")
381
- # endregion
382
-
383
- labels_and_such = QtWidgets.QHBoxLayout()
384
- labels_and_such.setContentsMargins(NO_MARGIN)
385
- labels_and_such.addWidget(QtWidgets.QWidget(layout=labels, maximumWidth=140))
386
- labels_and_such.addWidget(such)
387
-
388
- primary_controls = QtWidgets.QVBoxLayout()
389
- primary_controls.setContentsMargins(NO_MARGIN)
390
- primary_controls.addLayout(self.build_file_path_selection_display())
391
- primary_controls.addWidget(QtWidgets.QWidget(layout=labels_and_such, fixedHeight=240))
392
-
393
- general_controls = QtWidgets.QHBoxLayout()
394
- general_controls.setContentsMargins(NO_MARGIN)
395
- general_controls.addLayout(primary_controls)
396
- general_controls.addWidget(QtWidgets.QWidget(layout=general_controls_btns, maximumWidth=130))
397
-
398
- # region File system explorer
399
- file_system_model = DirFileSystemModel()
400
- file_system_model.setFilter(QtCore.QDir.Filter.Dirs
401
- | QtCore.QDir.Filter.Drives
402
- | QtCore.QDir.Filter.NoDotAndDotDot)
403
- file_system_model.setRootPath("")
404
- self.file_system_view = QtWidgets.QTreeView(headerHidden=True)
405
- self.file_system_view.setContentsMargins(NO_MARGIN)
406
- self.file_system_view.setModel(file_system_model)
407
- self.file_system_view.setRootIndex(file_system_model.index(""))
408
- self.file_system_view.hideColumn(1) # Size
409
- self.file_system_view.hideColumn(2) # Type
410
- self.file_system_view.hideColumn(3) # Date Modified
411
- file_view_splitter = QtWidgets.QHBoxLayout()
412
- file_view_splitter.setContentsMargins(NO_MARGIN)
413
- file_view_splitter.addWidget(self.file_system_view)
414
- file_view_splitter.addWidget(QtWidgets.QWidget(layout=general_controls, maximumWidth=600))
415
- # endregion
416
- # endregion
417
-
418
- outer_splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal, childrenCollapsible=False)
419
- inner_splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical, childrenCollapsible=False)
420
-
421
- inner_splitter.addWidget(QtWidgets.QWidget(layout=file_view_splitter))
422
- inner_splitter.addWidget(self.build_image_view_area())
423
- inner_splitter.setSizes([250, 250])
424
-
425
- self.duplicate_group_list = DuplicateGroupList(sizePolicy=MAX_SIZE_POLICY)
426
- self.duplicate_group_list.groupTileStateChanged.connect(self.on_match_state_changed)
427
- self.duplicate_group_list.groupTileHovered.connect(self.image_view_area.set_image)
428
- self.duplicate_group_list.page_down.pressed.connect(self.on_page_down)
429
- self.duplicate_group_list.page_up.pressed.connect(self.on_page_up)
430
- self.duplicate_group_list.first_page.pressed.connect(self.on_page_first)
431
- self.duplicate_group_list.last_page.pressed.connect(self.on_page_last)
432
- outer_splitter.addWidget(self.duplicate_group_list)
433
- outer_splitter.addWidget(inner_splitter)
434
-
435
- # Create a central widget and layout to hold the splitters
436
- central_widget = QtWidgets.QWidget()
437
- hbox = QtWidgets.QHBoxLayout(central_widget)
438
- hbox.addWidget(outer_splitter)
439
- self.setCentralWidget(central_widget)
440
-
441
- def _on_run_timer_tick(self):
442
- if not self.processor:
443
- return
444
-
445
- # Only count time while actively running
446
- if not self.processor.is_paused() and not self.processor.is_finished():
447
- self._elapsed_secs += 1
448
- h, rem = divmod(self._elapsed_secs, 3600)
449
- m, s = divmod(rem, 60)
450
- self._timer_label.setText(f"{h:02d}:{m:02d}:{s:02d}")
451
-
452
- def _on_labels_tick(self):
453
- if not self.processor:
454
- return
455
-
456
- self.update_labels()
457
-
458
- def on_precision_adjust(self, e):
459
- """The precision slider has been adjusted, so update the hash checkbox"""
460
- if e != 10:
461
- self.hash_match_chkbx.setEnabled(False)
462
- self.hash_match_chkbx.setChecked(False)
463
- else:
464
- self.hash_match_chkbx.setEnabled(True)
465
-
466
- def on_pause(self, e):
467
- if not e:
468
- return
469
-
470
- if not self.processor:
471
- return
472
-
473
- self.processor.pause()
474
- self._run_timer.stop()
475
- self._label_timer.stop()
476
-
477
- def on_start(self, e):
478
- if not e:
479
- return
480
-
481
- if not self.processor:
482
- # This is the first time running!
483
- self.processor = ImageMatcher(
484
- strength=self.precision_slider.value(),
485
- exact_match=self.hash_match_chkbx.isChecked(),
486
- )
487
-
488
- elif self.processor.is_paused() and not self.processor.is_finished():
489
- self.processor.resume()
490
- self.pause_btn.setEnabled(True)
491
- self._run_timer.start()
492
- self._label_timer.start()
493
- return
494
-
495
- elif self.processor.running():
496
- raise RuntimeError("Somehow we're trying to run when the processor appears to be running already!")
497
-
498
- target_paths = [
499
- self.selected_file_path_display.item(i).text()
500
- for i in range(self.selected_file_path_display.count())
501
- ]
502
-
503
- self.thread = ProcessorThread(self.processor, target_paths)
504
- self.thread.signals.new_group.connect(self.on_new_match_group_found)
505
- self.thread.signals.new_match.connect(self.on_new_match_found)
506
- self.thread.signals.finish.connect(self.on_finish)
507
- self.threadpool.start(self.thread)
508
- self.pause_btn.setEnabled(True)
509
- self.precision_slider.setEnabled(False)
510
- self.hash_match_chkbx.setEnabled(False)
511
- self._run_timer.start()
512
- self._label_timer.start()
513
- return
514
-
515
- def on_finish(self):
516
- self.stop_btn.setChecked(True)
517
- self.pause_btn.setEnabled(False)
518
- self._run_timer.stop()
519
- self._label_timer.stop()
520
- self.thread = None
521
-
522
- def on_delete(self, *_):
523
- self.process_file_states({SelectionState.DELETE})
524
-
525
- def on_ignore(self, *_):
526
- self.process_file_states({SelectionState.IGNORE})
527
-
528
- @property
529
- def last_page(self):
530
- return ceildiv(len(self.processor.matches), self.duplicate_group_list._max_rows)
531
-
532
- def on_new_match_group_found(self, match_group: ImageMatch):
533
- if self._gui_paused:
534
- return
535
-
536
- page_this_belongs_on, row_this_is = divmod(match_group.match_i, self.duplicate_group_list._max_rows)
537
- self.duplicate_group_list.update_page_indicator(self.current_page, self.last_page)
538
-
539
- if self.current_page == page_this_belongs_on + 1:
540
- self.duplicate_group_list.add_group(match_group.matches)
541
-
542
- self.set_duplicate_groups_label(len(self.processor.matches))
543
-
544
- def on_new_match_found(self, response):
545
- match_group: ImageMatch
546
- new_match: ZipPath
547
- match_group, new_match = response
548
-
549
- if self._gui_paused:
550
- return
551
-
552
- page_this_belongs_on, row_this_is = divmod(match_group.match_i, self.duplicate_group_list._max_rows)
553
-
554
- if self.current_page == page_this_belongs_on + 1:
555
- self.duplicate_group_list._rows[row_this_is].add_tile(new_match)
556
-
557
- self.set_duplicate_images_label(self.processor.duplicate_images)
558
-
559
- def set_duplicate_groups_label(self, duplicate_groups):
560
- self._dup_groups_label.setText(f"Duplicate groups....{duplicate_groups}")
561
-
562
- def set_duplicate_images_label(self, duplicate_images):
563
- self._dup_pictures_label.setText(f"Duplicate pictures..{duplicate_images}")
564
-
565
- def set_remaining_files_label(self, remaining_files):
566
- self._remaining_files_label.setText(f"Remaining files....{remaining_files}")
567
-
568
- def set_loaded_pictures_label(self, loaded_pictures):
569
- self._loaded_pictures_label.setText(f"Loaded pictures..{loaded_pictures}")
570
-
571
- def update_labels(self):
572
- if not self.processor:
573
- return
574
-
575
- if self.processor.found_images:
576
- self._progress_bar.setMaximum(self.processor.found_images)
577
- self._progress_bar.setValue(self.processor.processed_images)
578
- else:
579
- self._progress_bar.setMaximum(100)
580
- self._progress_bar.setValue(0)
581
-
582
- self.set_remaining_files_label(self.processor.left_to_process)
583
- self.set_loaded_pictures_label(self.processor.processed_images)
584
- self.set_duplicate_groups_label(len(self.processor.matches))
585
- self.set_duplicate_images_label(self.processor.duplicate_images)
586
-
587
- def on_exit(self, *_):
588
- self.close()
589
-
590
- def closeEvent(self, event: QtGui.QCloseEvent) -> None:
591
- """
592
- Intercept window close requests (titlebar X, Cmd/Ctrl+Q, File→Exit, etc.).
593
- Only prompt if data is loaded.
594
- """
595
- if self.processor:
596
- if len(self.processor.matches) and not self.confirm_close():
597
- # There IS data loaded and user said NO to exiting
598
- event.ignore()
599
- return
600
-
601
- if self.processor.running():
602
- self.processor.finish()
603
-
604
- event.accept()
605
-
606
- def on_page_down(self, *_):
607
- if self.last_page == 1:
608
- # Theres only one page....
609
- return
610
-
611
- if self.current_page == 1:
612
- self.current_page = self.last_page
613
- else:
614
- self.current_page -= 1
615
-
616
- self.update_group_list()
617
-
618
- def on_page_up(self, *_):
619
- if self.last_page == 1:
620
- # Theres only one page....
621
- return
622
-
623
- if self.current_page == self.last_page:
624
- self.current_page = 1
625
- else:
626
- self.current_page += 1
627
-
628
- self.update_group_list()
629
-
630
- def on_page_first(self):
631
- if self.current_page != 1:
632
- self.current_page = 1
633
- self.update_group_list()
634
-
635
- def on_page_last(self):
636
- last_page = self.last_page
637
- if self.current_page != last_page:
638
- self.current_page = last_page
639
- self.update_group_list()
640
-
641
- def update_group_list(self):
642
- row_count = self.duplicate_group_list._max_rows
643
- self.duplicate_group_list.set_groups(
644
- [m.matches
645
- for m in self.processor.matches[(self.current_page - 1) * row_count:self.current_page * row_count]]
646
- )
647
-
648
- for group in self.duplicate_group_list._rows:
649
- for tile in group.tiles():
650
- set_state = self.file_states.get(tile.path)
651
- if set_state:
652
- tile.state = set_state
653
-
654
- self.duplicate_group_list.update_page_indicator(self.current_page, self.last_page)
655
-
656
- def on_match_state_changed(self, path: ZipPath, state):
657
- self.file_states[path] = state
658
-
659
- for group in self.duplicate_group_list._rows:
660
- for tile in group.tiles():
661
- if tile.path == path:
662
- tile.state = state
663
-
664
- # region File Path Selection display
665
- def build_file_path_selection_display(self):
666
- # TODO: I need better icons here but I can't find the "in"/"out" icons in VP execution data...
667
-
668
- # region Selected File Path sort controls
669
- file_path_up_control = QtWidgets.QPushButton("^+")
670
- file_path_up_control.setSizePolicy(MAX_SIZE_POLICY)
671
- file_path_up_control.clicked.connect(self.file_path_up_clicked)
672
- file_path_down_control = QtWidgets.QPushButton("V-")
673
- file_path_down_control.setSizePolicy(MAX_SIZE_POLICY)
674
- file_path_down_control.clicked.connect(self.file_path_down_clicked)
675
-
676
- file_path_sort_controls = QtWidgets.QVBoxLayout()
677
- file_path_sort_controls.setContentsMargins(NO_MARGIN)
678
- file_path_sort_controls.addWidget(file_path_up_control)
679
- file_path_sort_controls.addWidget(file_path_down_control)
680
- # endregion
681
-
682
- # region Selected File Path selection controls
683
- file_path_in_control = QtWidgets.QPushButton(">+")
684
- file_path_in_control.setSizePolicy(MAX_SIZE_POLICY)
685
- file_path_in_control.clicked.connect(self.file_path_in_clicked)
686
- file_path_out_control = QtWidgets.QPushButton("<-")
687
- file_path_out_control.setSizePolicy(MAX_SIZE_POLICY)
688
- file_path_out_control.clicked.connect(self.file_path_out_clicked)
689
-
690
- file_path_io_controls = QtWidgets.QVBoxLayout()
691
- file_path_io_controls.setContentsMargins(NO_MARGIN)
692
- file_path_io_controls.addWidget(file_path_in_control)
693
- file_path_io_controls.addWidget(file_path_out_control)
694
- # endregion
695
-
696
- self.selected_file_path_display = QtWidgets.QListWidget()
697
-
698
- file_path_controls = QtWidgets.QHBoxLayout()
699
- file_path_controls.setContentsMargins(NO_MARGIN)
700
- file_path_controls.addWidget(QtWidgets.QWidget(layout=file_path_io_controls, maximumWidth=50))
701
- file_path_controls.addWidget(self.selected_file_path_display)
702
- file_path_controls.addWidget(QtWidgets.QWidget(layout=file_path_sort_controls, maximumWidth=50))
703
- return file_path_controls
704
-
705
- def file_path_up_clicked(self, _):
706
- for selected_index in self.selected_file_path_display.selectedIndexes():
707
- row = selected_index.row()
708
- if row == 0:
709
- continue
710
-
711
- item = self.selected_file_path_display.takeItem(row)
712
- self.selected_file_path_display.insertItem(row - 1, item)
713
- self.selected_file_path_display.setCurrentIndex(self.selected_file_path_display.indexFromItem(item))
714
-
715
- def file_path_down_clicked(self, _):
716
- for selected_index in self.selected_file_path_display.selectedIndexes():
717
- row = selected_index.row()
718
- if row == self.selected_file_path_display.count():
719
- continue
720
-
721
- item = self.selected_file_path_display.takeItem(row)
722
- self.selected_file_path_display.insertItem(row + 1, item)
723
- self.selected_file_path_display.setCurrentIndex(self.selected_file_path_display.indexFromItem(item))
724
-
725
- def file_path_in_clicked(self, _):
726
- selected_indexes = self.file_system_view.selectedIndexes()
727
- for index in selected_indexes:
728
- info = self.file_system_view.model().fileInfo(index)
729
- target_path = info.filePath()
730
- self.selected_file_path_display.addItem(target_path)
731
-
732
- if self.processor and self.processor.running():
733
- self.processor.add_path(target_path)
734
-
735
- def file_path_out_clicked(self, _):
736
- for selected_index in self.selected_file_path_display.selectedIndexes():
737
- selected_item = self.selected_file_path_display.takeItem(selected_index.row())
738
-
739
- if self.processor:
740
- self.processor.remove_path(selected_item.text())
741
- self.update_group_list()
742
- self.update_labels()
743
- # endregion
744
-
745
- # region Image View area (bottom-right)
746
- def build_image_view_area(self):
747
- self.preview_resized.changed.connect(self.preview_resized_changed)
748
- self.image_view_area = ImageViewPane()
749
- return self.image_view_area
750
-
751
- def preview_resized_changed(self):
752
- self.image_view_area.set_index(int(not self.preview_resized.isChecked()))
753
- # endregion
754
-
755
- def build_statusbar(self) -> None:
756
- """Creates a simple status bar for hints and counts."""
757
- sb = QtWidgets.QStatusBar()
758
- self.setStatusBar(sb)
759
- self.count_label = QtWidgets.QLabel("No groups loaded")
760
- sb.addPermanentWidget(self.count_label)
761
-
762
- def process_file_states(self, states=None):
763
- self.image_view_area.clear()
764
-
765
- if not states:
766
- states = {SelectionState.DELETE, SelectionState.IGNORE}
767
-
768
- states.add(SelectionState.KEEP)
769
-
770
- is_paused = self.processor.is_paused()
771
- if not is_paused:
772
- self.processor.pause()
773
-
774
- file_size_deleted = 0
775
- file_count_deleted = 0
776
- file_count_ignored = 0
777
- failed_file_deletes = []
778
-
779
- for file, set_state in self.file_states.items():
780
- if set_state not in states:
781
- continue
782
-
783
- if set_state == SelectionState.DELETE:
784
- # TODO: This is just for testing:
785
- logger.info(f"Deleting {file}")
786
- path = Path(file.path)
787
- try:
788
- file_size_deleted += path.stat().st_size
789
- path.unlink()
790
- except PermissionError:
791
- logger.info(f"Failed to delete {file}, it is in use!")
792
- failed_file_deletes.append(file)
793
- else:
794
- self.processor.remove(file)
795
- file_count_deleted += 1
796
- elif set_state == SelectionState.IGNORE:
797
- self.processor.remove(file)
798
- file_count_ignored += 1
799
- elif set_state == SelectionState.KEEP:
800
- pass
801
-
802
- self.file_states = {
803
- k: v
804
- for k, v in self.file_states.items()
805
- if v not in states or k in failed_file_deletes
806
- }
807
-
808
- if self.current_page > self.last_page:
809
- self.current_page = self.last_page
810
-
811
- self.update_labels()
812
- self.update_group_list()
813
- if not is_paused:
814
- self.processor.resume()
815
-
816
- popup_text = ""
817
-
818
- if file_count_deleted:
819
- popup_text += f"Deleted {file_count_deleted} files for a savings of {human_bytes(file_size_deleted)}.\n"
820
-
821
- if file_count_ignored:
822
- popup_text += f"Removed {file_count_ignored} from matching.\n"
823
-
824
- if popup_text:
825
- dlg = QtWidgets.QMessageBox(self)
826
- dlg.setWindowTitle("Result")
827
- dlg.setText(popup_text)
828
- dlg.exec()
829
-
830
- def confirm_close(self) -> bool:
831
- """
832
- Show a confirmation dialog asking if the user really wants to close.
833
-
834
- Returns:
835
- True if the user confirmed closing; False to keep the app open.
836
- """
837
- return self.exit_warning.exec() == QtWidgets.QMessageBox.StandardButton.Yes