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