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/__init__.py +444 -444
- pixmatch/__main__.py +48 -48
- pixmatch/utils.py +36 -36
- {pixmatch-0.0.1.dist-info → pixmatch-0.0.2.dist-info}/METADATA +93 -93
- pixmatch-0.0.2.dist-info/RECORD +8 -0
- {pixmatch-0.0.1.dist-info → pixmatch-0.0.2.dist-info}/licenses/LICENSE +18 -18
- pixmatch/gui/__init__.py +0 -837
- pixmatch/gui/pixmatch.ico +0 -0
- pixmatch/gui/utils.py +0 -13
- pixmatch/gui/widgets.py +0 -656
- pixmatch/gui/zip.png +0 -0
- pixmatch-0.0.1.dist-info/RECORD +0 -13
- {pixmatch-0.0.1.dist-info → pixmatch-0.0.2.dist-info}/WHEEL +0 -0
- {pixmatch-0.0.1.dist-info → pixmatch-0.0.2.dist-info}/top_level.txt +0 -0
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
|
pixmatch-0.0.1.dist-info/RECORD
DELETED
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|