pygpt-net 2.6.32__py3-none-any.whl → 2.6.33__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.
- pygpt_net/CHANGELOG.txt +5 -0
- pygpt_net/__init__.py +1 -1
- pygpt_net/controller/chat/attachment.py +2 -0
- pygpt_net/controller/painter/common.py +10 -11
- pygpt_net/controller/painter/painter.py +4 -12
- pygpt_net/core/camera/camera.py +369 -53
- pygpt_net/data/config/config.json +233 -220
- pygpt_net/data/config/models.json +179 -180
- pygpt_net/data/locale/locale.de.ini +1 -0
- pygpt_net/data/locale/locale.en.ini +1 -0
- pygpt_net/data/locale/locale.es.ini +1 -0
- pygpt_net/data/locale/locale.fr.ini +1 -0
- pygpt_net/data/locale/locale.it.ini +1 -0
- pygpt_net/data/locale/locale.pl.ini +1 -0
- pygpt_net/data/locale/locale.uk.ini +1 -0
- pygpt_net/data/locale/locale.zh.ini +1 -0
- pygpt_net/ui/widget/draw/painter.py +452 -84
- {pygpt_net-2.6.32.dist-info → pygpt_net-2.6.33.dist-info}/METADATA +7 -2
- {pygpt_net-2.6.32.dist-info → pygpt_net-2.6.33.dist-info}/RECORD +22 -22
- {pygpt_net-2.6.32.dist-info → pygpt_net-2.6.33.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.32.dist-info → pygpt_net-2.6.33.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.32.dist-info → pygpt_net-2.6.33.dist-info}/entry_points.txt +0 -0
|
@@ -1,19 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
# ================================================== #
|
|
4
|
-
# This file is a part of PYGPT package #
|
|
5
|
-
# Website: https://pygpt.net #
|
|
6
|
-
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
|
-
# MIT License #
|
|
8
|
-
# Created By : Marcin Szczygliński #
|
|
9
|
-
# Updated Date: 2025.08.24 23:00:00 #
|
|
10
|
-
# ================================================== #
|
|
1
|
+
# ui/widget.painer.py
|
|
11
2
|
|
|
12
3
|
import datetime
|
|
13
4
|
from collections import deque
|
|
14
5
|
|
|
15
|
-
from PySide6.QtCore import Qt, QPoint
|
|
16
|
-
from PySide6.QtGui import QImage, QPainter, QPen, QAction, QIcon
|
|
6
|
+
from PySide6.QtCore import Qt, QPoint, QRect, QSize
|
|
7
|
+
from PySide6.QtGui import QImage, QPainter, QPen, QAction, QIcon, QColor, QCursor
|
|
17
8
|
from PySide6.QtWidgets import QMenu, QWidget, QFileDialog, QMessageBox, QApplication
|
|
18
9
|
|
|
19
10
|
from pygpt_net.core.tabs.tab import Tab
|
|
@@ -24,24 +15,53 @@ class PainterWidget(QWidget):
|
|
|
24
15
|
def __init__(self, window=None):
|
|
25
16
|
super().__init__(window)
|
|
26
17
|
self.window = window
|
|
18
|
+
|
|
19
|
+
# Final composited image (canvas-sized). Kept for API compatibility.
|
|
27
20
|
self.image = QImage(self.size(), QImage.Format_RGB32)
|
|
28
|
-
|
|
21
|
+
|
|
22
|
+
# Layered model:
|
|
23
|
+
# - sourceImageOriginal: original background image (full quality, not canvas-sized).
|
|
24
|
+
# - baseCanvas: canvas-sized background with the source scaled to fit (letterboxed).
|
|
25
|
+
# - drawingLayer: canvas-sized transparent layer for strokes.
|
|
26
|
+
self.sourceImageOriginal = None
|
|
27
|
+
self.baseCanvas = None
|
|
28
|
+
self.baseTargetRect = QRect()
|
|
29
|
+
self.drawingLayer = None
|
|
30
|
+
|
|
31
|
+
# Drawing state
|
|
29
32
|
self.drawing = False
|
|
33
|
+
self._mouseDown = False
|
|
30
34
|
self.brushSize = 3
|
|
31
35
|
self.brushColor = Qt.black
|
|
36
|
+
self._mode = "brush" # "brush" or "erase"
|
|
32
37
|
self.lastPoint = QPoint()
|
|
33
|
-
self.
|
|
38
|
+
self._pen = QPen(self.brushColor, self.brushSize, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
|
|
39
|
+
|
|
40
|
+
# Crop tool state
|
|
41
|
+
self.cropping = False
|
|
42
|
+
self._selecting = False
|
|
43
|
+
self._selectionStart = QPoint()
|
|
44
|
+
self._selectionRect = QRect()
|
|
45
|
+
|
|
46
|
+
# Undo/redo: store full layered state to support non-destructive operations
|
|
34
47
|
self.undoLimit = 10
|
|
35
48
|
self.undoStack = deque(maxlen=self.undoLimit)
|
|
36
49
|
self.redoStack = deque()
|
|
50
|
+
|
|
51
|
+
self.originalImage = None # kept for API compatibility; reflects current composited image
|
|
37
52
|
self.setFocusPolicy(Qt.StrongFocus)
|
|
38
53
|
self.setFocus()
|
|
39
54
|
self.installEventFilter(self)
|
|
40
55
|
self.tab = None
|
|
41
|
-
|
|
56
|
+
|
|
42
57
|
self.setAttribute(Qt.WA_OpaquePaintEvent, True)
|
|
43
58
|
self.setAttribute(Qt.WA_StaticContents, True)
|
|
44
59
|
|
|
60
|
+
# Internal flags
|
|
61
|
+
self._pendingResizeApply = None # payload used after crop to apply exact pixels on resize
|
|
62
|
+
self._ignoreResizeOnce = False # guard to prevent recursive work in resize path
|
|
63
|
+
|
|
64
|
+
# Actions
|
|
45
65
|
self._act_undo = QAction(QIcon(":/icons/undo.svg"), trans('action.undo'), self)
|
|
46
66
|
self._act_undo.triggered.connect(self.undo)
|
|
47
67
|
|
|
@@ -66,9 +86,17 @@ class PainterWidget(QWidget):
|
|
|
66
86
|
self._act_clear = QAction(QIcon(":/icons/close.svg"), trans('painter.btn.clear'), self)
|
|
67
87
|
self._act_clear.triggered.connect(self.action_clear)
|
|
68
88
|
|
|
89
|
+
# Crop action (also add this QAction to your top toolbar if desired)
|
|
90
|
+
self._act_crop = QAction(QIcon(":/icons/crop.svg"), trans('painter.btn.crop') if trans('painter.btn.crop') else "Crop", self)
|
|
91
|
+
self._act_crop.triggered.connect(self.start_crop)
|
|
92
|
+
|
|
93
|
+
# Context menu
|
|
69
94
|
self._ctx_menu = QMenu(self)
|
|
70
95
|
self._ctx_menu.addAction(self._act_undo)
|
|
71
96
|
self._ctx_menu.addAction(self._act_redo)
|
|
97
|
+
self._ctx_menu.addSeparator()
|
|
98
|
+
self._ctx_menu.addAction(self._act_crop)
|
|
99
|
+
self._ctx_menu.addSeparator()
|
|
72
100
|
self._ctx_menu.addAction(self._act_open)
|
|
73
101
|
self._ctx_menu.addAction(self._act_capture)
|
|
74
102
|
self._ctx_menu.addAction(self._act_copy)
|
|
@@ -84,6 +112,111 @@ class PainterWidget(QWidget):
|
|
|
84
112
|
"""
|
|
85
113
|
self.tab = tab
|
|
86
114
|
|
|
115
|
+
# ---------- Layer & composition helpers ----------
|
|
116
|
+
|
|
117
|
+
def _ensure_layers(self):
|
|
118
|
+
"""Ensure baseCanvas, drawingLayer, and image are allocated to current canvas size."""
|
|
119
|
+
sz = self.size()
|
|
120
|
+
if sz.width() <= 0 or sz.height() <= 0:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if self.baseCanvas is None or self.baseCanvas.size() != sz:
|
|
124
|
+
self.baseCanvas = QImage(sz, QImage.Format_RGB32)
|
|
125
|
+
self.baseCanvas.fill(Qt.white)
|
|
126
|
+
|
|
127
|
+
if self.drawingLayer is None or self.drawingLayer.size() != sz:
|
|
128
|
+
self.drawingLayer = QImage(sz, QImage.Format_ARGB32_Premultiplied)
|
|
129
|
+
self.drawingLayer.fill(Qt.transparent)
|
|
130
|
+
|
|
131
|
+
if self.image.size() != sz:
|
|
132
|
+
self.image = QImage(sz, QImage.Format_RGB32)
|
|
133
|
+
self.image.fill(Qt.white)
|
|
134
|
+
|
|
135
|
+
def _rescale_base_from_source(self):
|
|
136
|
+
"""
|
|
137
|
+
Rebuild baseCanvas from sourceImageOriginal to fit current canvas, preserving aspect ratio.
|
|
138
|
+
"""
|
|
139
|
+
self._ensure_layers()
|
|
140
|
+
self.baseCanvas.fill(Qt.white)
|
|
141
|
+
self.baseTargetRect = QRect()
|
|
142
|
+
if self.sourceImageOriginal is None or self.sourceImageOriginal.isNull():
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
canvas_size = self.size()
|
|
146
|
+
src = self.sourceImageOriginal
|
|
147
|
+
# Compute scaled size that fits within the canvas (max width/height)
|
|
148
|
+
scaled_size = src.size().scaled(canvas_size, Qt.KeepAspectRatio)
|
|
149
|
+
# Center the image within the canvas
|
|
150
|
+
x = (canvas_size.width() - scaled_size.width()) // 2
|
|
151
|
+
y = (canvas_size.height() - scaled_size.height()) // 2
|
|
152
|
+
self.baseTargetRect = QRect(x, y, scaled_size.width(), scaled_size.height())
|
|
153
|
+
|
|
154
|
+
p = QPainter(self.baseCanvas)
|
|
155
|
+
p.setRenderHint(QPainter.SmoothPixmapTransform, True)
|
|
156
|
+
p.drawImage(self.baseTargetRect, src)
|
|
157
|
+
p.end()
|
|
158
|
+
|
|
159
|
+
def _recompose(self):
|
|
160
|
+
"""Compose final canvas image from baseCanvas + drawingLayer."""
|
|
161
|
+
self._ensure_layers()
|
|
162
|
+
self.image.fill(Qt.white)
|
|
163
|
+
p = QPainter(self.image)
|
|
164
|
+
# draw background
|
|
165
|
+
p.drawImage(QPoint(0, 0), self.baseCanvas)
|
|
166
|
+
# draw drawing layer
|
|
167
|
+
p.setCompositionMode(QPainter.CompositionMode_SourceOver)
|
|
168
|
+
p.drawImage(QPoint(0, 0), self.drawingLayer)
|
|
169
|
+
p.end()
|
|
170
|
+
self.originalImage = self.image
|
|
171
|
+
|
|
172
|
+
def _snapshot_state(self):
|
|
173
|
+
"""Create a full snapshot for undo."""
|
|
174
|
+
state = {
|
|
175
|
+
'image': QImage(self.image),
|
|
176
|
+
'base': QImage(self.baseCanvas) if self.baseCanvas is not None else None,
|
|
177
|
+
'draw': QImage(self.drawingLayer) if self.drawingLayer is not None else None,
|
|
178
|
+
'src': QImage(self.sourceImageOriginal) if self.sourceImageOriginal is not None else None,
|
|
179
|
+
'size': QSize(self.width(), self.height()),
|
|
180
|
+
'baseRect': QRect(self.baseTargetRect),
|
|
181
|
+
}
|
|
182
|
+
return state
|
|
183
|
+
|
|
184
|
+
def _apply_state(self, state):
|
|
185
|
+
"""Apply a snapshot (used by undo/redo)."""
|
|
186
|
+
if not state:
|
|
187
|
+
return
|
|
188
|
+
self._ignoreResizeOnce = True
|
|
189
|
+
target_size = state['size']
|
|
190
|
+
|
|
191
|
+
# Set canvas size if needed
|
|
192
|
+
if target_size != self.size():
|
|
193
|
+
self.setFixedSize(target_size)
|
|
194
|
+
|
|
195
|
+
# Apply layers and image
|
|
196
|
+
self.image = QImage(state['image']) if state['image'] is not None else QImage(self.size(), QImage.Format_RGB32)
|
|
197
|
+
if self.image.size() != self.size():
|
|
198
|
+
self.image = self.image.scaled(self.size(), Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
|
|
199
|
+
|
|
200
|
+
if state['base'] is not None:
|
|
201
|
+
self.baseCanvas = QImage(state['base'])
|
|
202
|
+
else:
|
|
203
|
+
self.baseCanvas = QImage(self.size(), QImage.Format_RGB32)
|
|
204
|
+
self.baseCanvas.fill(Qt.white)
|
|
205
|
+
|
|
206
|
+
if state['draw'] is not None:
|
|
207
|
+
self.drawingLayer = QImage(state['draw'])
|
|
208
|
+
else:
|
|
209
|
+
self.drawingLayer = QImage(self.size(), QImage.Format_ARGB32_Premultiplied)
|
|
210
|
+
self.drawingLayer.fill(Qt.transparent)
|
|
211
|
+
|
|
212
|
+
self.sourceImageOriginal = QImage(state['src']) if state['src'] is not None else None
|
|
213
|
+
self.baseTargetRect = QRect(state['baseRect']) if state['baseRect'] is not None else QRect()
|
|
214
|
+
|
|
215
|
+
self._ignoreResizeOnce = False
|
|
216
|
+
self.update()
|
|
217
|
+
|
|
218
|
+
# ---------- Public API (clipboard, file, actions) ----------
|
|
219
|
+
|
|
87
220
|
def handle_paste(self):
|
|
88
221
|
"""Handle clipboard paste"""
|
|
89
222
|
clipboard = QApplication.clipboard()
|
|
@@ -91,10 +224,13 @@ class PainterWidget(QWidget):
|
|
|
91
224
|
if source.hasImage():
|
|
92
225
|
image = clipboard.image()
|
|
93
226
|
if isinstance(image, QImage):
|
|
94
|
-
|
|
227
|
+
# paste should create custom canvas with image size
|
|
228
|
+
self.set_image(image, fit_canvas_to_image=True)
|
|
95
229
|
|
|
96
230
|
def handle_copy(self):
|
|
97
231
|
"""Handle clipboard copy"""
|
|
232
|
+
# ensure composited image is up-to-date
|
|
233
|
+
self._recompose()
|
|
98
234
|
clipboard = QApplication.clipboard()
|
|
99
235
|
clipboard.setImage(self.image)
|
|
100
236
|
|
|
@@ -131,6 +267,8 @@ class PainterWidget(QWidget):
|
|
|
131
267
|
|
|
132
268
|
def action_save(self):
|
|
133
269
|
"""Save image to file"""
|
|
270
|
+
# ensure composited image is up-to-date
|
|
271
|
+
self._recompose()
|
|
134
272
|
name = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + ".png"
|
|
135
273
|
path, _ = QFileDialog.getSaveFileName(
|
|
136
274
|
self,
|
|
@@ -153,79 +291,87 @@ class PainterWidget(QWidget):
|
|
|
153
291
|
|
|
154
292
|
:param path: Path to image
|
|
155
293
|
"""
|
|
156
|
-
self.saveForUndo()
|
|
157
294
|
img = QImage(path)
|
|
158
295
|
if img.isNull():
|
|
159
296
|
QMessageBox.information(self, "Image Loader", "Cannot load file.")
|
|
160
297
|
return
|
|
161
|
-
|
|
298
|
+
# Treat opening as loading a new original; resize canvas to image size (custom)
|
|
299
|
+
self.set_image(img, fit_canvas_to_image=True)
|
|
162
300
|
|
|
163
|
-
def
|
|
301
|
+
def load_flat_image(self, path):
|
|
302
|
+
"""
|
|
303
|
+
Load a flat image from file as current source.
|
|
304
|
+
This is used for session restore; it does not enforce canvas resize now.
|
|
164
305
|
"""
|
|
165
|
-
|
|
306
|
+
img = QImage(path)
|
|
307
|
+
if img.isNull():
|
|
308
|
+
return
|
|
309
|
+
# Do not change canvas size here; setup() will follow with change_canvas_size().
|
|
310
|
+
self.sourceImageOriginal = QImage(img)
|
|
311
|
+
# Rebuild layers for current canvas (if any size already set)
|
|
312
|
+
if self.width() > 0 and self.height() > 0:
|
|
313
|
+
self._ensure_layers()
|
|
314
|
+
self._rescale_base_from_source()
|
|
315
|
+
self.drawingLayer.fill(Qt.transparent)
|
|
316
|
+
self._recompose()
|
|
317
|
+
else:
|
|
318
|
+
# defer until resize arrives
|
|
319
|
+
pass
|
|
166
320
|
|
|
321
|
+
def set_image(self, image, fit_canvas_to_image: bool = False):
|
|
322
|
+
"""
|
|
323
|
+
Set image (as new original source)
|
|
167
324
|
:param image: Image
|
|
325
|
+
:param fit_canvas_to_image: True = set canvas size to image size (custom)
|
|
168
326
|
"""
|
|
327
|
+
if image.isNull():
|
|
328
|
+
return
|
|
169
329
|
self.saveForUndo()
|
|
170
|
-
self.
|
|
171
|
-
|
|
330
|
+
self.sourceImageOriginal = QImage(image)
|
|
331
|
+
if fit_canvas_to_image:
|
|
332
|
+
# set custom canvas size to image size
|
|
333
|
+
w, h = image.width(), image.height()
|
|
334
|
+
self.window.controller.painter.common.change_canvas_size(f"{w}x{h}")
|
|
335
|
+
else:
|
|
336
|
+
# just rebuild within current canvas
|
|
337
|
+
self._ensure_layers()
|
|
338
|
+
self._rescale_base_from_source()
|
|
339
|
+
self.drawingLayer.fill(Qt.transparent)
|
|
340
|
+
self._recompose()
|
|
172
341
|
self.update()
|
|
173
342
|
|
|
174
343
|
def scale_to_fit(self, image):
|
|
175
344
|
"""
|
|
176
|
-
|
|
177
|
-
|
|
345
|
+
Backward-compatibility wrapper. Uses layered model now.
|
|
178
346
|
:param image: Image
|
|
179
347
|
"""
|
|
180
|
-
|
|
181
|
-
new = image
|
|
182
|
-
else:
|
|
183
|
-
if image.width() > image.height():
|
|
184
|
-
width = self.width()
|
|
185
|
-
height = (image.height() * self.width()) / image.width()
|
|
186
|
-
new = image.scaled(
|
|
187
|
-
width,
|
|
188
|
-
int(height),
|
|
189
|
-
Qt.KeepAspectRatioByExpanding,
|
|
190
|
-
Qt.SmoothTransformation,
|
|
191
|
-
)
|
|
192
|
-
else:
|
|
193
|
-
height = self.height()
|
|
194
|
-
width = (image.width() * self.height()) / image.height()
|
|
195
|
-
new = image.scaled(
|
|
196
|
-
int(width),
|
|
197
|
-
height,
|
|
198
|
-
Qt.KeepAspectRatioByExpanding,
|
|
199
|
-
Qt.SmoothTransformation,
|
|
200
|
-
)
|
|
348
|
+
self.set_image(image, fit_canvas_to_image=False)
|
|
201
349
|
|
|
202
|
-
|
|
203
|
-
self.image = QImage(self.size(), QImage.Format_RGB32)
|
|
204
|
-
self.image.fill(Qt.white)
|
|
205
|
-
painter = QPainter(self.image)
|
|
206
|
-
painter.drawImage(0, 0, new)
|
|
207
|
-
painter.end()
|
|
208
|
-
self.update()
|
|
209
|
-
self.originalImage = self.image
|
|
350
|
+
# ---------- Undo/redo ----------
|
|
210
351
|
|
|
211
352
|
def saveForUndo(self):
|
|
212
353
|
"""Save current state for undo"""
|
|
213
|
-
|
|
354
|
+
# Ensure layers up-to-date before snapshot
|
|
355
|
+
self._ensure_layers()
|
|
356
|
+
self._recompose()
|
|
357
|
+
self.undoStack.append(self._snapshot_state())
|
|
214
358
|
self.redoStack.clear()
|
|
215
359
|
|
|
216
360
|
def undo(self):
|
|
217
361
|
"""Undo the last action"""
|
|
218
362
|
if self.undoStack:
|
|
219
|
-
self.
|
|
220
|
-
self.
|
|
221
|
-
self.
|
|
363
|
+
current = self._snapshot_state()
|
|
364
|
+
self.redoStack.append(current)
|
|
365
|
+
state = self.undoStack.pop()
|
|
366
|
+
self._apply_state(state)
|
|
222
367
|
|
|
223
368
|
def redo(self):
|
|
224
369
|
"""Redo the last undo action"""
|
|
225
370
|
if self.redoStack:
|
|
226
|
-
self.
|
|
227
|
-
self.
|
|
228
|
-
self.
|
|
371
|
+
current = self._snapshot_state()
|
|
372
|
+
self.undoStack.append(current)
|
|
373
|
+
state = self.redoStack.pop()
|
|
374
|
+
self._apply_state(state)
|
|
229
375
|
|
|
230
376
|
def has_undo(self) -> bool:
|
|
231
377
|
"""Check if undo is available"""
|
|
@@ -235,6 +381,21 @@ class PainterWidget(QWidget):
|
|
|
235
381
|
"""Check if redo is available"""
|
|
236
382
|
return bool(self.redoStack)
|
|
237
383
|
|
|
384
|
+
# ---------- Brush/eraser ----------
|
|
385
|
+
|
|
386
|
+
def set_mode(self, mode: str):
|
|
387
|
+
"""
|
|
388
|
+
Set painting mode: "brush" or "erase"
|
|
389
|
+
"""
|
|
390
|
+
if mode not in ("brush", "erase"):
|
|
391
|
+
return
|
|
392
|
+
self._mode = mode
|
|
393
|
+
# cursor hint
|
|
394
|
+
if self._mode == "erase":
|
|
395
|
+
self.setCursor(QCursor(Qt.PointingHandCursor))
|
|
396
|
+
else:
|
|
397
|
+
self.setCursor(QCursor(Qt.CrossCursor))
|
|
398
|
+
|
|
238
399
|
def set_brush_color(self, color):
|
|
239
400
|
"""
|
|
240
401
|
Set the brush color
|
|
@@ -254,11 +415,96 @@ class PainterWidget(QWidget):
|
|
|
254
415
|
self._pen.setWidth(size)
|
|
255
416
|
|
|
256
417
|
def clear_image(self):
|
|
257
|
-
"""Clear the image"""
|
|
258
|
-
self.
|
|
259
|
-
self.
|
|
418
|
+
"""Clear the image (both background and drawing layer)"""
|
|
419
|
+
self._ensure_layers()
|
|
420
|
+
self.sourceImageOriginal = None
|
|
421
|
+
self.baseCanvas.fill(Qt.white)
|
|
422
|
+
self.drawingLayer.fill(Qt.transparent)
|
|
423
|
+
self._recompose()
|
|
424
|
+
self.update()
|
|
425
|
+
|
|
426
|
+
# ---------- Crop tool ----------
|
|
427
|
+
|
|
428
|
+
def start_crop(self):
|
|
429
|
+
"""Activate crop mode."""
|
|
430
|
+
self.cropping = True
|
|
431
|
+
self._selecting = False
|
|
432
|
+
self._selectionRect = QRect()
|
|
433
|
+
self.setCursor(QCursor(Qt.CrossCursor))
|
|
260
434
|
self.update()
|
|
261
435
|
|
|
436
|
+
def cancel_crop(self):
|
|
437
|
+
"""Cancel crop mode."""
|
|
438
|
+
self.cropping = False
|
|
439
|
+
self._selecting = False
|
|
440
|
+
self._selectionRect = QRect()
|
|
441
|
+
self.unsetCursor()
|
|
442
|
+
self.update()
|
|
443
|
+
|
|
444
|
+
def _finalize_crop(self):
|
|
445
|
+
"""Finalize crop with current selection rectangle."""
|
|
446
|
+
if not self.cropping or self._selectionRect.isNull() or self._selectionRect.width() <= 1 or self._selectionRect.height() <= 1:
|
|
447
|
+
self.cancel_crop()
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
self._ensure_layers()
|
|
451
|
+
sel = self._selectionRect.normalized()
|
|
452
|
+
# Keep previous state for undo
|
|
453
|
+
# saveForUndo called on mousePress at crop start
|
|
454
|
+
|
|
455
|
+
# Crop base and drawing layers to selection
|
|
456
|
+
new_base = self.baseCanvas.copy(sel)
|
|
457
|
+
new_draw = self.drawingLayer.copy(sel)
|
|
458
|
+
|
|
459
|
+
# Prepare to apply exact cropped pixels after resize event
|
|
460
|
+
self._pendingResizeApply = {
|
|
461
|
+
'base': QImage(new_base),
|
|
462
|
+
'draw': QImage(new_draw),
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
# Update original source to cropped region for future high-quality resizes
|
|
466
|
+
if self.sourceImageOriginal is not None and not self.baseTargetRect.isNull():
|
|
467
|
+
inter = sel.intersected(self.baseTargetRect)
|
|
468
|
+
if inter.isValid() and not inter.isNull():
|
|
469
|
+
# Map intersection rect to original source coordinates
|
|
470
|
+
sx_ratio = self.sourceImageOriginal.width() / self.baseTargetRect.width()
|
|
471
|
+
sy_ratio = self.sourceImageOriginal.height() / self.baseTargetRect.height()
|
|
472
|
+
|
|
473
|
+
dx = inter.x() - self.baseTargetRect.x()
|
|
474
|
+
dy = inter.y() - self.baseTargetRect.y()
|
|
475
|
+
|
|
476
|
+
sx = max(0, int(dx * sx_ratio))
|
|
477
|
+
sy = max(0, int(dy * sy_ratio))
|
|
478
|
+
sw = max(1, int(inter.width() * sx_ratio))
|
|
479
|
+
sh = max(1, int(inter.height() * sy_ratio))
|
|
480
|
+
# Clip
|
|
481
|
+
if sx + sw > self.sourceImageOriginal.width():
|
|
482
|
+
sw = self.sourceImageOriginal.width() - sx
|
|
483
|
+
if sy + sh > self.sourceImageOriginal.height():
|
|
484
|
+
sh = self.sourceImageOriginal.height() - sy
|
|
485
|
+
if sw > 0 and sh > 0:
|
|
486
|
+
self.sourceImageOriginal = self.sourceImageOriginal.copy(sx, sy, sw, sh)
|
|
487
|
+
else:
|
|
488
|
+
self.sourceImageOriginal = None
|
|
489
|
+
else:
|
|
490
|
+
# Selection outside of image; keep no source
|
|
491
|
+
self.sourceImageOriginal = None
|
|
492
|
+
else:
|
|
493
|
+
# No original source, nothing to update
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
# Resize canvas to selection size; resizeEvent will apply _pendingResizeApply
|
|
497
|
+
self.cropping = False
|
|
498
|
+
self._selecting = False
|
|
499
|
+
self._selectionRect = QRect()
|
|
500
|
+
self.unsetCursor()
|
|
501
|
+
|
|
502
|
+
# Perform canvas resize (custom)
|
|
503
|
+
self.window.controller.painter.common.change_canvas_size(f"{sel.width()}x{sel.height()}")
|
|
504
|
+
self.update()
|
|
505
|
+
|
|
506
|
+
# ---------- Events ----------
|
|
507
|
+
|
|
262
508
|
def mousePressEvent(self, event):
|
|
263
509
|
"""
|
|
264
510
|
Mouse press event
|
|
@@ -266,13 +512,33 @@ class PainterWidget(QWidget):
|
|
|
266
512
|
:param event: Event
|
|
267
513
|
"""
|
|
268
514
|
if event.button() == Qt.LeftButton:
|
|
515
|
+
self._mouseDown = True
|
|
516
|
+
if self.cropping:
|
|
517
|
+
self.saveForUndo()
|
|
518
|
+
self._selecting = True
|
|
519
|
+
self._selectionStart = event.pos()
|
|
520
|
+
self._selectionRect = QRect(self._selectionStart, self._selectionStart)
|
|
521
|
+
self.update()
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
# painting
|
|
525
|
+
self._ensure_layers()
|
|
269
526
|
self.drawing = True
|
|
270
527
|
self.lastPoint = event.pos()
|
|
271
528
|
self.saveForUndo()
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
529
|
+
|
|
530
|
+
p = QPainter(self.drawingLayer)
|
|
531
|
+
p.setRenderHint(QPainter.Antialiasing, True)
|
|
532
|
+
if self._mode == "erase":
|
|
533
|
+
p.setCompositionMode(QPainter.CompositionMode_Clear)
|
|
534
|
+
pen = QPen(Qt.transparent, self.brushSize, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
|
|
535
|
+
p.setPen(pen)
|
|
536
|
+
else:
|
|
537
|
+
p.setCompositionMode(QPainter.CompositionMode_SourceOver)
|
|
538
|
+
p.setPen(self._pen)
|
|
539
|
+
p.drawPoint(self.lastPoint)
|
|
540
|
+
p.end()
|
|
541
|
+
self._recompose()
|
|
276
542
|
self.update()
|
|
277
543
|
|
|
278
544
|
def mouseMoveEvent(self, event):
|
|
@@ -281,12 +547,26 @@ class PainterWidget(QWidget):
|
|
|
281
547
|
|
|
282
548
|
:param event: Event
|
|
283
549
|
"""
|
|
550
|
+
if self.cropping and self._selecting and (event.buttons() & Qt.LeftButton):
|
|
551
|
+
self._selectionRect = QRect(self._selectionStart, event.pos())
|
|
552
|
+
self.update()
|
|
553
|
+
return
|
|
554
|
+
|
|
284
555
|
if (event.buttons() & Qt.LeftButton) and self.drawing:
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
556
|
+
self._ensure_layers()
|
|
557
|
+
p = QPainter(self.drawingLayer)
|
|
558
|
+
p.setRenderHint(QPainter.Antialiasing, True)
|
|
559
|
+
if self._mode == "erase":
|
|
560
|
+
p.setCompositionMode(QPainter.CompositionMode_Clear)
|
|
561
|
+
pen = QPen(Qt.transparent, self.brushSize, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
|
|
562
|
+
p.setPen(pen)
|
|
563
|
+
else:
|
|
564
|
+
p.setCompositionMode(QPainter.CompositionMode_SourceOver)
|
|
565
|
+
p.setPen(self._pen)
|
|
566
|
+
p.drawLine(self.lastPoint, event.pos())
|
|
567
|
+
p.end()
|
|
289
568
|
self.lastPoint = event.pos()
|
|
569
|
+
self._recompose()
|
|
290
570
|
self.update()
|
|
291
571
|
|
|
292
572
|
def mouseReleaseEvent(self, event):
|
|
@@ -295,12 +575,15 @@ class PainterWidget(QWidget):
|
|
|
295
575
|
|
|
296
576
|
:param event: Event
|
|
297
577
|
"""
|
|
298
|
-
if event.button()
|
|
578
|
+
if event.button() in (Qt.LeftButton, Qt.RightButton):
|
|
579
|
+
self._mouseDown = False
|
|
580
|
+
if self.cropping and self._selecting:
|
|
581
|
+
self._finalize_crop()
|
|
299
582
|
self.drawing = False
|
|
300
583
|
|
|
301
584
|
def keyPressEvent(self, event):
|
|
302
585
|
"""
|
|
303
|
-
Key press event to handle
|
|
586
|
+
Key press event to handle shortcuts
|
|
304
587
|
|
|
305
588
|
:param event: Event
|
|
306
589
|
"""
|
|
@@ -308,6 +591,14 @@ class PainterWidget(QWidget):
|
|
|
308
591
|
self.undo()
|
|
309
592
|
elif event.key() == Qt.Key_V and QApplication.keyboardModifiers() == Qt.ControlModifier:
|
|
310
593
|
self.handle_paste()
|
|
594
|
+
elif event.key() in (Qt.Key_Return, Qt.Key_Enter):
|
|
595
|
+
# finalize crop with Enter
|
|
596
|
+
if self.cropping and self._selecting:
|
|
597
|
+
self._finalize_crop()
|
|
598
|
+
elif event.key() == Qt.Key_Escape:
|
|
599
|
+
# cancel crop
|
|
600
|
+
if self.cropping:
|
|
601
|
+
self.cancel_crop()
|
|
311
602
|
|
|
312
603
|
def paintEvent(self, event):
|
|
313
604
|
"""
|
|
@@ -315,24 +606,101 @@ class PainterWidget(QWidget):
|
|
|
315
606
|
|
|
316
607
|
:param event: Event
|
|
317
608
|
"""
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
609
|
+
# Ensure final composition is valid
|
|
610
|
+
if self.image.size() != self.size():
|
|
611
|
+
self._ensure_layers()
|
|
612
|
+
self._rescale_base_from_source()
|
|
613
|
+
self._recompose()
|
|
614
|
+
|
|
615
|
+
p = QPainter(self)
|
|
616
|
+
p.drawImage(self.rect(), self.image, self.image.rect())
|
|
617
|
+
|
|
618
|
+
# Draw crop overlay if active
|
|
619
|
+
if self.cropping and not self._selectionRect.isNull():
|
|
620
|
+
sel = self._selectionRect.normalized()
|
|
621
|
+
overlay = QColor(0, 0, 0, 120)
|
|
622
|
+
W, H = self.width(), self.height()
|
|
623
|
+
|
|
624
|
+
# left
|
|
625
|
+
if sel.left() > 0:
|
|
626
|
+
p.fillRect(0, 0, sel.left(), H, overlay)
|
|
627
|
+
# right
|
|
628
|
+
if sel.right() < W - 1:
|
|
629
|
+
p.fillRect(sel.right() + 1, 0, W - (sel.right() + 1), H, overlay)
|
|
630
|
+
# top
|
|
631
|
+
if sel.top() > 0:
|
|
632
|
+
p.fillRect(sel.left(), 0, sel.width(), sel.top(), overlay)
|
|
633
|
+
# bottom
|
|
634
|
+
if sel.bottom() < H - 1:
|
|
635
|
+
p.fillRect(sel.left(), sel.bottom() + 1, sel.width(), H - (sel.bottom() + 1), overlay)
|
|
636
|
+
|
|
637
|
+
# selection border
|
|
638
|
+
p.setPen(QPen(QColor(255, 255, 255, 200), 1, Qt.DashLine))
|
|
639
|
+
p.drawRect(sel.adjusted(0, 0, -1, -1))
|
|
640
|
+
|
|
641
|
+
p.end()
|
|
321
642
|
self.originalImage = self.image
|
|
322
643
|
|
|
323
644
|
def resizeEvent(self, event):
|
|
324
645
|
"""
|
|
325
|
-
Update
|
|
646
|
+
Update layers on resize
|
|
326
647
|
|
|
327
648
|
:param event: Event
|
|
328
649
|
"""
|
|
329
|
-
if self.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
650
|
+
if self._ignoreResizeOnce:
|
|
651
|
+
return super().resizeEvent(event)
|
|
652
|
+
|
|
653
|
+
old_size = event.oldSize()
|
|
654
|
+
new_size = event.size()
|
|
655
|
+
|
|
656
|
+
# Allocate new layers and recompose
|
|
657
|
+
self._ensure_layers()
|
|
658
|
+
|
|
659
|
+
if self._pendingResizeApply is not None:
|
|
660
|
+
# Apply exact cropped pixels to new canvas size; center if differs
|
|
661
|
+
new_base = self._pendingResizeApply.get('base')
|
|
662
|
+
new_draw = self._pendingResizeApply.get('draw')
|
|
663
|
+
|
|
664
|
+
# Resize canvas already happened; we need to place these images exactly fitting the canvas size.
|
|
665
|
+
# If sizes match new canvas, copy directly; else center them.
|
|
666
|
+
self.baseCanvas.fill(Qt.white)
|
|
667
|
+
self.drawingLayer.fill(Qt.transparent)
|
|
668
|
+
|
|
669
|
+
if new_base is not None:
|
|
670
|
+
if new_base.size() == new_size:
|
|
671
|
+
self.baseCanvas = QImage(new_base)
|
|
672
|
+
else:
|
|
673
|
+
bx = (new_size.width() - new_base.width()) // 2
|
|
674
|
+
by = (new_size.height() - new_base.height()) // 2
|
|
675
|
+
p = QPainter(self.baseCanvas)
|
|
676
|
+
p.drawImage(QPoint(max(0, bx), max(0, by)), new_base)
|
|
677
|
+
p.end()
|
|
678
|
+
|
|
679
|
+
if new_draw is not None:
|
|
680
|
+
if new_draw.size() == new_size:
|
|
681
|
+
self.drawingLayer = QImage(new_draw)
|
|
682
|
+
else:
|
|
683
|
+
dx = (new_size.width() - new_draw.width()) // 2
|
|
684
|
+
dy = (new_size.height() - new_draw.height()) // 2
|
|
685
|
+
p = QPainter(self.drawingLayer)
|
|
686
|
+
p.drawImage(QPoint(max(0, dx), max(0, dy)), new_draw)
|
|
687
|
+
p.end()
|
|
688
|
+
|
|
689
|
+
self._pendingResizeApply = None
|
|
690
|
+
# baseTargetRect becomes entire canvas if new_base filled it; otherwise keep unknown
|
|
691
|
+
self.baseTargetRect = QRect(0, 0, self.baseCanvas.width(), self.baseCanvas.height())
|
|
692
|
+
else:
|
|
693
|
+
# Standard path: rebuild base from original source to avoid quality loss
|
|
694
|
+
self._rescale_base_from_source()
|
|
695
|
+
|
|
696
|
+
# Scale drawing layer content to new size (best effort). This may introduce minor quality loss, acceptable.
|
|
697
|
+
if old_size.isValid() and (old_size.width() > 0 and old_size.height() > 0) and \
|
|
698
|
+
(self.drawingLayer is not None) and (self.drawingLayer.size() != new_size):
|
|
699
|
+
self.drawingLayer = self.drawingLayer.scaled(new_size, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
|
|
700
|
+
|
|
701
|
+
self._recompose()
|
|
702
|
+
self.update()
|
|
703
|
+
super().resizeEvent(event)
|
|
336
704
|
|
|
337
705
|
def eventFilter(self, source, event):
|
|
338
706
|
"""
|