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.
Files changed (44) hide show
  1. pygpt_net/CHANGELOG.txt +12 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/assistant/batch.py +14 -4
  4. pygpt_net/controller/assistant/files.py +1 -0
  5. pygpt_net/controller/assistant/store.py +195 -1
  6. pygpt_net/controller/camera/camera.py +1 -1
  7. pygpt_net/controller/chat/attachment.py +2 -0
  8. pygpt_net/controller/chat/common.py +50 -46
  9. pygpt_net/controller/config/placeholder.py +95 -75
  10. pygpt_net/controller/dialogs/confirm.py +3 -1
  11. pygpt_net/controller/media/media.py +11 -3
  12. pygpt_net/controller/painter/common.py +227 -10
  13. pygpt_net/controller/painter/painter.py +4 -12
  14. pygpt_net/core/assistants/files.py +18 -0
  15. pygpt_net/core/camera/camera.py +38 -93
  16. pygpt_net/core/camera/worker.py +430 -0
  17. pygpt_net/core/filesystem/url.py +3 -0
  18. pygpt_net/core/render/web/body.py +65 -9
  19. pygpt_net/core/text/utils.py +3 -0
  20. pygpt_net/data/config/config.json +234 -221
  21. pygpt_net/data/config/models.json +179 -180
  22. pygpt_net/data/config/settings.json +10 -5
  23. pygpt_net/data/locale/locale.de.ini +8 -6
  24. pygpt_net/data/locale/locale.en.ini +9 -5
  25. pygpt_net/data/locale/locale.es.ini +8 -6
  26. pygpt_net/data/locale/locale.fr.ini +8 -6
  27. pygpt_net/data/locale/locale.it.ini +8 -6
  28. pygpt_net/data/locale/locale.pl.ini +8 -6
  29. pygpt_net/data/locale/locale.uk.ini +8 -6
  30. pygpt_net/data/locale/locale.zh.ini +8 -6
  31. pygpt_net/item/assistant.py +13 -1
  32. pygpt_net/provider/api/google/__init__.py +32 -23
  33. pygpt_net/provider/api/openai/store.py +45 -1
  34. pygpt_net/provider/llms/google.py +4 -0
  35. pygpt_net/ui/dialog/assistant_store.py +213 -203
  36. pygpt_net/ui/layout/chat/input.py +3 -3
  37. pygpt_net/ui/widget/draw/painter.py +458 -75
  38. pygpt_net/ui/widget/option/combo.py +5 -1
  39. pygpt_net/ui/widget/textarea/input.py +273 -3
  40. {pygpt_net-2.6.32.dist-info → pygpt_net-2.6.34.dist-info}/METADATA +14 -2
  41. {pygpt_net-2.6.32.dist-info → pygpt_net-2.6.34.dist-info}/RECORD +44 -43
  42. {pygpt_net-2.6.32.dist-info → pygpt_net-2.6.34.dist-info}/LICENSE +0 -0
  43. {pygpt_net-2.6.32.dist-info → pygpt_net-2.6.34.dist-info}/WHEEL +0 -0
  44. {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.08.24 23:00:00 #
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
- self.image.fill(Qt.white)
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.originalImage = None
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
- self._pen = QPen(self.brushColor, self.brushSize, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
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
- self.window.ui.painter.set_image(image)
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
- self.scale_to_fit(img)
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 set_image(self, image):
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
- Set image
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.scale_to_fit(image)
171
- self.originalImage = self.image
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
- Scale image to fit the widget
177
-
354
+ Backward-compatibility wrapper. Uses layered model now.
178
355
  :param image: Image
179
356
  """
180
- if image.width() == self.width():
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
- if self.image.size() != self.size():
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
- self.undoStack.append(QImage(self.image))
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.redoStack.append(QImage(self.image))
220
- self.image = self.undoStack.pop()
221
- self.update()
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.undoStack.append(QImage(self.image))
227
- self.image = self.redoStack.pop()
228
- self.update()
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.saveForUndo()
259
- self.image.fill(Qt.white)
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
- painter = QPainter(self.image)
273
- painter.setPen(self._pen)
274
- painter.drawPoint(self.lastPoint)
275
- painter.end()
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
- painter = QPainter(self.image)
286
- painter.setPen(self._pen)
287
- painter.drawLine(self.lastPoint, event.pos())
288
- painter.end()
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() == Qt.LeftButton or event.button() == Qt.RightButton:
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 undo action
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
- painter = QPainter(self)
319
- painter.drawImage(self.rect(), self.image, self.image.rect())
320
- painter.end()
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 coords on resize
661
+ Update layers on resize
326
662
 
327
663
  :param event: Event
328
664
  """
329
- if self.image.size() != self.size():
330
- new = QImage(self.size(), QImage.Format_RGB32)
331
- new.fill(Qt.white)
332
- painter = QPainter(new)
333
- painter.drawImage(QPoint(0, 0), self.image)
334
- painter.end()
335
- self.image = new
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
  """