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/pixmatch.ico DELETED
Binary file
pixmatch/gui/utils.py DELETED
@@ -1,13 +0,0 @@
1
- import os
2
-
3
- from enum import Enum, auto
4
- from typing import Dict, Iterable, List, Sequence
5
-
6
- from PIL import Image
7
- from PySide6 import QtCore, QtGui, QtWidgets
8
-
9
-
10
- NO_MARGIN = QtCore.QMargins(0, 0, 0, 0)
11
-
12
- MAX_SIZE_POLICY = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding,
13
- QtWidgets.QSizePolicy.Policy.Expanding)
pixmatch/gui/widgets.py DELETED
@@ -1,656 +0,0 @@
1
- from datetime import datetime, timezone
2
- from enum import Enum, auto
3
- from functools import cache, lru_cache
4
- from pathlib import Path
5
- from typing import Dict, Iterable, List, Sequence
6
- from zipfile import ZipFile
7
-
8
- from PySide6 import QtCore, QtGui, QtWidgets
9
-
10
- from pixmatch import ZipPath
11
- from pixmatch.gui.utils import NO_MARGIN, MAX_SIZE_POLICY
12
- from pixmatch.utils import human_bytes
13
-
14
- ZIP_ICON_PATH = Path(__file__).resolve().parent / 'zip.png'
15
-
16
-
17
- class SelectionState(Enum):
18
- """Per-thumbnail action state."""
19
- KEEP = auto()
20
- DELETE = auto()
21
- IGNORE = auto()
22
-
23
-
24
- STATE_ORDER = [SelectionState.KEEP, SelectionState.DELETE, SelectionState.IGNORE]
25
- STATE_COLORS = {
26
- SelectionState.KEEP: QtGui.QColor(80, 200, 120), # green
27
- SelectionState.DELETE: QtGui.QColor(230, 80, 80), # red
28
- SelectionState.IGNORE: QtGui.QColor(240, 190, 60), # amber
29
- }
30
-
31
-
32
- # region Image view panel
33
- def _load_pixmap(path: ZipPath, thumb_size: int) -> QtGui.QPixmap:
34
- """Load an image from disk and scale to a square thumbnail."""
35
- if path.subpath:
36
- with ZipFile(path.path) as zf:
37
- pm = QtGui.QPixmap()
38
- pm.loadFromData(zf.read(path.subpath))
39
- else:
40
- pm = QtGui.QPixmap(str(path.path))
41
-
42
- if pm.isNull():
43
- # Fallback: generate a checkerboard if load failed.
44
- pm = QtGui.QPixmap(thumb_size, thumb_size)
45
- pm.fill(QtGui.QColor("lightgray"))
46
- p = QtGui.QPainter(pm)
47
- p.setPen(QtCore.Qt.PenStyle.NoPen)
48
- c1 = QtGui.QColor(210, 210, 210)
49
- c2 = QtGui.QColor(180, 180, 180)
50
- for y in range(0, thumb_size, 16):
51
- for x in range(0, thumb_size, 16):
52
- p.setBrush(c1 if ((x // 16 + y // 16) % 2 == 0) else c2)
53
- p.drawRect(x, y, 16, 16)
54
- p.end()
55
- return pm.scaled(thumb_size, thumb_size,
56
- QtCore.Qt.AspectRatioMode.IgnoreAspectRatio,
57
- QtCore.Qt.TransformationMode.FastTransformation)
58
-
59
-
60
- def movie_size(movie: QtGui.QMovie):
61
- movie.jumpToFrame(0)
62
- rect = QtCore.QRect()
63
- for i in range(movie.frameCount()):
64
- movie.jumpToNextFrame()
65
- rect |= movie.frameRect()
66
- width = rect.x() + rect.width()
67
- height = rect.y() + rect.height()
68
-
69
- return QtCore.QSize(width, height)
70
-
71
-
72
- def movie_uncompressed_filesize(movie: QtGui.QMovie):
73
- file_size = 0
74
- for i in range(movie.frameCount()):
75
- movie.jumpToNextFrame()
76
- img = movie.currentImage()
77
- file_size += img.sizeInBytes()
78
- return file_size
79
-
80
-
81
- class ImageViewPane(QtWidgets.QWidget):
82
- """Container with a stacked image viewer and a bottom overlay status label."""
83
- def __init__(self, parent=None):
84
- super().__init__(parent)
85
-
86
- # --- viewers ---
87
- self.current_path = None
88
- self._buffer = self._qbytearray = None
89
- self.scaled = ScaledLabel(contentsMargins=NO_MARGIN, sizePolicy=MAX_SIZE_POLICY)
90
- self.scaled.setMinimumSize(10, 10)
91
-
92
- self.raw_label = QtWidgets.QLabel()
93
- self.raw_label.setContentsMargins(NO_MARGIN)
94
- self.raw_label.setMargin(0)
95
- self.scroll = QtWidgets.QScrollArea()
96
- self.scroll.setContentsMargins(NO_MARGIN)
97
- self.scroll.setSizePolicy(MAX_SIZE_POLICY)
98
- self.scroll.setWidget(self.raw_label)
99
-
100
- # Only one visible at a time -> use a stack
101
- self.stack = QtWidgets.QStackedWidget()
102
- self.stack.addWidget(self.scaled) # index 0
103
- self.stack.addWidget(self.scroll) # index 1
104
-
105
- # --- overlay status label ---
106
- self.status = QtWidgets.QLabel(text="Ready", alignment=QtCore.Qt.AlignmentFlag.AlignBottom)
107
- self.status.setContentsMargins(NO_MARGIN)
108
- self.status.setObjectName("imageStatus")
109
- self.status.setMaximumHeight(16)
110
- self.status.setStyleSheet("""
111
- QLabel#imageStatus {
112
- font-weight: bold;
113
- }
114
- """)
115
-
116
- lay = QtWidgets.QVBoxLayout(self)
117
- lay.setContentsMargins(NO_MARGIN)
118
- lay.addWidget(self.stack)
119
- lay.addWidget(self.status)
120
-
121
- # Optional helper you can call to update the text
122
- def set_status(self, text: str):
123
- self.status.setText(text)
124
-
125
- def set_index(self, index: int):
126
- if index not in (0, 1):
127
- raise ValueError('Valid index must be 0 or 1 for the image pane to select!')
128
-
129
- self.stack.setCurrentIndex(index)
130
-
131
- if self.current_path:
132
- self.set_image(self.current_path)
133
-
134
- self.update()
135
-
136
- def clear(self):
137
- """Clear and reset the current object, and the two sub-objects"""
138
- existing_movie = self.raw_label.movie()
139
- if existing_movie:
140
- existing_movie.stop()
141
- existing_movie.deleteLater()
142
- self.raw_label.clear()
143
-
144
- self.scaled.clear()
145
-
146
- @lru_cache(maxsize=5)
147
- def get_movie(self, path: ZipPath):
148
- file_size = modified = None
149
- # We're setting a movie...
150
- if path.subpath:
151
- # Need to load movie from a zipfile
152
- with ZipFile(path.path) as zf:
153
- st = zf.getinfo(path.subpath)
154
- modified = st.date_time
155
- file_size = st.file_size
156
- self._qbytearray = QtCore.QByteArray(zf.read(path.subpath))
157
- self._buffer = QtCore.QBuffer(self._qbytearray)
158
- self._buffer.open(QtCore.QIODevice.OpenModeFlag.ReadOnly)
159
-
160
- movie = QtGui.QMovie()
161
- movie.setDevice(self._buffer)
162
- else:
163
- # Basic movie path
164
- movie = QtGui.QMovie(str(path.path))
165
-
166
- return movie, file_size, modified
167
-
168
- @lru_cache(maxsize=10)
169
- def get_pixmap(self, path: ZipPath):
170
- file_size = modified = None
171
- # We're setting an image...
172
- if path.subpath:
173
- # Need to load image from a zipfile
174
- with ZipFile(path.path) as zf:
175
- st = zf.getinfo(path.subpath)
176
- modified = st.date_time
177
- file_size = st.file_size
178
- pixmap = QtGui.QPixmap()
179
- pixmap.loadFromData(zf.read(path.subpath))
180
- else:
181
- # Basic image path
182
- pixmap = QtGui.QPixmap(str(path.path))
183
-
184
- return pixmap, file_size, modified
185
-
186
- def set_image(self, path: ZipPath):
187
- if path == self.current_path:
188
- return
189
-
190
- self.current_path = path
191
- file_size = modified = None
192
- extra = ''
193
- self.clear()
194
- if path.is_gif:
195
- movie, file_size, modified = self.get_movie(path)
196
- object_size = movie_size(movie)
197
-
198
- if self.stack.currentIndex() == 0:
199
- self.scaled.setMovie(movie)
200
- else:
201
- self.raw_label.setMovie(movie)
202
-
203
- uncompressed_size = movie_uncompressed_filesize(movie)
204
-
205
- # WEBP files which aren't animated will appear as movies with a single frame
206
- # Thats boring. For the purposes of the statusbar, just treat them as images
207
- # TODO: Gee, that makes me wonder...
208
- # could any image be treated as a movie and we could do away with this whole pixmap or movie thing?
209
- # Worth investigating when I have more time...
210
- if movie.frameCount() > 1:
211
- extra = f'({human_bytes(uncompressed_size)}, {movie.frameCount()}) '
212
- else:
213
- extra = f'({human_bytes(uncompressed_size)}) '
214
- movie.start()
215
- else:
216
- pixmap, file_size, modified = self.get_pixmap(path)
217
- extra = f'({human_bytes(pixmap.toImage().sizeInBytes())}) '
218
- object_size = pixmap.size()
219
-
220
- if self.stack.currentIndex() == 0:
221
- self.scaled.setPixmap(pixmap)
222
- else:
223
- self.raw_label.setPixmap(pixmap)
224
-
225
- if self.stack.currentIndex() == 1:
226
- self.raw_label.resize(object_size)
227
-
228
- self.update()
229
-
230
- # region Update status text
231
- if not path.subpath:
232
- path = Path(path.path)
233
- st = path.stat()
234
- modified = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).strftime('%m/%d/%Y')
235
- file_size = st.st_size
236
- elif modified:
237
- modified = f"{modified[1]}/{modified[2]}/{modified[0]}"
238
- self.status.setText(
239
- f"{path.absolute()} ("
240
- f"{human_bytes(file_size)} {extra}"
241
- f"- {object_size.width()},{object_size.height()}px "
242
- f"- {modified}"
243
- f")"
244
- )
245
- # endregion
246
-
247
-
248
- class ScaledLabel(QtWidgets.QLabel):
249
- """
250
- A version of the above ScaledLabel but for gifs/movies
251
-
252
- https://stackoverflow.com/questions/72188903
253
- https://stackoverflow.com/questions/77602181
254
- """
255
- def __init__(self, *args, **kwargs):
256
- super().__init__(*args, **kwargs)
257
- self._movieSize = QtCore.QSize()
258
- self._minSize = QtCore.QSize()
259
- self.object_size = QtCore.QSize()
260
- self.orig_pixmap = self.pixmap()
261
- self.orig_movie = self.movie()
262
-
263
- def clear(self):
264
- super().clear()
265
- self.orig_pixmap = None
266
- if self.orig_movie:
267
- self.orig_movie.device().close()
268
- self.orig_movie.stop()
269
- self.orig_movie.deleteLater()
270
- self.orig_movie = None
271
-
272
- def minimumSizeHint(self):
273
- if self._minSize.isValid():
274
- return self._minSize
275
- return super().minimumSizeHint()
276
-
277
- def setPixmap(self, pixmap): # overiding setPixmap
278
- if not pixmap:
279
- return
280
- self.clear()
281
- self.orig_pixmap = pixmap
282
- return super().setPixmap(self.orig_pixmap.scaled(self.frameSize(), QtCore.Qt.AspectRatioMode.KeepAspectRatio))
283
-
284
- def setMovie(self, movie):
285
- if self.movie() == movie:
286
- return
287
- if self.orig_movie and movie:
288
- self.clear()
289
- super().setMovie(movie)
290
- self.orig_movie = movie
291
-
292
- if not isinstance(movie, QtGui.QMovie) or not movie.isValid():
293
- self._movieSize = QtCore.QSize()
294
- self._minSize = QtCore.QSize()
295
- self.updateGeometry()
296
- return
297
-
298
- cf = movie.currentFrameNumber()
299
- movie.jumpToFrame(0)
300
- self._movieSize = movie_size(movie)
301
- width = self._movieSize.width()
302
- height = self._movieSize.height()
303
-
304
- minimum = min(width, height)
305
- maximum = max(width, height)
306
- ratio = maximum / minimum
307
- base = min(4, minimum)
308
- self._minSize = QtCore.QSize(base, round(base * ratio))
309
- if minimum == width:
310
- self._minSize.transpose()
311
-
312
- movie.jumpToFrame(cf)
313
- self.updateGeometry()
314
-
315
- def paintEvent(self, event):
316
- movie = self.movie()
317
- if not isinstance(movie, QtGui.QMovie) or not movie.isValid():
318
- super().paintEvent(event)
319
- if self.orig_pixmap:
320
- self.setPixmap(self.orig_pixmap)
321
- return
322
-
323
- qp = QtGui.QPainter(self)
324
- self.drawFrame(qp)
325
-
326
- cr = self.contentsRect()
327
- margin = self.margin()
328
- cr.adjust(margin, margin, -margin, -margin)
329
-
330
- style = self.style()
331
- alignment = style.visualAlignment(self.layoutDirection(), self.alignment())
332
- maybeSize = self._movieSize.scaled(cr.size(), QtCore.Qt.AspectRatioMode.KeepAspectRatio)
333
-
334
- if maybeSize != movie.scaledSize():
335
- movie.setScaledSize(maybeSize)
336
- style.drawItemPixmap(
337
- qp, cr, alignment,
338
- movie.currentPixmap().scaled(cr.size(), QtCore.Qt.AspectRatioMode.KeepAspectRatio)
339
- )
340
-
341
- else:
342
- style.drawItemPixmap(
343
- qp, cr, alignment,
344
- movie.currentPixmap()
345
- )
346
- # endregion
347
-
348
-
349
- # region Thumbnail tile panel
350
- @cache
351
- def get_overlay_icon(height, width):
352
- return QtGui.QPixmap(ZIP_ICON_PATH).scaled(
353
- int(height), int(width),
354
- QtCore.Qt.AspectRatioMode.KeepAspectRatio,
355
- QtCore.Qt.TransformationMode.FastTransformation,
356
- )
357
-
358
-
359
- class ThumbnailTile(QtWidgets.QFrame):
360
- """
361
- Clickable thumbnail tile that cycles between KEEP → DELETE → IGNORE.
362
-
363
- Attributes:
364
- path: Image path (opaque identifier for the caller).
365
- stateChanged(path: str, state: SelectionState): Emitted on state updates.
366
- """
367
- stateChanged = QtCore.Signal(ZipPath, SelectionState)
368
- hovered = QtCore.Signal(ZipPath)
369
-
370
- def __init__(self, path: ZipPath, pixmap: QtGui.QPixmap, thumb_size: int = 32, parent=None):
371
- super().__init__(parent, frameShape=QtWidgets.QFrame.Shape.Box, lineWidth=2)
372
- self.setObjectName("ThumbnailTile")
373
- self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
374
-
375
- self._path = path
376
- self._state = SelectionState.KEEP
377
- self._thumb_size = thumb_size
378
-
379
- self._image = QtWidgets.QLabel(alignment=QtCore.Qt.AlignmentFlag.AlignCenter, pixmap=pixmap)
380
- self._image.setFixedSize(thumb_size, thumb_size)
381
-
382
- lay = QtWidgets.QVBoxLayout(self)
383
- lay.setContentsMargins(NO_MARGIN)
384
- lay.setSpacing(0)
385
- lay.addWidget(self._image)
386
-
387
- if path.subpath:
388
- _overlay_icon = QtWidgets.QLabel(self._image) # child of the tile so it floats over the image
389
- _overlay_icon.setObjectName("LockOverlay")
390
- _overlay_icon.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
391
- _overlay_icon.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
392
- _overlay_icon.setFixedSize(thumb_size, thumb_size) # small badge; adjust later if you want
393
- _overlay_icon.setPixmap(get_overlay_icon(thumb_size / 1.5, thumb_size / 1.5))
394
-
395
- self.context_menu = QtWidgets.QMenu(self)
396
-
397
- act_delete = self.context_menu.addAction("Delete")
398
- act_ignore = self.context_menu.addAction("Ignore")
399
- act_ignore_group = self.context_menu.addAction("Ignore group")
400
- self.context_menu.addSeparator()
401
- act_rename = self.context_menu.addAction("Rename this file...")
402
- act_move = self.context_menu.addAction("Move this file")
403
- act_symlink = self.context_menu.addAction("Symlink this file")
404
- self.context_menu.addSeparator()
405
- act_unmark = self.context_menu.addAction("Unmark")
406
-
407
- # Enablement: only these three should work right now
408
- # If the path is from a zip (locked), disable Delete here as well.
409
- act_delete.setEnabled(not bool(self._path.subpath))
410
- act_ignore.setEnabled(True)
411
- act_unmark.setEnabled(True)
412
-
413
- # Everything else disabled for now
414
- act_ignore_group.setEnabled(False)
415
- act_rename.setEnabled(False)
416
- act_move.setEnabled(False)
417
- act_symlink.setEnabled(False)
418
-
419
- # Wire up state changes
420
- act_delete.triggered.connect(lambda _=False: setattr(self, "state", SelectionState.DELETE))
421
- act_ignore.triggered.connect(lambda _=False: setattr(self, "state", SelectionState.IGNORE))
422
- act_unmark.triggered.connect(lambda _=False: setattr(self, "state", SelectionState.KEEP))
423
-
424
- self._apply_state_style()
425
-
426
- @property
427
- def path(self) -> ZipPath:
428
- return self._path
429
-
430
- @property
431
- def state(self) -> SelectionState:
432
- return self._state
433
-
434
- @state.setter
435
- def state(self, state: SelectionState) -> None:
436
- """Set the tile selection state without cycling."""
437
- if self._state is state:
438
- return
439
- self._state = state
440
- self._apply_state_style()
441
- self.stateChanged.emit(self._path, self._state)
442
-
443
- def cycle_state(self) -> None:
444
- """Advance KEEP → DELETE → IGNORE → KEEP."""
445
- idx = STATE_ORDER.index(self._state)
446
- locked = bool(self._path.subpath)
447
- next_state = STATE_ORDER[(idx + 1) % len(STATE_ORDER)]
448
- if locked and next_state == SelectionState.DELETE:
449
- next_state = STATE_ORDER[(idx + 2) % len(STATE_ORDER)]
450
- self.state = next_state
451
-
452
- def mousePressEvent(self, e: QtGui.QMouseEvent) -> None:
453
- if e.button() == QtCore.Qt.MouseButton.LeftButton:
454
- self.cycle_state()
455
- e.accept()
456
- else:
457
- super().mousePressEvent(e)
458
-
459
- def enterEvent(self, e: QtGui.QEnterEvent) -> None:
460
- # fire when the cursor enters the tile
461
- self.hovered.emit(self._path)
462
- super().enterEvent(e)
463
-
464
- def _apply_state_style(self) -> None:
465
- color = STATE_COLORS[self._state]
466
- self.setStyleSheet(
467
- f"""
468
- QFrame#ThumbnailTile {{
469
- border: 2px solid rgba({color.red()}, {color.green()}, {color.blue()}, 220);
470
- border-radius: 6px;
471
- background: #202020;
472
- }}
473
- QLabel#StateBadge {{
474
- color: black;
475
- background: rgba({color.red()}, {color.green()}, {color.blue()}, 220);
476
- border-radius: 6px;
477
- font-weight: 600;
478
- }}
479
- """
480
- )
481
-
482
- def contextMenuEvent(self, event: QtGui.QContextMenuEvent) -> None:
483
- self.context_menu.exec(event.globalPos())
484
- event.accept()
485
-
486
-
487
- class DuplicateGroupRow(QtWidgets.QWidget):
488
- """
489
- A single row of thumbnails representing one duplicate group.
490
-
491
- Signals:
492
- tileStateChanged(path: str, state: SelectionState)
493
- """
494
- tileStateChanged = QtCore.Signal(ZipPath, SelectionState)
495
- tileHovered = QtCore.Signal(ZipPath)
496
-
497
- def __init__(self, images: Sequence[ZipPath], thumb_size: int = 32, parent=None):
498
- super().__init__(parent)
499
- self._tiles: List[ThumbnailTile] = []
500
- self._thumb_size = thumb_size
501
- self.layout = QtWidgets.QHBoxLayout(self)
502
- self.layout.setContentsMargins(NO_MARGIN)
503
- self.layout.setSpacing(0)
504
-
505
- for path in images:
506
- self.add_tile(path)
507
-
508
- self.layout.addStretch(1)
509
-
510
- def tiles(self) -> Iterable[ThumbnailTile]:
511
- return list(self._tiles)
512
-
513
- def add_tile(self, path: ZipPath):
514
- pm = _load_pixmap(path, self._thumb_size)
515
- tile = ThumbnailTile(path=path, pixmap=pm, thumb_size=self._thumb_size)
516
- tile.stateChanged.connect(self.tileStateChanged)
517
- tile.hovered.connect(self.tileHovered)
518
- self._tiles.append(tile)
519
- self.layout.insertWidget(len(self._tiles) - 1, tile)
520
-
521
-
522
- class DuplicateGroupList(QtWidgets.QWidget):
523
- """
524
- Scrollable list of duplicate groups. Each group renders as a row of thumbnails.
525
-
526
- Public API:
527
- set_groups(groups): Load groups; each group is a list of image paths.
528
- decisions(): Dict[path, SelectionState] for all tiles.
529
- set_max_rows(n): Limit how many groups to show (default 25).
530
- set_thumb_size(px): Set square thumbnail size (default 128).
531
- reset_states(): Set all tiles to KEEP.
532
-
533
- Notes:
534
- - Clicking a thumbnail cycles KEEP → DELETE → IGNORE.
535
- - Borders/badges are colored by state.
536
- """
537
-
538
- groupTileStateChanged = QtCore.Signal(ZipPath, SelectionState) # path, state
539
- groupTileHovered = QtCore.Signal(ZipPath)
540
-
541
- def __init__(self, parent=None, *, max_rows: int = 25, thumb_size: int = 64, **kwargs):
542
- super().__init__(parent, **kwargs)
543
- self._max_rows = max_rows
544
- self._thumb_size = thumb_size
545
-
546
- self._scroll = QtWidgets.QScrollArea(widgetResizable=True)
547
- self._container = QtWidgets.QWidget()
548
- self._vbox = QtWidgets.QVBoxLayout(self._container)
549
- self._vbox.setContentsMargins(NO_MARGIN)
550
- self._vbox.setSpacing(0)
551
- _tail_spacer = QtWidgets.QSpacerItem(
552
- 0, 0, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding
553
- )
554
- self._vbox.addItem(_tail_spacer)
555
- self._scroll.setWidget(self._container)
556
-
557
- # Header with quick-actions.
558
- # self._header = QtWidgets.QHBoxLayout(contentsMargins=NO_MARGIN)
559
- # self._btn_keep_all = QtWidgets.QPushButton("Mark All Keep", contentsMargins=NO_MARGIN)
560
- # self._btn_delete_all = QtWidgets.QPushButton("Mark All Delete", contentsMargins=NO_MARGIN)
561
- # self._btn_ignore_all = QtWidgets.QPushButton("Mark All Ignore", contentsMargins=NO_MARGIN)
562
- # self._header.addWidget(self._btn_keep_all)
563
- # self._header.addWidget(self._btn_delete_all)
564
- # self._header.addWidget(self._btn_ignore_all)
565
- # self._header.addStretch(1)
566
-
567
- # self._btn_keep_all.clicked.connect(lambda: self._bulk_set(SelectionState.KEEP))
568
- # self._btn_delete_all.clicked.connect(lambda: self._bulk_set(SelectionState.DELETE))
569
- # self._btn_ignore_all.clicked.connect(lambda: self._bulk_set(SelectionState.IGNORE))
570
-
571
- # Main layout
572
- outer = QtWidgets.QVBoxLayout(self)
573
- outer.setContentsMargins(NO_MARGIN)
574
- outer.setSpacing(0)
575
- # self._outer.addLayout(self._header)
576
- outer.addWidget(self._scroll)
577
-
578
- self._rows: List[DuplicateGroupRow] = []
579
-
580
- # Status bar
581
- _status = QtWidgets.QHBoxLayout()
582
- self.first_page = QtWidgets.QPushButton("<<")
583
- self.page_down = QtWidgets.QPushButton("<")
584
- self.page_indicator = QtWidgets.QLabel(alignment=QtCore.Qt.AlignmentFlag.AlignCenter)
585
- self.page_up = QtWidgets.QPushButton(">")
586
- self.last_page = QtWidgets.QPushButton(">>")
587
- _status.addWidget(self.first_page)
588
- _status.addWidget(self.page_down)
589
- _status.addWidget(self.page_indicator)
590
- _status.addWidget(self.page_up)
591
- _status.addWidget(self.last_page)
592
- outer.addLayout(_status)
593
-
594
- self.update_page_indicator(1, 1)
595
-
596
- def set_max_rows(self, n: int) -> None:
597
- """Set maximum visible rows (groups)."""
598
- self._max_rows = max(1, int(n))
599
-
600
- def set_thumb_size(self, px: int) -> None:
601
- """Set square thumbnail size for subsequent loads."""
602
- self._thumb_size = max(32, int(px))
603
-
604
- def set_groups(self, groups: Sequence[Sequence[ZipPath]]) -> None:
605
- """
606
- Load duplicate groups.
607
-
608
- Args:
609
- groups: An iterable of groups; each group is an iterable of image file paths.
610
- Only the first `max_rows` groups are shown.
611
- """
612
- self._clear_rows()
613
- for group in groups[: self._max_rows]:
614
- self.add_group(group)
615
-
616
- def update_page_indicator(self, current_page, total_pages):
617
- self.page_indicator.setText(f"Page {current_page} of {total_pages}")
618
-
619
- def add_group(self, group: Sequence[ZipPath]) -> None:
620
- if len(self._rows) == self._max_rows:
621
- raise ValueError("Cannot add a new group to a fully filled group list!")
622
-
623
- row = DuplicateGroupRow(group, thumb_size=self._thumb_size)
624
- row.tileStateChanged.connect(self.groupTileStateChanged)
625
- row.tileHovered.connect(self.groupTileHovered)
626
- tail_index = self._vbox.count() - 1
627
- self._vbox.insertWidget(tail_index, row)
628
- self._rows.append(row)
629
-
630
- def decisions(self) -> Dict[ZipPath, SelectionState]:
631
- """Collect {path: state} for all tiles across all rows."""
632
- out: Dict[ZipPath, SelectionState] = {}
633
- for row in self._rows:
634
- for tile in row.tiles():
635
- out[tile.path] = tile.state
636
- return out
637
-
638
- def reset_states(self) -> None:
639
- """Set all tiles to KEEP."""
640
- for row in self._rows:
641
- for tile in row.tiles():
642
- tile.state = SelectionState.KEEP
643
-
644
- def _clear_rows(self) -> None:
645
- for row in self._rows:
646
- row.setParent(None)
647
- row.deleteLater()
648
- self._rows.clear()
649
- # endregion
650
-
651
-
652
- class DirFileSystemModel(QtWidgets.QFileSystemModel):
653
- def hasChildren(self, /, parent: QtCore.QModelIndex | QtCore.QPersistentModelIndex = ...):
654
- file_info = self.fileInfo(parent)
655
- _dir = QtCore.QDir(file_info.absoluteFilePath())
656
- return bool(_dir.entryList(self.filter()))
pixmatch/gui/zip.png DELETED
Binary file
@@ -1,13 +0,0 @@
1
- pixmatch/__init__.py,sha256=XgI-Yn2-WEL7QW3B7Y5K_hCb_crU8tx4UomHwjgxSx4,16052
2
- pixmatch/__main__.py,sha256=z1jYOYCNnUZjOk7dYU3wT8vnCWgKFt6ZjB1G_gFoQgE,1595
3
- pixmatch/utils.py,sha256=fNVsrdH5LOlG5YLNS_ZLKZwFECvSepO2zUalaaQhTWE,1126
4
- pixmatch/gui/__init__.py,sha256=8ohabKr7LsE0D93oysBprDdh64M5VIBII3uDK8jfMMg,33692
5
- pixmatch/gui/pixmatch.ico,sha256=thwmOksh7_3PJIVJTQbgxuDRrKLEqHt2bNNZ-JJBVGw,18850
6
- pixmatch/gui/utils.py,sha256=3okdvCXmfk4JEN3kxp8Kw2bTP0zrvfFJD8gzXNpVkGE,373
7
- pixmatch/gui/widgets.py,sha256=sh8AjNmPud4vQmRiiodD4yx--45AWtXQoh6VXpnIx-w,24469
8
- pixmatch/gui/zip.png,sha256=yLn744kmES_jS0QDs5QpDuoAgCQYN1X1MhuJMFvv-58,2326
9
- pixmatch-0.0.1.dist-info/licenses/LICENSE,sha256=6kbiFSfobTZ7beWiKnHpN902HgBx-Jzgcme0SvKqhKY,1091
10
- pixmatch-0.0.1.dist-info/METADATA,sha256=YO--mcMrsgQZNC3KvHpT4JwFU-gA09YNMRrro2qTDM8,3633
11
- pixmatch-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- pixmatch-0.0.1.dist-info/top_level.txt,sha256=u-67zafU4VFT-oIM4mdGvf9KrHZvD64QjjtNzVxBj7E,9
13
- pixmatch-0.0.1.dist-info/RECORD,,