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.
@@ -1,19 +1,10 @@
1
- #!/usr/bin/env python3
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
- self.image.fill(Qt.white)
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.originalImage = None
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
- self._pen = QPen(self.brushColor, self.brushSize, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
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
- self.window.ui.painter.set_image(image)
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
- self.scale_to_fit(img)
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 set_image(self, image):
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
- Set image
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.scale_to_fit(image)
171
- self.originalImage = self.image
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
- Scale image to fit the widget
177
-
345
+ Backward-compatibility wrapper. Uses layered model now.
178
346
  :param image: Image
179
347
  """
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
- )
348
+ self.set_image(image, fit_canvas_to_image=False)
201
349
 
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
350
+ # ---------- Undo/redo ----------
210
351
 
211
352
  def saveForUndo(self):
212
353
  """Save current state for undo"""
213
- self.undoStack.append(QImage(self.image))
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.redoStack.append(QImage(self.image))
220
- self.image = self.undoStack.pop()
221
- self.update()
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.undoStack.append(QImage(self.image))
227
- self.image = self.redoStack.pop()
228
- self.update()
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.saveForUndo()
259
- self.image.fill(Qt.white)
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
- painter = QPainter(self.image)
273
- painter.setPen(self._pen)
274
- painter.drawPoint(self.lastPoint)
275
- painter.end()
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
- painter = QPainter(self.image)
286
- painter.setPen(self._pen)
287
- painter.drawLine(self.lastPoint, event.pos())
288
- painter.end()
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() == Qt.LeftButton or event.button() == Qt.RightButton:
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 undo action
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
- painter = QPainter(self)
319
- painter.drawImage(self.rect(), self.image, self.image.rect())
320
- painter.end()
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 coords on resize
646
+ Update layers on resize
326
647
 
327
648
  :param event: Event
328
649
  """
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
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
  """