pygpt-net 2.6.34__py3-none-any.whl → 2.6.35__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 +7 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/chat/common.py +8 -2
  4. pygpt_net/controller/chat/handler/stream_worker.py +55 -43
  5. pygpt_net/controller/painter/common.py +13 -1
  6. pygpt_net/controller/painter/painter.py +11 -2
  7. pygpt_net/core/bridge/bridge.py +1 -5
  8. pygpt_net/core/bridge/context.py +81 -36
  9. pygpt_net/core/bridge/worker.py +3 -1
  10. pygpt_net/core/ctx/bag.py +4 -0
  11. pygpt_net/core/events/app.py +10 -17
  12. pygpt_net/core/events/base.py +17 -25
  13. pygpt_net/core/events/control.py +9 -17
  14. pygpt_net/core/events/event.py +9 -62
  15. pygpt_net/core/events/kernel.py +8 -17
  16. pygpt_net/core/events/realtime.py +8 -17
  17. pygpt_net/core/events/render.py +9 -17
  18. pygpt_net/core/render/web/body.py +394 -36
  19. pygpt_net/core/render/web/pid.py +39 -24
  20. pygpt_net/core/render/web/renderer.py +146 -40
  21. pygpt_net/data/config/config.json +4 -3
  22. pygpt_net/data/config/models.json +3 -3
  23. pygpt_net/data/css/web-blocks.css +3 -2
  24. pygpt_net/data/css/web-chatgpt.css +3 -1
  25. pygpt_net/data/css/web-chatgpt_wide.css +3 -1
  26. pygpt_net/data/locale/locale.de.ini +1 -0
  27. pygpt_net/data/locale/locale.en.ini +3 -2
  28. pygpt_net/data/locale/locale.es.ini +1 -0
  29. pygpt_net/data/locale/locale.fr.ini +1 -0
  30. pygpt_net/data/locale/locale.it.ini +1 -0
  31. pygpt_net/data/locale/locale.pl.ini +2 -1
  32. pygpt_net/data/locale/locale.uk.ini +1 -0
  33. pygpt_net/data/locale/locale.zh.ini +1 -0
  34. pygpt_net/provider/api/google/__init__.py +14 -5
  35. pygpt_net/provider/api/openai/__init__.py +13 -10
  36. pygpt_net/provider/core/config/patch.py +9 -0
  37. pygpt_net/ui/layout/chat/painter.py +63 -4
  38. pygpt_net/ui/widget/draw/painter.py +702 -106
  39. pygpt_net/ui/widget/textarea/web.py +2 -0
  40. {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/METADATA +9 -2
  41. {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/RECORD +44 -44
  42. {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/LICENSE +0 -0
  43. {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/WHEEL +0 -0
  44. {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/entry_points.txt +0 -0
@@ -6,27 +6,44 @@
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.09.02 20:00:00 #
9
+ # Updated Date: 2025.09.02 15:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import datetime
13
+ import os
14
+ import bisect
13
15
  from collections import deque
14
16
 
15
- from PySide6.QtCore import Qt, QPoint, QRect, QSize
17
+ from PySide6.QtCore import Qt, QPoint, QPointF, QRect, QSize, QSaveFile, QIODevice, QTimer, Signal
16
18
  from PySide6.QtGui import QImage, QPainter, QPen, QAction, QIcon, QColor, QCursor
17
- from PySide6.QtWidgets import QMenu, QWidget, QFileDialog, QMessageBox, QApplication
19
+ from PySide6.QtWidgets import QMenu, QWidget, QFileDialog, QMessageBox, QApplication, QAbstractScrollArea
18
20
 
19
21
  from pygpt_net.core.tabs.tab import Tab
20
22
  from pygpt_net.utils import trans
21
23
 
22
24
 
23
25
  class PainterWidget(QWidget):
26
+ # Emitted whenever zoom changes; payload is zoom factor (e.g. 1.0 for 100%)
27
+ zoomChanged = Signal(float)
28
+
24
29
  def __init__(self, window=None):
25
30
  super().__init__(window)
26
31
  self.window = window
27
32
 
33
+ # Logical canvas size (in pixels). Rendering buffers follow this, never the display size.
34
+ w0 = max(1, self.width())
35
+ h0 = max(1, self.height())
36
+ self._canvasSize = QSize(w0, h0)
37
+
38
+ # Zoom state (pure view transform; does not affect canvas resolution)
39
+ self.zoom = 1.0
40
+ self._minZoom = 0.10 # 10%
41
+ self._maxZoom = 10.0 # 1000%
42
+ self._zoomSteps = [0.10, 0.25, 0.50, 0.75, 1.00, 1.50, 2.00, 5.00, 10.00]
43
+ self._zoomResizeInProgress = False # guard used during display-size updates caused by zoom
44
+
28
45
  # Final composited image (canvas-sized). Kept for API compatibility.
29
- self.image = QImage(self.size(), QImage.Format_RGB32)
46
+ self.image = QImage(self._canvasSize, QImage.Format_RGB32)
30
47
 
31
48
  # Layered model:
32
49
  # - sourceImageOriginal: original background image (full quality, not canvas-sized).
@@ -43,10 +60,10 @@ class PainterWidget(QWidget):
43
60
  self.brushSize = 3
44
61
  self.brushColor = Qt.black
45
62
  self._mode = "brush" # "brush" or "erase"
46
- self.lastPoint = QPoint()
63
+ self.lastPointCanvas = QPoint()
47
64
  self._pen = QPen(self.brushColor, self.brushSize, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
48
65
 
49
- # Crop tool state
66
+ # Crop tool state (selection kept in canvas coordinates)
50
67
  self.cropping = False
51
68
  self._selecting = False
52
69
  self._selectionStart = QPoint()
@@ -61,6 +78,7 @@ class PainterWidget(QWidget):
61
78
  self.setFocusPolicy(Qt.StrongFocus)
62
79
  self.setFocus()
63
80
  self.installEventFilter(self)
81
+
64
82
  self.tab = None
65
83
 
66
84
  self.setAttribute(Qt.WA_OpaquePaintEvent, True)
@@ -70,6 +88,16 @@ class PainterWidget(QWidget):
70
88
  self._pendingResizeApply = None # payload used after crop to apply exact pixels on resize
71
89
  self._ignoreResizeOnce = False # guard to prevent recursive work in resize path
72
90
 
91
+ # Auto-scroll while cropping (scroll area integration)
92
+ self._scrollArea = None
93
+ self._scrollViewport = None
94
+ self._autoScrollTimer = QTimer(self)
95
+ self._autoScrollTimer.setInterval(16) # ~60 FPS, low overhead
96
+ self._autoScrollTimer.timeout.connect(self._autoscroll_tick)
97
+ self._autoScrollMargin = 36 # px from viewport edge to trigger autoscroll
98
+ self._autoScrollMinSpeed = 2 # px per tick (min)
99
+ self._autoScrollMaxSpeed = 18 # px per tick (max)
100
+
73
101
  # Actions
74
102
  self._act_undo = QAction(QIcon(":/icons/undo.svg"), trans('action.undo'), self)
75
103
  self._act_undo.triggered.connect(self.undo)
@@ -99,12 +127,18 @@ class PainterWidget(QWidget):
99
127
  self._act_crop = QAction(QIcon(":/icons/crop.svg"), trans('painter.btn.crop') if trans('painter.btn.crop') else "Crop", self)
100
128
  self._act_crop.triggered.connect(self.start_crop)
101
129
 
130
+ # Fit action (trims letterbox and resizes canvas to the scaled image area)
131
+ self._act_fit = QAction(QIcon(":/icons/resize.svg"), trans('painter.btn.fit') if trans('painter.btn.fit') else "Fit", self)
132
+ self._act_fit.triggered.connect(self.action_fit)
133
+
102
134
  # Context menu
103
135
  self._ctx_menu = QMenu(self)
104
136
  self._ctx_menu.addAction(self._act_undo)
105
137
  self._ctx_menu.addAction(self._act_redo)
106
138
  self._ctx_menu.addSeparator()
107
139
  self._ctx_menu.addAction(self._act_crop)
140
+ self._ctx_menu.addAction(self._act_fit)
141
+ self._ctx_menu.addSeparator()
108
142
  self._ctx_menu.addSeparator()
109
143
  self._ctx_menu.addAction(self._act_open)
110
144
  self._ctx_menu.addAction(self._act_capture)
@@ -113,6 +147,12 @@ class PainterWidget(QWidget):
113
147
  self._ctx_menu.addAction(self._act_save)
114
148
  self._ctx_menu.addAction(self._act_clear)
115
149
 
150
+ # Allocate initial buffers
151
+ self._ensure_layers()
152
+ self._recompose()
153
+ # Keep display size in sync with zoom (initially 1.0 => no change)
154
+ self._update_widget_size_from_zoom()
155
+
116
156
  def set_tab(self, tab: Tab):
117
157
  """
118
158
  Set tab
@@ -121,11 +161,235 @@ class PainterWidget(QWidget):
121
161
  """
122
162
  self.tab = tab
123
163
 
164
+ # ---------- Zoom public API ----------
165
+
166
+ def on_zoom_combo_changed(self, text: str):
167
+ """
168
+ Slot for a zoom ComboBox change. Accepts strings like "100%" or "150 %".
169
+
170
+ :param text: Text from the combo box
171
+ """
172
+ val = self._parse_percent(text)
173
+ if val is None:
174
+ return
175
+ # Use viewport center as anchor when changed from combobox
176
+ anchor = self._viewport_center_in_widget_coords()
177
+ self.set_zoom(val / 100.0, anchor_widget_pos=anchor)
178
+
179
+ def set_zoom_percent(self, percent: int):
180
+ """
181
+ Set zoom using percent value, e.g. 150 for 150%.
182
+
183
+ :param percent: Zoom in percent
184
+ """
185
+ anchor = self._viewport_center_in_widget_coords()
186
+ self.set_zoom(max(1, percent) / 100.0, anchor_widget_pos=anchor)
187
+
188
+ def get_zoom_percent(self) -> int:
189
+ """
190
+ Return current zoom as integer percent.
191
+
192
+ :return: Zoom in percent (e.g. 150 for 150%)
193
+ """
194
+ return int(round(self.zoom * 100.0))
195
+
196
+ def get_zoom_steps_percent(self) -> list[int]:
197
+ """
198
+ Return recommended preset zoom steps in percent for a combo-box.
199
+
200
+ :return: List of zoom steps in percent
201
+ """
202
+ return [int(round(z * 100)) for z in self._zoomSteps]
203
+
204
+ def set_zoom(self, zoom: float, anchor_widget_pos: QPointF | None = None, emit_signal: bool = True):
205
+ """
206
+ Set zoom to an absolute factor. View-only; does not touch canvas resolution.
207
+ anchor_widget_pos: QPointF in widget coordinates; if None, viewport center is used.
208
+
209
+ :param zoom: Zoom factor (e.g. 1.0 for 100%)
210
+ :param anchor_widget_pos: Anchor point in widget coordinates to keep stable during zoom
211
+ :param emit_signal: Whether to emit zoomChanged signal and sync combobox
212
+ """
213
+ new_zoom = max(self._minZoom, min(self._maxZoom, float(zoom)))
214
+ if abs(new_zoom - self.zoom) < 1e-6:
215
+ return
216
+
217
+ old_zoom = self.zoom
218
+ self.zoom = new_zoom
219
+
220
+ # Sync UI (combobox) and emit signal
221
+ if emit_signal:
222
+ self._emit_zoom_changed()
223
+
224
+ # Update display size and scroll to keep anchor stable
225
+ if anchor_widget_pos is None:
226
+ anchor_widget_pos = self._viewport_center_in_widget_coords()
227
+ self._update_widget_size_from_zoom()
228
+ self._adjust_scroll_to_anchor(anchor_widget_pos, old_zoom, self.zoom)
229
+
230
+ self.update()
231
+
232
+ def zoom_in_step(self):
233
+ """Increase zoom to next preset step."""
234
+ idx = self._nearest_zoom_step_index(self.zoom)
235
+ if idx < len(self._zoomSteps) - 1:
236
+ self.set_zoom(self._zoomSteps[idx + 1], anchor_widget_pos=self._cursor_pos_in_widget())
237
+
238
+ def zoom_out_step(self):
239
+ """Decrease zoom to previous preset step."""
240
+ idx = self._nearest_zoom_step_index(self.zoom)
241
+ if idx > 0:
242
+ self.set_zoom(self._zoomSteps[idx - 1], anchor_widget_pos=self._cursor_pos_in_widget())
243
+
244
+ # ---------- Internal zoom helpers ----------
245
+
246
+ def _emit_zoom_changed(self):
247
+ """Emit signal and try to sync external combobox via controller if available."""
248
+ self.zoomChanged.emit(self.zoom)
249
+ try:
250
+ if self.window and hasattr(self.window, "controller"):
251
+ common = getattr(self.window.controller.painter, "common", None)
252
+ if common is not None:
253
+ # Preferred method name
254
+ if hasattr(common, "sync_zoom_combo_from_widget"):
255
+ common.sync_zoom_combo_from_widget(self.get_zoom_percent())
256
+ # Fallback method names that may exist in some UIs
257
+ elif hasattr(common, "set_zoom_percent"):
258
+ common.set_zoom_percent(self.get_zoom_percent())
259
+ elif hasattr(common, "set_zoom_value"):
260
+ common.set_zoom_value(self.get_zoom_percent())
261
+ except Exception:
262
+ pass
263
+
264
+ def _nearest_zoom_step_index(self, z: float) -> int:
265
+ """
266
+ Find index of the nearest step to z in _zoomSteps.
267
+
268
+ :param z: Zoom factor
269
+ :return: Index of the nearest zoom step
270
+ """
271
+ steps = self._zoomSteps
272
+ pos = bisect.bisect_left(steps, z)
273
+ if pos == 0:
274
+ return 0
275
+ if pos >= len(steps):
276
+ return len(steps) - 1
277
+ before = steps[pos - 1]
278
+ after = steps[pos]
279
+ return pos if abs(after - z) < abs(z - before) else pos - 1
280
+
281
+ def _cursor_pos_in_widget(self) -> QPointF:
282
+ """
283
+ Return current cursor position in widget coordinates.
284
+
285
+ :return: QPointF in widget coordinates
286
+ """
287
+ return QPointF(self.mapFromGlobal(QCursor.pos()))
288
+
289
+ def _viewport_center_in_widget_coords(self) -> QPointF:
290
+ """
291
+ Return viewport center mapped to widget coordinates; falls back to widget center.
292
+
293
+ :return: QPointF in widget coordinates
294
+ """
295
+ self._find_scroll_area()
296
+ if self._scrollViewport is not None:
297
+ vp = self._scrollViewport
298
+ center_vp = QPointF(vp.width() / 2.0, vp.height() / 2.0)
299
+ return QPointF(self.mapFrom(vp, center_vp.toPoint()))
300
+ return QPointF(self.width() / 2.0, self.height() / 2.0)
301
+
302
+ def _adjust_scroll_to_anchor(self, anchor_widget_pos: QPointF, old_zoom: float, new_zoom: float):
303
+ """
304
+ Adjust scrollbars to keep the anchor point stable in viewport during zoom.
305
+
306
+ :param anchor_widget_pos: Anchor point in widget coordinates
307
+ :param old_zoom: Previous zoom factor
308
+ :param new_zoom: New zoom factor
309
+ """
310
+ self._find_scroll_area()
311
+ if self._scrollArea is None or self._scrollViewport is None:
312
+ return
313
+ hbar = self._scrollArea.horizontalScrollBar()
314
+ vbar = self._scrollArea.verticalScrollBar()
315
+ if hbar is None and vbar is None:
316
+ return
317
+ scale = new_zoom / max(1e-6, old_zoom)
318
+ dx = anchor_widget_pos.x() * (scale - 1.0)
319
+ dy = anchor_widget_pos.y() * (scale - 1.0)
320
+ if hbar is not None:
321
+ hbar.setValue(int(round(hbar.value() + dx)))
322
+ if vbar is not None:
323
+ vbar.setValue(int(round(vbar.value() + dy)))
324
+
325
+ def _update_widget_size_from_zoom(self):
326
+ """Resize display widget to reflect current zoom; leaves canvas buffers untouched."""
327
+ disp_w = max(1, int(round(self._canvasSize.width() * self.zoom)))
328
+ disp_h = max(1, int(round(self._canvasSize.height() * self.zoom)))
329
+ new_disp = QSize(disp_w, disp_h)
330
+ if self.size() == new_disp:
331
+ return
332
+ self._zoomResizeInProgress = True
333
+ try:
334
+ # setFixedSize is preferred for content widgets inside scroll areas
335
+ self.setFixedSize(new_disp)
336
+ finally:
337
+ self._zoomResizeInProgress = False
338
+
339
+ def _to_canvas_point(self, pt) -> QPoint:
340
+ """
341
+ Map a widget point (QPoint or QPointF) to canvas coordinates.
342
+
343
+ :param pt: QPoint or QPointF in widget coordinates
344
+ :return: QPoint in canvas coordinates
345
+ """
346
+ if isinstance(pt, QPointF):
347
+ x = int(round(pt.x() / self.zoom))
348
+ y = int(round(pt.y() / self.zoom))
349
+ else:
350
+ x = int(round(pt.x() / self.zoom))
351
+ y = int(round(pt.y() / self.zoom))
352
+ x = max(0, min(self._canvasSize.width() - 1, x))
353
+ y = max(0, min(self._canvasSize.height() - 1, y))
354
+ return QPoint(x, y)
355
+
356
+ def _from_canvas_rect(self, rc: QRect) -> QRect:
357
+ """
358
+ Map a canvas rect to widget/display coordinates.
359
+
360
+ :param rc: QRect in canvas coordinates
361
+ :return: QRect in widget coordinates
362
+ """
363
+ x = int(round(rc.x() * self.zoom))
364
+ y = int(round(rc.y() * self.zoom))
365
+ w = int(round(rc.width() * self.zoom))
366
+ h = int(round(rc.height() * self.zoom))
367
+ return QRect(x, y, w, h)
368
+
369
+ def _parse_percent(self, text: str) -> int | None:
370
+ """
371
+ Parse '150%' -> 150.
372
+
373
+ Returns None if parsing fails.
374
+
375
+ :param text: Text to parse
376
+ :return: Integer percent or None
377
+ """
378
+ if not text:
379
+ return None
380
+ try:
381
+ s = text.strip().replace('%', '').strip()
382
+ s = s.replace(',', '.')
383
+ valf = float(s)
384
+ return int(round(valf))
385
+ except Exception:
386
+ return None
387
+
124
388
  # ---------- Layer & composition helpers ----------
125
389
 
126
390
  def _ensure_layers(self):
127
391
  """Ensure baseCanvas, drawingLayer, and image are allocated to current canvas size."""
128
- sz = self.size()
392
+ sz = self._canvasSize
129
393
  if sz.width() <= 0 or sz.height() <= 0:
130
394
  return
131
395
 
@@ -142,20 +406,16 @@ class PainterWidget(QWidget):
142
406
  self.image.fill(Qt.white)
143
407
 
144
408
  def _rescale_base_from_source(self):
145
- """
146
- Rebuild baseCanvas from sourceImageOriginal to fit current canvas, preserving aspect ratio.
147
- """
409
+ """Rebuild baseCanvas from sourceImageOriginal to fit current canvas, preserving aspect ratio."""
148
410
  self._ensure_layers()
149
411
  self.baseCanvas.fill(Qt.white)
150
412
  self.baseTargetRect = QRect()
151
413
  if self.sourceImageOriginal is None or self.sourceImageOriginal.isNull():
152
414
  return
153
415
 
154
- canvas_size = self.size()
416
+ canvas_size = self._canvasSize
155
417
  src = self.sourceImageOriginal
156
- # Compute scaled size that fits within the canvas (max width/height)
157
418
  scaled_size = src.size().scaled(canvas_size, Qt.KeepAspectRatio)
158
- # Center the image within the canvas
159
419
  x = (canvas_size.width() - scaled_size.width()) // 2
160
420
  y = (canvas_size.height() - scaled_size.height()) // 2
161
421
  self.baseTargetRect = QRect(x, y, scaled_size.width(), scaled_size.height())
@@ -170,9 +430,7 @@ class PainterWidget(QWidget):
170
430
  self._ensure_layers()
171
431
  self.image.fill(Qt.white)
172
432
  p = QPainter(self.image)
173
- # draw background
174
433
  p.drawImage(QPoint(0, 0), self.baseCanvas)
175
- # draw drawing layer
176
434
  p.setCompositionMode(QPainter.CompositionMode_SourceOver)
177
435
  p.drawImage(QPoint(0, 0), self.drawingLayer)
178
436
  p.end()
@@ -185,45 +443,169 @@ class PainterWidget(QWidget):
185
443
  'base': QImage(self.baseCanvas) if self.baseCanvas is not None else None,
186
444
  'draw': QImage(self.drawingLayer) if self.drawingLayer is not None else None,
187
445
  'src': QImage(self.sourceImageOriginal) if self.sourceImageOriginal is not None else None,
188
- 'size': QSize(self.width(), self.height()),
446
+ 'canvas_size': QSize(self._canvasSize.width(), self._canvasSize.height()),
189
447
  'baseRect': QRect(self.baseTargetRect),
190
448
  }
191
449
  return state
192
450
 
193
451
  def _apply_state(self, state):
194
- """Apply a snapshot (used by undo/redo)."""
452
+ """
453
+ Apply a snapshot (used by undo/redo).
454
+
455
+ :param state: State dict from _snapshot_state()
456
+ """
195
457
  if not state:
196
458
  return
197
- self._ignoreResizeOnce = True
198
- target_size = state['size']
199
459
 
200
- # Set canvas size if needed
201
- if target_size != self.size():
202
- self.setFixedSize(target_size)
460
+ target_canvas_size = state.get('canvas_size', None)
461
+ if isinstance(target_canvas_size, QSize) and target_canvas_size.isValid():
462
+ old_canvas = QSize(self._canvasSize)
463
+ self._canvasSize = QSize(target_canvas_size)
203
464
 
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)
465
+ self.image = QImage(state['image']) if state['image'] is not None else QImage(self._canvasSize, QImage.Format_RGB32)
466
+ if self.image.size() != self._canvasSize:
467
+ self.image = self.image.scaled(self._canvasSize, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
208
468
 
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)
469
+ if state['base'] is not None:
470
+ self.baseCanvas = QImage(state['base'])
471
+ else:
472
+ self.baseCanvas = QImage(self._canvasSize, QImage.Format_RGB32)
473
+ self.baseCanvas.fill(Qt.white)
214
474
 
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)
475
+ if state['draw'] is not None:
476
+ self.drawingLayer = QImage(state['draw'])
477
+ else:
478
+ self.drawingLayer = QImage(self._canvasSize, QImage.Format_ARGB32_Premultiplied)
479
+ self.drawingLayer.fill(Qt.transparent)
480
+
481
+ self.sourceImageOriginal = QImage(state['src']) if state['src'] is not None else None
482
+ self.baseTargetRect = QRect(state['baseRect']) if state['baseRect'] is not None else QRect()
483
+
484
+ self._recompose()
485
+ self._update_widget_size_from_zoom()
486
+ self.update()
487
+
488
+ def _is_fit_available(self) -> bool:
489
+ """
490
+ Return True if there are letterbox margins that can be trimmed.
491
+
492
+ :return: True if fit action is available
493
+ """
494
+ self._recompose()
495
+
496
+ if self.baseTargetRect.isValid() and not self.baseTargetRect.isNull():
497
+ if self.baseTargetRect.width() < self._canvasSize.width() or self.baseTargetRect.height() < self._canvasSize.height():
498
+ return True
499
+
500
+ bounds = self._detect_nonwhite_bounds(self.image)
501
+ if bounds is not None:
502
+ return bounds.width() < self._canvasSize.width() or bounds.height() < self._canvasSize.height()
503
+ return False
504
+
505
+ def action_fit(self):
506
+ """Trim white letterbox margins and resize canvas to the scaled image area. Undo-safe."""
507
+ if not self._is_fit_available():
508
+ return
220
509
 
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()
510
+ self.saveForUndo()
511
+ self._ensure_layers()
512
+ self._recompose()
513
+
514
+ fit_rect = None
515
+ if self.baseTargetRect.isValid() and not self.baseTargetRect.isNull():
516
+ canvas_rect = QRect(0, 0, self._canvasSize.width(), self._canvasSize.height())
517
+ fit_rect = self.baseTargetRect.intersected(canvas_rect)
518
+
519
+ if fit_rect is None or fit_rect.isNull() or fit_rect.width() <= 0 or fit_rect.height() <= 0:
520
+ fit_rect = self._detect_nonwhite_bounds(self.image)
521
+ if fit_rect is None or fit_rect.isNull() or fit_rect.width() <= 0 or fit_rect.height() <= 0:
522
+ return
523
+
524
+ if fit_rect.width() == self._canvasSize.width() and fit_rect.height() == self._canvasSize.height():
525
+ return
526
+
527
+ new_base = self.baseCanvas.copy(fit_rect)
528
+ new_draw = self.drawingLayer.copy(fit_rect)
529
+
530
+ self._pendingResizeApply = {
531
+ 'base': QImage(new_base),
532
+ 'draw': QImage(new_draw),
533
+ }
223
534
 
224
- self._ignoreResizeOnce = False
535
+ self.window.controller.painter.common.change_canvas_size(f"{fit_rect.width()}x{fit_rect.height()}")
225
536
  self.update()
226
537
 
538
+ def _detect_nonwhite_bounds(self, img: QImage, threshold: int = 250) -> QRect | None:
539
+ """
540
+ Detect tight bounding rect of non-white content in a composited image.
541
+ A pixel is considered background if all channels >= threshold.
542
+ Returns None if no non-white content is found.
543
+
544
+ :param img: Image to analyze
545
+ :param threshold: Threshold for considering a pixel as background (0-255)
546
+ :return: QRect of non-white content or None
547
+ """
548
+ if img is None or img.isNull():
549
+ return None
550
+
551
+ w, h = img.width(), img.height()
552
+ if w <= 0 or h <= 0:
553
+ return None
554
+
555
+ def is_bg(px: QColor) -> bool:
556
+ return px.red() >= threshold and px.green() >= threshold and px.blue() >= threshold
557
+
558
+ left = 0
559
+ found = False
560
+ for x in range(w):
561
+ for y in range(h):
562
+ if not is_bg(img.pixelColor(x, y)):
563
+ left = x
564
+ found = True
565
+ break
566
+ if found:
567
+ break
568
+ if not found:
569
+ return None # all white
570
+
571
+ right = w - 1
572
+ found = False
573
+ for x in range(w - 1, -1, -1):
574
+ for y in range(h):
575
+ if not is_bg(img.pixelColor(x, y)):
576
+ right = x
577
+ found = True
578
+ break
579
+ if found:
580
+ break
581
+
582
+ top = 0
583
+ found = False
584
+ for y in range(h):
585
+ for x in range(left, right + 1):
586
+ if not is_bg(img.pixelColor(x, y)):
587
+ top = y
588
+ found = True
589
+ break
590
+ if found:
591
+ break
592
+
593
+ bottom = h - 1
594
+ found = False
595
+ for y in range(h - 1, -1, -1):
596
+ for x in range(left, right + 1):
597
+ if not is_bg(img.pixelColor(x, y)):
598
+ bottom = y
599
+ found = True
600
+ break
601
+ if found:
602
+ break
603
+
604
+ if right < left or bottom < top:
605
+ return None
606
+
607
+ return QRect(left, top, right - left + 1, bottom - top + 1)
608
+
227
609
  # ---------- Public API (clipboard, file, actions) ----------
228
610
 
229
611
  def handle_paste(self):
@@ -233,12 +615,10 @@ class PainterWidget(QWidget):
233
615
  if source.hasImage():
234
616
  image = clipboard.image()
235
617
  if isinstance(image, QImage):
236
- # paste should create custom canvas with image size
237
618
  self.set_image(image, fit_canvas_to_image=True)
238
619
 
239
620
  def handle_copy(self):
240
621
  """Handle clipboard copy"""
241
- # ensure composited image is up-to-date
242
622
  self._recompose()
243
623
  clipboard = QApplication.clipboard()
244
624
  clipboard.setImage(self.image)
@@ -251,6 +631,7 @@ class PainterWidget(QWidget):
251
631
  """
252
632
  self._act_undo.setEnabled(self.has_undo())
253
633
  self._act_redo.setEnabled(self.has_redo())
634
+ self._act_fit.setEnabled(self._is_fit_available())
254
635
 
255
636
  clipboard = QApplication.clipboard()
256
637
  mime_data = clipboard.mimeData()
@@ -276,7 +657,6 @@ class PainterWidget(QWidget):
276
657
 
277
658
  def action_save(self):
278
659
  """Save image to file"""
279
- # ensure composited image is up-to-date
280
660
  self._recompose()
281
661
  name = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + ".png"
282
662
  path, _ = QFileDialog.getSaveFileName(
@@ -304,32 +684,31 @@ class PainterWidget(QWidget):
304
684
  if img.isNull():
305
685
  QMessageBox.information(self, "Image Loader", "Cannot load file.")
306
686
  return
307
- # Treat opening as loading a new original; resize canvas to image size (custom)
308
687
  self.set_image(img, fit_canvas_to_image=True)
309
688
 
310
689
  def load_flat_image(self, path):
311
690
  """
312
691
  Load a flat image from file as current source.
313
692
  This is used for session restore; it does not enforce canvas resize now.
693
+
694
+ :param path: Path to image
314
695
  """
315
696
  img = QImage(path)
316
697
  if img.isNull():
317
698
  return
318
- # Do not change canvas size here; setup() will follow with change_canvas_size().
319
699
  self.sourceImageOriginal = QImage(img)
320
- # Rebuild layers for current canvas (if any size already set)
321
- if self.width() > 0 and self.height() > 0:
700
+ if self._canvasSize.width() > 0 and self._canvasSize.height() > 0:
322
701
  self._ensure_layers()
323
702
  self._rescale_base_from_source()
324
703
  self.drawingLayer.fill(Qt.transparent)
325
704
  self._recompose()
326
705
  else:
327
- # defer until resize arrives
328
706
  pass
329
707
 
330
708
  def set_image(self, image, fit_canvas_to_image: bool = False):
331
709
  """
332
710
  Set image (as new original source)
711
+
333
712
  :param image: Image
334
713
  :param fit_canvas_to_image: True = set canvas size to image size (custom)
335
714
  """
@@ -338,11 +717,9 @@ class PainterWidget(QWidget):
338
717
  self.saveForUndo()
339
718
  self.sourceImageOriginal = QImage(image)
340
719
  if fit_canvas_to_image:
341
- # set custom canvas size to image size
342
720
  w, h = image.width(), image.height()
343
721
  self.window.controller.painter.common.change_canvas_size(f"{w}x{h}")
344
722
  else:
345
- # just rebuild within current canvas
346
723
  self._ensure_layers()
347
724
  self._rescale_base_from_source()
348
725
  self.drawingLayer.fill(Qt.transparent)
@@ -352,6 +729,7 @@ class PainterWidget(QWidget):
352
729
  def scale_to_fit(self, image):
353
730
  """
354
731
  Backward-compatibility wrapper. Uses layered model now.
732
+
355
733
  :param image: Image
356
734
  """
357
735
  self.set_image(image, fit_canvas_to_image=False)
@@ -360,7 +738,6 @@ class PainterWidget(QWidget):
360
738
 
361
739
  def saveForUndo(self):
362
740
  """Save current state for undo"""
363
- # Ensure layers up-to-date before snapshot
364
741
  self._ensure_layers()
365
742
  self._recompose()
366
743
  self.undoStack.append(self._snapshot_state())
@@ -373,7 +750,6 @@ class PainterWidget(QWidget):
373
750
  self.redoStack.append(current)
374
751
  state = self.undoStack.pop()
375
752
  self._apply_state(state)
376
- # Keep size combo in sync with restored canvas and source (handles sticky custom)
377
753
  if self.window and hasattr(self.window, "controller"):
378
754
  self.window.controller.painter.common.sync_canvas_combo_from_widget()
379
755
 
@@ -384,28 +760,123 @@ class PainterWidget(QWidget):
384
760
  self.undoStack.append(current)
385
761
  state = self.redoStack.pop()
386
762
  self._apply_state(state)
387
- # Keep size combo in sync with restored canvas and source (handles sticky custom)
388
763
  if self.window and hasattr(self.window, "controller"):
389
764
  self.window.controller.painter.common.sync_canvas_combo_from_widget()
390
765
 
391
766
  def has_undo(self) -> bool:
392
- """Check if undo is available"""
767
+ """
768
+ Check if undo is available
769
+
770
+ :return: True if undo is available
771
+ """
393
772
  return bool(self.undoStack)
394
773
 
395
774
  def has_redo(self) -> bool:
396
- """Check if redo is available"""
775
+ """
776
+ Check if redo is available
777
+
778
+ :return: True if redo is available
779
+ """
397
780
  return bool(self.redoStack)
398
781
 
782
+ def save_base(self, path: str, include_drawing: bool = False) -> bool:
783
+ """
784
+ Save high-quality base image:
785
+ - If an original source is present, saves that (cropped if crop was applied).
786
+ - If no source exists, falls back to saving the current composited canvas.
787
+ - When include_drawing=True, composites the stroke layer onto the original at original resolution.
788
+ Returns True on success.
789
+
790
+ :param path: Path to save
791
+ :param include_drawing: Whether to include drawing layer
792
+ :return: True on success
793
+ """
794
+ if not path:
795
+ return False
796
+
797
+ try:
798
+ os.makedirs(os.path.dirname(path), exist_ok=True)
799
+ except Exception:
800
+ pass
801
+
802
+ if self.sourceImageOriginal is not None and not self.sourceImageOriginal.isNull():
803
+ if not include_drawing:
804
+ return self._save_image_atomic(self.sourceImageOriginal, path)
805
+
806
+ src = QImage(self.sourceImageOriginal)
807
+ if self.drawingLayer is None or self.drawingLayer.isNull():
808
+ return self._save_image_atomic(src, path)
809
+
810
+ if self.baseTargetRect.isNull() or self.baseTargetRect.width() <= 0 or self.baseTargetRect.height() <= 0:
811
+ return self._save_image_atomic(src, path)
812
+
813
+ overlay_canvas_roi = self.drawingLayer.copy(self.baseTargetRect)
814
+ overlay_hi = overlay_canvas_roi.scaled(
815
+ src.size(),
816
+ Qt.IgnoreAspectRatio,
817
+ Qt.SmoothTransformation
818
+ )
819
+
820
+ result = QImage(src)
821
+ p = QPainter(result)
822
+ p.setRenderHint(QPainter.Antialiasing, True)
823
+ p.setCompositionMode(QPainter.CompositionMode_SourceOver)
824
+ p.drawImage(QPoint(0, 0), overlay_hi)
825
+ p.end()
826
+
827
+ return self._save_image_atomic(result, path)
828
+
829
+ self._recompose()
830
+ return self._save_image_atomic(self.image, path)
831
+
832
+ def _save_image_atomic(self, img: QImage, path: str, fmt: str = None, quality: int = -1) -> bool:
833
+ """
834
+ Save an image atomically using QSaveFile. Returns True on success.
835
+
836
+ :param img: Image
837
+ :param path: Path to save
838
+ :param fmt: Format (e.g. 'PNG', 'JPEG'); if None, inferred from file extension
839
+ :param quality: Quality (0-100) or -1 for default
840
+ :return: True on success
841
+ """
842
+ if img is None or img.isNull() or not path:
843
+ return False
844
+
845
+ if fmt is None:
846
+ ext = os.path.splitext(path)[1].lower()
847
+ if ext in ('.jpg', '.jpeg'):
848
+ fmt = 'JPEG'
849
+ elif ext == '.bmp':
850
+ fmt = 'BMP'
851
+ elif ext == '.webp':
852
+ fmt = 'WEBP'
853
+ elif ext in ('.tif', '.tiff'):
854
+ fmt = 'TIFF'
855
+ else:
856
+ fmt = 'PNG'
857
+
858
+ f = QSaveFile(path)
859
+ if not f.open(QIODevice.WriteOnly):
860
+ return False
861
+
862
+ ok = img.save(f, fmt, quality)
863
+ if not ok:
864
+ f.cancelWriting()
865
+ return False
866
+
867
+ return f.commit()
868
+
399
869
  # ---------- Brush/eraser ----------
400
870
 
401
871
  def set_mode(self, mode: str):
402
872
  """
403
873
  Set painting mode: "brush" or "erase"
874
+
875
+ :param mode: Mode
404
876
  """
405
877
  if mode not in ("brush", "erase"):
406
878
  return
407
879
  self._mode = mode
408
- # cursor hint
409
880
  if self._mode == "erase":
410
881
  self.setCursor(QCursor(Qt.PointingHandCursor))
411
882
  else:
@@ -453,35 +924,31 @@ class PainterWidget(QWidget):
453
924
  self.cropping = False
454
925
  self._selecting = False
455
926
  self._selectionRect = QRect()
927
+ self._stop_autoscroll()
456
928
  self.unsetCursor()
457
929
  self.update()
458
930
 
459
931
  def _finalize_crop(self):
460
932
  """Finalize crop with current selection rectangle."""
933
+ self._stop_autoscroll()
461
934
  if not self.cropping or self._selectionRect.isNull() or self._selectionRect.width() <= 1 or self._selectionRect.height() <= 1:
462
935
  self.cancel_crop()
463
936
  return
464
937
 
465
938
  self._ensure_layers()
466
939
  sel = self._selectionRect.normalized()
467
- # Keep previous state for undo
468
- # saveForUndo called on mousePress at crop start
469
940
 
470
- # Crop base and drawing layers to selection
471
941
  new_base = self.baseCanvas.copy(sel)
472
942
  new_draw = self.drawingLayer.copy(sel)
473
943
 
474
- # Prepare to apply exact cropped pixels after resize event
475
944
  self._pendingResizeApply = {
476
945
  'base': QImage(new_base),
477
946
  'draw': QImage(new_draw),
478
947
  }
479
948
 
480
- # Update original source to cropped region for future high-quality resizes
481
949
  if self.sourceImageOriginal is not None and not self.baseTargetRect.isNull():
482
950
  inter = sel.intersected(self.baseTargetRect)
483
951
  if inter.isValid() and not inter.isNull():
484
- # Map intersection rect to original source coordinates
485
952
  sx_ratio = self.sourceImageOriginal.width() / self.baseTargetRect.width()
486
953
  sy_ratio = self.sourceImageOriginal.height() / self.baseTargetRect.height()
487
954
 
@@ -492,7 +959,6 @@ class PainterWidget(QWidget):
492
959
  sy = max(0, int(dy * sy_ratio))
493
960
  sw = max(1, int(inter.width() * sx_ratio))
494
961
  sh = max(1, int(inter.height() * sy_ratio))
495
- # Clip
496
962
  if sx + sw > self.sourceImageOriginal.width():
497
963
  sw = self.sourceImageOriginal.width() - sx
498
964
  if sy + sh > self.sourceImageOriginal.height():
@@ -502,24 +968,141 @@ class PainterWidget(QWidget):
502
968
  else:
503
969
  self.sourceImageOriginal = None
504
970
  else:
505
- # Selection outside of image; keep no source
506
971
  self.sourceImageOriginal = None
507
972
  else:
508
- # No original source, nothing to update
509
973
  pass
510
974
 
511
- # Resize canvas to selection size; resizeEvent will apply _pendingResizeApply
512
975
  self.cropping = False
513
976
  self._selecting = False
514
977
  self._selectionRect = QRect()
515
978
  self.unsetCursor()
516
979
 
517
- # Perform canvas resize (custom)
518
980
  self.window.controller.painter.common.change_canvas_size(f"{sel.width()}x{sel.height()}")
519
981
  self.update()
520
982
 
983
+ # ---------- Auto-scroll while cropping (for scroll areas) ----------
984
+
985
+ def _find_scroll_area(self):
986
+ """Locate the nearest ancestor QAbstractScrollArea and cache references."""
987
+ w = self.parentWidget()
988
+ area = None
989
+ while w is not None:
990
+ if isinstance(w, QAbstractScrollArea):
991
+ area = w
992
+ break
993
+ w = w.parentWidget()
994
+ self._scrollArea = area
995
+ self._scrollViewport = area.viewport() if area is not None else None
996
+
997
+ def _calc_scroll_step(self, dist_to_edge: int, margin: int) -> int:
998
+ """
999
+ Compute a smooth step size (px per tick) based on proximity to the edge.
1000
+ Closer to the edge -> faster scroll, clamped to configured limits.
1001
+
1002
+ :param dist_to_edge: Distance to the edge in pixels (0 = at edge)
1003
+ :param margin: Margin in pixels where autoscroll is active
1004
+ :return: Step size in pixels (positive integer)
1005
+ """
1006
+ if dist_to_edge < 0:
1007
+ dist_to_edge = 0
1008
+ if margin <= 0:
1009
+ return self._autoScrollMinSpeed
1010
+ ratio = 1.0 - min(1.0, dist_to_edge / float(margin))
1011
+ step = self._autoScrollMinSpeed + ratio * (self._autoScrollMaxSpeed - self._autoScrollMinSpeed)
1012
+ return max(self._autoScrollMinSpeed, min(self._autoScrollMaxSpeed, int(step)))
1013
+
1014
+ def _start_autoscroll(self):
1015
+ """Start autoscroll timer if inside a scroll area and cropping is active."""
1016
+ self._find_scroll_area()
1017
+ if self._scrollArea is not None and self._scrollViewport is not None:
1018
+ if not self._autoScrollTimer.isActive():
1019
+ self._autoScrollTimer.start()
1020
+
1021
+ def _stop_autoscroll(self):
1022
+ """Stop autoscroll timer and release mouse if grabbed."""
1023
+ if self._autoScrollTimer.isActive():
1024
+ self._autoScrollTimer.stop()
1025
+ self.releaseMouse()
1026
+
1027
+ def _autoscroll_tick(self):
1028
+ """
1029
+ Periodic autoscroll while user drags the crop selection near viewport edges.
1030
+ Uses global cursor position -> viewport coords -> scrollbars.
1031
+ Also updates current selection end in widget coordinates.
1032
+ """
1033
+ if not (self.cropping and self._selecting):
1034
+ self._stop_autoscroll()
1035
+ return
1036
+ if self._scrollArea is None or self._scrollViewport is None:
1037
+ return
1038
+
1039
+ vp = self._scrollViewport
1040
+ area = self._scrollArea
1041
+
1042
+ global_pos = QCursor.pos()
1043
+ pos_vp = vp.mapFromGlobal(global_pos)
1044
+
1045
+ margin = self._autoScrollMargin
1046
+ dx = 0
1047
+ dy = 0
1048
+
1049
+ if pos_vp.x() < margin:
1050
+ dx = -self._calc_scroll_step(pos_vp.x(), margin)
1051
+ elif pos_vp.x() > vp.width() - margin:
1052
+ dist = max(0, vp.width() - pos_vp.x())
1053
+ dx = self._calc_scroll_step(dist, margin)
1054
+
1055
+ if pos_vp.y() < margin:
1056
+ dy = -self._calc_scroll_step(pos_vp.y(), margin)
1057
+ elif pos_vp.y() > vp.height() - margin:
1058
+ dist = max(0, vp.height() - pos_vp.y())
1059
+ dy = self._calc_scroll_step(dist, margin)
1060
+
1061
+ scrolled = False
1062
+ if dx != 0:
1063
+ hbar = area.horizontalScrollBar()
1064
+ if hbar is not None and hbar.maximum() > hbar.minimum():
1065
+ newv = max(hbar.minimum(), min(hbar.maximum(), hbar.value() + dx))
1066
+ if newv != hbar.value():
1067
+ hbar.setValue(newv)
1068
+ scrolled = True
1069
+
1070
+ if dy != 0:
1071
+ vbar = area.verticalScrollBar()
1072
+ if vbar is not None and vbar.maximum() > vbar.minimum():
1073
+ newv = max(vbar.minimum(), min(vbar.maximum(), vbar.value() + dy))
1074
+ if newv != vbar.value():
1075
+ vbar.setValue(newv)
1076
+ scrolled = True
1077
+
1078
+ if self._selecting:
1079
+ pos_widget = self.mapFromGlobal(global_pos)
1080
+ cx = min(max(0, pos_widget.x()), max(0, self.width() - 1))
1081
+ cy = min(max(0, pos_widget.y()), max(0, self.height() - 1))
1082
+ cpt = self._to_canvas_point(QPoint(cx, cy))
1083
+ self._selectionRect = QRect(self._selectionStart, cpt)
1084
+ if scrolled or dx != 0 or dy != 0:
1085
+ self.update()
1086
+
521
1087
  # ---------- Events ----------
522
1088
 
1089
+ def wheelEvent(self, event):
1090
+ """
1091
+ CTRL + wheel => zoom. Regular scrolling falls back to default behavior.
1092
+
1093
+ :param event: Event
1094
+ """
1095
+ mods = event.modifiers()
1096
+ if mods & Qt.ControlModifier:
1097
+ delta = event.angleDelta().y()
1098
+ if delta > 0:
1099
+ self.zoom_in_step()
1100
+ elif delta < 0:
1101
+ self.zoom_out_step()
1102
+ event.accept()
1103
+ return
1104
+ super().wheelEvent(event)
1105
+
523
1106
  def mousePressEvent(self, event):
524
1107
  """
525
1108
  Mouse press event
@@ -531,15 +1114,16 @@ class PainterWidget(QWidget):
531
1114
  if self.cropping:
532
1115
  self.saveForUndo()
533
1116
  self._selecting = True
534
- self._selectionStart = event.pos()
1117
+ self._selectionStart = self._to_canvas_point(event.position())
535
1118
  self._selectionRect = QRect(self._selectionStart, self._selectionStart)
536
1119
  self.update()
1120
+ self.grabMouse()
1121
+ self._start_autoscroll()
537
1122
  return
538
1123
 
539
- # painting
540
1124
  self._ensure_layers()
541
1125
  self.drawing = True
542
- self.lastPoint = event.pos()
1126
+ self.lastPointCanvas = self._to_canvas_point(event.position())
543
1127
  self.saveForUndo()
544
1128
 
545
1129
  p = QPainter(self.drawingLayer)
@@ -551,7 +1135,7 @@ class PainterWidget(QWidget):
551
1135
  else:
552
1136
  p.setCompositionMode(QPainter.CompositionMode_SourceOver)
553
1137
  p.setPen(self._pen)
554
- p.drawPoint(self.lastPoint)
1138
+ p.drawPoint(self.lastPointCanvas)
555
1139
  p.end()
556
1140
  self._recompose()
557
1141
  self.update()
@@ -563,12 +1147,13 @@ class PainterWidget(QWidget):
563
1147
  :param event: Event
564
1148
  """
565
1149
  if self.cropping and self._selecting and (event.buttons() & Qt.LeftButton):
566
- self._selectionRect = QRect(self._selectionStart, event.pos())
1150
+ self._selectionRect = QRect(self._selectionStart, self._to_canvas_point(event.position()))
567
1151
  self.update()
568
1152
  return
569
1153
 
570
1154
  if (event.buttons() & Qt.LeftButton) and self.drawing:
571
1155
  self._ensure_layers()
1156
+ cur = self._to_canvas_point(event.position())
572
1157
  p = QPainter(self.drawingLayer)
573
1158
  p.setRenderHint(QPainter.Antialiasing, True)
574
1159
  if self._mode == "erase":
@@ -578,9 +1163,9 @@ class PainterWidget(QWidget):
578
1163
  else:
579
1164
  p.setCompositionMode(QPainter.CompositionMode_SourceOver)
580
1165
  p.setPen(self._pen)
581
- p.drawLine(self.lastPoint, event.pos())
1166
+ p.drawLine(self.lastPointCanvas, cur)
582
1167
  p.end()
583
- self.lastPoint = event.pos()
1168
+ self.lastPointCanvas = cur
584
1169
  self._recompose()
585
1170
  self.update()
586
1171
 
@@ -607,11 +1192,9 @@ class PainterWidget(QWidget):
607
1192
  elif event.key() == Qt.Key_V and QApplication.keyboardModifiers() == Qt.ControlModifier:
608
1193
  self.handle_paste()
609
1194
  elif event.key() in (Qt.Key_Return, Qt.Key_Enter):
610
- # finalize crop with Enter
611
1195
  if self.cropping and self._selecting:
612
1196
  self._finalize_crop()
613
1197
  elif event.key() == Qt.Key_Escape:
614
- # cancel crop
615
1198
  if self.cropping:
616
1199
  self.cancel_crop()
617
1200
 
@@ -621,64 +1204,79 @@ class PainterWidget(QWidget):
621
1204
 
622
1205
  :param event: Event
623
1206
  """
624
- # Ensure final composition is valid
625
- if self.image.size() != self.size():
1207
+ if self.image.size() != self._canvasSize:
626
1208
  self._ensure_layers()
627
1209
  self._rescale_base_from_source()
628
1210
  self._recompose()
629
1211
 
630
1212
  p = QPainter(self)
1213
+ # Draw composited canvas scaled to display rect
631
1214
  p.drawImage(self.rect(), self.image, self.image.rect())
632
1215
 
633
- # Draw crop overlay if active
1216
+ # Draw crop overlay if active (convert canvas selection to display coords)
634
1217
  if self.cropping and not self._selectionRect.isNull():
635
1218
  sel = self._selectionRect.normalized()
1219
+ sel_view = self._from_canvas_rect(sel)
636
1220
  overlay = QColor(0, 0, 0, 120)
637
1221
  W, H = self.width(), self.height()
638
1222
 
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
1223
+ if sel_view.left() > 0:
1224
+ p.fillRect(0, 0, sel_view.left(), H, overlay)
1225
+ if sel_view.right() < W - 1:
1226
+ p.fillRect(sel_view.right() + 1, 0, W - (sel_view.right() + 1), H, overlay)
1227
+ if sel_view.top() > 0:
1228
+ p.fillRect(sel_view.left(), 0, sel_view.width(), sel_view.top(), overlay)
1229
+ if sel_view.bottom() < H - 1:
1230
+ p.fillRect(sel_view.left(), sel_view.bottom() + 1, sel_view.width(), H - (sel_view.bottom() + 1), overlay)
1231
+
653
1232
  p.setPen(QPen(QColor(255, 255, 255, 200), 1, Qt.DashLine))
654
- p.drawRect(sel.adjusted(0, 0, -1, -1))
1233
+ p.drawRect(sel_view.adjusted(0, 0, -1, -1))
655
1234
 
656
1235
  p.end()
657
1236
  self.originalImage = self.image
658
1237
 
659
1238
  def resizeEvent(self, event):
660
1239
  """
661
- Update layers on resize
1240
+ Update layers on canvas size change; ignore display-only resizes from zoom.
662
1241
 
663
1242
  :param event: Event
664
1243
  """
665
- if self._ignoreResizeOnce:
666
- return super().resizeEvent(event)
1244
+ new_widget_size = event.size()
1245
+ expected_display = QSize(max(1, int(round(self._canvasSize.width() * self.zoom))),
1246
+ max(1, int(round(self._canvasSize.height() * self.zoom))))
1247
+
1248
+ # External canvas resize (e.g. controller.change_canvas_size -> setFixedSize(canvas))
1249
+ if new_widget_size != expected_display and not self._zoomResizeInProgress:
1250
+ old_canvas = QSize(self._canvasSize)
1251
+ # Adopt widget size as the new logical canvas size
1252
+ self._canvasSize = QSize(new_widget_size)
1253
+ self._handle_canvas_resized(old_canvas, self._canvasSize)
1254
+ # After canvas change, enforce current zoom on the display size
1255
+ self._update_widget_size_from_zoom()
1256
+ super().resizeEvent(event)
1257
+ return
1258
+
1259
+ # Display-only resize caused by zoom update: nothing to do with buffers
1260
+ self.update()
1261
+ super().resizeEvent(event)
667
1262
 
668
- old_size = event.oldSize()
669
- new_size = event.size()
1263
+ def _handle_canvas_resized(self, old_size: QSize, new_size: QSize):
1264
+ """
1265
+ Apply buffer updates when the logical canvas size changes.
670
1266
 
671
- # Allocate new layers and recompose
1267
+ :param old_size: Previous canvas size
1268
+ :param new_size: New canvas size
1269
+ """
672
1270
  self._ensure_layers()
673
1271
 
674
1272
  if self._pendingResizeApply is not None:
675
- # Apply exact cropped pixels to new canvas size; center if differs
676
1273
  new_base = self._pendingResizeApply.get('base')
677
1274
  new_draw = self._pendingResizeApply.get('draw')
678
1275
 
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.
1276
+ # Reset layers to new canvas size
1277
+ self.baseCanvas = QImage(new_size, QImage.Format_RGB32)
681
1278
  self.baseCanvas.fill(Qt.white)
1279
+ self.drawingLayer = QImage(new_size, QImage.Format_ARGB32_Premultiplied)
682
1280
  self.drawingLayer.fill(Qt.transparent)
683
1281
 
684
1282
  if new_base is not None:
@@ -702,20 +1300,18 @@ class PainterWidget(QWidget):
702
1300
  p.end()
703
1301
 
704
1302
  self._pendingResizeApply = None
705
- # baseTargetRect becomes entire canvas if new_base filled it; otherwise keep unknown
706
1303
  self.baseTargetRect = QRect(0, 0, self.baseCanvas.width(), self.baseCanvas.height())
707
1304
  else:
708
- # Standard path: rebuild base from original source to avoid quality loss
1305
+ # Rebuild background from original source
709
1306
  self._rescale_base_from_source()
710
1307
 
711
- # Scale drawing layer content to new size (best effort). This may introduce minor quality loss, acceptable.
1308
+ # Scale drawing content to new canvas size if previous canvas was valid
712
1309
  if old_size.isValid() and (old_size.width() > 0 and old_size.height() > 0) and \
713
1310
  (self.drawingLayer is not None) and (self.drawingLayer.size() != new_size):
714
1311
  self.drawingLayer = self.drawingLayer.scaled(new_size, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
715
1312
 
716
1313
  self._recompose()
717
1314
  self.update()
718
- super().resizeEvent(event)
719
1315
 
720
1316
  def eventFilter(self, source, event):
721
1317
  """