pygpt-net 2.6.33__py3-none-any.whl → 2.6.36__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 (64) hide show
  1. pygpt_net/CHANGELOG.txt +18 -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/common.py +58 -48
  8. pygpt_net/controller/chat/handler/stream_worker.py +55 -43
  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 +243 -13
  13. pygpt_net/controller/painter/painter.py +11 -2
  14. pygpt_net/core/assistants/files.py +18 -0
  15. pygpt_net/core/bridge/bridge.py +1 -5
  16. pygpt_net/core/bridge/context.py +81 -36
  17. pygpt_net/core/bridge/worker.py +3 -1
  18. pygpt_net/core/camera/camera.py +31 -402
  19. pygpt_net/core/camera/worker.py +430 -0
  20. pygpt_net/core/ctx/bag.py +4 -0
  21. pygpt_net/core/events/app.py +10 -17
  22. pygpt_net/core/events/base.py +17 -25
  23. pygpt_net/core/events/control.py +9 -17
  24. pygpt_net/core/events/event.py +9 -62
  25. pygpt_net/core/events/kernel.py +8 -17
  26. pygpt_net/core/events/realtime.py +8 -17
  27. pygpt_net/core/events/render.py +9 -17
  28. pygpt_net/core/filesystem/url.py +3 -0
  29. pygpt_net/core/render/web/body.py +483 -40
  30. pygpt_net/core/render/web/pid.py +39 -24
  31. pygpt_net/core/render/web/renderer.py +142 -36
  32. pygpt_net/core/text/utils.py +3 -0
  33. pygpt_net/data/config/config.json +4 -3
  34. pygpt_net/data/config/models.json +3 -3
  35. pygpt_net/data/config/settings.json +10 -5
  36. pygpt_net/data/css/web-blocks.css +4 -3
  37. pygpt_net/data/css/web-chatgpt.css +4 -2
  38. pygpt_net/data/css/web-chatgpt_wide.css +4 -2
  39. pygpt_net/data/locale/locale.de.ini +9 -7
  40. pygpt_net/data/locale/locale.en.ini +10 -6
  41. pygpt_net/data/locale/locale.es.ini +9 -7
  42. pygpt_net/data/locale/locale.fr.ini +9 -7
  43. pygpt_net/data/locale/locale.it.ini +9 -7
  44. pygpt_net/data/locale/locale.pl.ini +9 -7
  45. pygpt_net/data/locale/locale.uk.ini +9 -7
  46. pygpt_net/data/locale/locale.zh.ini +9 -7
  47. pygpt_net/item/assistant.py +13 -1
  48. pygpt_net/provider/api/google/__init__.py +46 -28
  49. pygpt_net/provider/api/openai/__init__.py +13 -10
  50. pygpt_net/provider/api/openai/store.py +45 -1
  51. pygpt_net/provider/core/config/patch.py +18 -0
  52. pygpt_net/provider/llms/google.py +4 -0
  53. pygpt_net/ui/dialog/assistant_store.py +213 -203
  54. pygpt_net/ui/layout/chat/input.py +3 -3
  55. pygpt_net/ui/layout/chat/painter.py +63 -4
  56. pygpt_net/ui/widget/draw/painter.py +715 -104
  57. pygpt_net/ui/widget/option/combo.py +5 -1
  58. pygpt_net/ui/widget/textarea/input.py +273 -3
  59. pygpt_net/ui/widget/textarea/web.py +2 -0
  60. {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.36.dist-info}/METADATA +20 -2
  61. {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.36.dist-info}/RECORD +64 -63
  62. {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.36.dist-info}/LICENSE +0 -0
  63. {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.36.dist-info}/WHEEL +0 -0
  64. {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.36.dist-info}/entry_points.txt +0 -0
@@ -1,23 +1,49 @@
1
- # ui/widget.painer.py
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.09.02 15:00:00 #
10
+ # ================================================== #
2
11
 
3
12
  import datetime
13
+ import os
14
+ import bisect
4
15
  from collections import deque
5
16
 
6
- from PySide6.QtCore import Qt, QPoint, QRect, QSize
17
+ from PySide6.QtCore import Qt, QPoint, QPointF, QRect, QSize, QSaveFile, QIODevice, QTimer, Signal
7
18
  from PySide6.QtGui import QImage, QPainter, QPen, QAction, QIcon, QColor, QCursor
8
- from PySide6.QtWidgets import QMenu, QWidget, QFileDialog, QMessageBox, QApplication
19
+ from PySide6.QtWidgets import QMenu, QWidget, QFileDialog, QMessageBox, QApplication, QAbstractScrollArea
9
20
 
10
21
  from pygpt_net.core.tabs.tab import Tab
11
22
  from pygpt_net.utils import trans
12
23
 
13
24
 
14
25
  class PainterWidget(QWidget):
26
+ # Emitted whenever zoom changes; payload is zoom factor (e.g. 1.0 for 100%)
27
+ zoomChanged = Signal(float)
28
+
15
29
  def __init__(self, window=None):
16
30
  super().__init__(window)
17
31
  self.window = window
18
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
+
19
45
  # Final composited image (canvas-sized). Kept for API compatibility.
20
- self.image = QImage(self.size(), QImage.Format_RGB32)
46
+ self.image = QImage(self._canvasSize, QImage.Format_RGB32)
21
47
 
22
48
  # Layered model:
23
49
  # - sourceImageOriginal: original background image (full quality, not canvas-sized).
@@ -34,10 +60,10 @@ class PainterWidget(QWidget):
34
60
  self.brushSize = 3
35
61
  self.brushColor = Qt.black
36
62
  self._mode = "brush" # "brush" or "erase"
37
- self.lastPoint = QPoint()
63
+ self.lastPointCanvas = QPoint()
38
64
  self._pen = QPen(self.brushColor, self.brushSize, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
39
65
 
40
- # Crop tool state
66
+ # Crop tool state (selection kept in canvas coordinates)
41
67
  self.cropping = False
42
68
  self._selecting = False
43
69
  self._selectionStart = QPoint()
@@ -52,6 +78,7 @@ class PainterWidget(QWidget):
52
78
  self.setFocusPolicy(Qt.StrongFocus)
53
79
  self.setFocus()
54
80
  self.installEventFilter(self)
81
+
55
82
  self.tab = None
56
83
 
57
84
  self.setAttribute(Qt.WA_OpaquePaintEvent, True)
@@ -61,6 +88,16 @@ class PainterWidget(QWidget):
61
88
  self._pendingResizeApply = None # payload used after crop to apply exact pixels on resize
62
89
  self._ignoreResizeOnce = False # guard to prevent recursive work in resize path
63
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
+
64
101
  # Actions
65
102
  self._act_undo = QAction(QIcon(":/icons/undo.svg"), trans('action.undo'), self)
66
103
  self._act_undo.triggered.connect(self.undo)
@@ -90,12 +127,18 @@ class PainterWidget(QWidget):
90
127
  self._act_crop = QAction(QIcon(":/icons/crop.svg"), trans('painter.btn.crop') if trans('painter.btn.crop') else "Crop", self)
91
128
  self._act_crop.triggered.connect(self.start_crop)
92
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
+
93
134
  # Context menu
94
135
  self._ctx_menu = QMenu(self)
95
136
  self._ctx_menu.addAction(self._act_undo)
96
137
  self._ctx_menu.addAction(self._act_redo)
97
138
  self._ctx_menu.addSeparator()
98
139
  self._ctx_menu.addAction(self._act_crop)
140
+ self._ctx_menu.addAction(self._act_fit)
141
+ self._ctx_menu.addSeparator()
99
142
  self._ctx_menu.addSeparator()
100
143
  self._ctx_menu.addAction(self._act_open)
101
144
  self._ctx_menu.addAction(self._act_capture)
@@ -104,6 +147,12 @@ class PainterWidget(QWidget):
104
147
  self._ctx_menu.addAction(self._act_save)
105
148
  self._ctx_menu.addAction(self._act_clear)
106
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
+
107
156
  def set_tab(self, tab: Tab):
108
157
  """
109
158
  Set tab
@@ -112,11 +161,235 @@ class PainterWidget(QWidget):
112
161
  """
113
162
  self.tab = tab
114
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
+
115
388
  # ---------- Layer & composition helpers ----------
116
389
 
117
390
  def _ensure_layers(self):
118
391
  """Ensure baseCanvas, drawingLayer, and image are allocated to current canvas size."""
119
- sz = self.size()
392
+ sz = self._canvasSize
120
393
  if sz.width() <= 0 or sz.height() <= 0:
121
394
  return
122
395
 
@@ -133,20 +406,16 @@ class PainterWidget(QWidget):
133
406
  self.image.fill(Qt.white)
134
407
 
135
408
  def _rescale_base_from_source(self):
136
- """
137
- Rebuild baseCanvas from sourceImageOriginal to fit current canvas, preserving aspect ratio.
138
- """
409
+ """Rebuild baseCanvas from sourceImageOriginal to fit current canvas, preserving aspect ratio."""
139
410
  self._ensure_layers()
140
411
  self.baseCanvas.fill(Qt.white)
141
412
  self.baseTargetRect = QRect()
142
413
  if self.sourceImageOriginal is None or self.sourceImageOriginal.isNull():
143
414
  return
144
415
 
145
- canvas_size = self.size()
416
+ canvas_size = self._canvasSize
146
417
  src = self.sourceImageOriginal
147
- # Compute scaled size that fits within the canvas (max width/height)
148
418
  scaled_size = src.size().scaled(canvas_size, Qt.KeepAspectRatio)
149
- # Center the image within the canvas
150
419
  x = (canvas_size.width() - scaled_size.width()) // 2
151
420
  y = (canvas_size.height() - scaled_size.height()) // 2
152
421
  self.baseTargetRect = QRect(x, y, scaled_size.width(), scaled_size.height())
@@ -161,9 +430,7 @@ class PainterWidget(QWidget):
161
430
  self._ensure_layers()
162
431
  self.image.fill(Qt.white)
163
432
  p = QPainter(self.image)
164
- # draw background
165
433
  p.drawImage(QPoint(0, 0), self.baseCanvas)
166
- # draw drawing layer
167
434
  p.setCompositionMode(QPainter.CompositionMode_SourceOver)
168
435
  p.drawImage(QPoint(0, 0), self.drawingLayer)
169
436
  p.end()
@@ -176,45 +443,169 @@ class PainterWidget(QWidget):
176
443
  'base': QImage(self.baseCanvas) if self.baseCanvas is not None else None,
177
444
  'draw': QImage(self.drawingLayer) if self.drawingLayer is not None else None,
178
445
  'src': QImage(self.sourceImageOriginal) if self.sourceImageOriginal is not None else None,
179
- 'size': QSize(self.width(), self.height()),
446
+ 'canvas_size': QSize(self._canvasSize.width(), self._canvasSize.height()),
180
447
  'baseRect': QRect(self.baseTargetRect),
181
448
  }
182
449
  return state
183
450
 
184
451
  def _apply_state(self, state):
185
- """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
+ """
186
457
  if not state:
187
458
  return
188
- self._ignoreResizeOnce = True
189
- target_size = state['size']
190
459
 
191
- # Set canvas size if needed
192
- if target_size != self.size():
193
- 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)
194
464
 
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)
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)
199
468
 
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)
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)
205
474
 
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)
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()
211
487
 
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()
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
509
+
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
214
526
 
215
- self._ignoreResizeOnce = False
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
+ }
534
+
535
+ self.window.controller.painter.common.change_canvas_size(f"{fit_rect.width()}x{fit_rect.height()}")
216
536
  self.update()
217
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
+
218
609
  # ---------- Public API (clipboard, file, actions) ----------
219
610
 
220
611
  def handle_paste(self):
@@ -224,12 +615,10 @@ class PainterWidget(QWidget):
224
615
  if source.hasImage():
225
616
  image = clipboard.image()
226
617
  if isinstance(image, QImage):
227
- # paste should create custom canvas with image size
228
618
  self.set_image(image, fit_canvas_to_image=True)
229
619
 
230
620
  def handle_copy(self):
231
621
  """Handle clipboard copy"""
232
- # ensure composited image is up-to-date
233
622
  self._recompose()
234
623
  clipboard = QApplication.clipboard()
235
624
  clipboard.setImage(self.image)
@@ -242,6 +631,7 @@ class PainterWidget(QWidget):
242
631
  """
243
632
  self._act_undo.setEnabled(self.has_undo())
244
633
  self._act_redo.setEnabled(self.has_redo())
634
+ self._act_fit.setEnabled(self._is_fit_available())
245
635
 
246
636
  clipboard = QApplication.clipboard()
247
637
  mime_data = clipboard.mimeData()
@@ -267,7 +657,6 @@ class PainterWidget(QWidget):
267
657
 
268
658
  def action_save(self):
269
659
  """Save image to file"""
270
- # ensure composited image is up-to-date
271
660
  self._recompose()
272
661
  name = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + ".png"
273
662
  path, _ = QFileDialog.getSaveFileName(
@@ -295,32 +684,31 @@ class PainterWidget(QWidget):
295
684
  if img.isNull():
296
685
  QMessageBox.information(self, "Image Loader", "Cannot load file.")
297
686
  return
298
- # Treat opening as loading a new original; resize canvas to image size (custom)
299
687
  self.set_image(img, fit_canvas_to_image=True)
300
688
 
301
689
  def load_flat_image(self, path):
302
690
  """
303
691
  Load a flat image from file as current source.
304
692
  This is used for session restore; it does not enforce canvas resize now.
693
+
694
+ :param path: Path to image
305
695
  """
306
696
  img = QImage(path)
307
697
  if img.isNull():
308
698
  return
309
- # Do not change canvas size here; setup() will follow with change_canvas_size().
310
699
  self.sourceImageOriginal = QImage(img)
311
- # Rebuild layers for current canvas (if any size already set)
312
- if self.width() > 0 and self.height() > 0:
700
+ if self._canvasSize.width() > 0 and self._canvasSize.height() > 0:
313
701
  self._ensure_layers()
314
702
  self._rescale_base_from_source()
315
703
  self.drawingLayer.fill(Qt.transparent)
316
704
  self._recompose()
317
705
  else:
318
- # defer until resize arrives
319
706
  pass
320
707
 
321
708
  def set_image(self, image, fit_canvas_to_image: bool = False):
322
709
  """
323
710
  Set image (as new original source)
711
+
324
712
  :param image: Image
325
713
  :param fit_canvas_to_image: True = set canvas size to image size (custom)
326
714
  """
@@ -329,11 +717,9 @@ class PainterWidget(QWidget):
329
717
  self.saveForUndo()
330
718
  self.sourceImageOriginal = QImage(image)
331
719
  if fit_canvas_to_image:
332
- # set custom canvas size to image size
333
720
  w, h = image.width(), image.height()
334
721
  self.window.controller.painter.common.change_canvas_size(f"{w}x{h}")
335
722
  else:
336
- # just rebuild within current canvas
337
723
  self._ensure_layers()
338
724
  self._rescale_base_from_source()
339
725
  self.drawingLayer.fill(Qt.transparent)
@@ -343,6 +729,7 @@ class PainterWidget(QWidget):
343
729
  def scale_to_fit(self, image):
344
730
  """
345
731
  Backward-compatibility wrapper. Uses layered model now.
732
+
346
733
  :param image: Image
347
734
  """
348
735
  self.set_image(image, fit_canvas_to_image=False)
@@ -351,7 +738,6 @@ class PainterWidget(QWidget):
351
738
 
352
739
  def saveForUndo(self):
353
740
  """Save current state for undo"""
354
- # Ensure layers up-to-date before snapshot
355
741
  self._ensure_layers()
356
742
  self._recompose()
357
743
  self.undoStack.append(self._snapshot_state())
@@ -364,6 +750,8 @@ class PainterWidget(QWidget):
364
750
  self.redoStack.append(current)
365
751
  state = self.undoStack.pop()
366
752
  self._apply_state(state)
753
+ if self.window and hasattr(self.window, "controller"):
754
+ self.window.controller.painter.common.sync_canvas_combo_from_widget()
367
755
 
368
756
  def redo(self):
369
757
  """Redo the last undo action"""
@@ -372,25 +760,123 @@ class PainterWidget(QWidget):
372
760
  self.undoStack.append(current)
373
761
  state = self.redoStack.pop()
374
762
  self._apply_state(state)
763
+ if self.window and hasattr(self.window, "controller"):
764
+ self.window.controller.painter.common.sync_canvas_combo_from_widget()
375
765
 
376
766
  def has_undo(self) -> bool:
377
- """Check if undo is available"""
767
+ """
768
+ Check if undo is available
769
+
770
+ :return: True if undo is available
771
+ """
378
772
  return bool(self.undoStack)
379
773
 
380
774
  def has_redo(self) -> bool:
381
- """Check if redo is available"""
775
+ """
776
+ Check if redo is available
777
+
778
+ :return: True if redo is available
779
+ """
382
780
  return bool(self.redoStack)
383
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
+
384
869
  # ---------- Brush/eraser ----------
385
870
 
386
871
  def set_mode(self, mode: str):
387
872
  """
388
873
  Set painting mode: "brush" or "erase"
874
+
875
+ :param mode: Mode
389
876
  """
390
877
  if mode not in ("brush", "erase"):
391
878
  return
392
879
  self._mode = mode
393
- # cursor hint
394
880
  if self._mode == "erase":
395
881
  self.setCursor(QCursor(Qt.PointingHandCursor))
396
882
  else:
@@ -438,35 +924,31 @@ class PainterWidget(QWidget):
438
924
  self.cropping = False
439
925
  self._selecting = False
440
926
  self._selectionRect = QRect()
927
+ self._stop_autoscroll()
441
928
  self.unsetCursor()
442
929
  self.update()
443
930
 
444
931
  def _finalize_crop(self):
445
932
  """Finalize crop with current selection rectangle."""
933
+ self._stop_autoscroll()
446
934
  if not self.cropping or self._selectionRect.isNull() or self._selectionRect.width() <= 1 or self._selectionRect.height() <= 1:
447
935
  self.cancel_crop()
448
936
  return
449
937
 
450
938
  self._ensure_layers()
451
939
  sel = self._selectionRect.normalized()
452
- # Keep previous state for undo
453
- # saveForUndo called on mousePress at crop start
454
940
 
455
- # Crop base and drawing layers to selection
456
941
  new_base = self.baseCanvas.copy(sel)
457
942
  new_draw = self.drawingLayer.copy(sel)
458
943
 
459
- # Prepare to apply exact cropped pixels after resize event
460
944
  self._pendingResizeApply = {
461
945
  'base': QImage(new_base),
462
946
  'draw': QImage(new_draw),
463
947
  }
464
948
 
465
- # Update original source to cropped region for future high-quality resizes
466
949
  if self.sourceImageOriginal is not None and not self.baseTargetRect.isNull():
467
950
  inter = sel.intersected(self.baseTargetRect)
468
951
  if inter.isValid() and not inter.isNull():
469
- # Map intersection rect to original source coordinates
470
952
  sx_ratio = self.sourceImageOriginal.width() / self.baseTargetRect.width()
471
953
  sy_ratio = self.sourceImageOriginal.height() / self.baseTargetRect.height()
472
954
 
@@ -477,7 +959,6 @@ class PainterWidget(QWidget):
477
959
  sy = max(0, int(dy * sy_ratio))
478
960
  sw = max(1, int(inter.width() * sx_ratio))
479
961
  sh = max(1, int(inter.height() * sy_ratio))
480
- # Clip
481
962
  if sx + sw > self.sourceImageOriginal.width():
482
963
  sw = self.sourceImageOriginal.width() - sx
483
964
  if sy + sh > self.sourceImageOriginal.height():
@@ -487,24 +968,141 @@ class PainterWidget(QWidget):
487
968
  else:
488
969
  self.sourceImageOriginal = None
489
970
  else:
490
- # Selection outside of image; keep no source
491
971
  self.sourceImageOriginal = None
492
972
  else:
493
- # No original source, nothing to update
494
973
  pass
495
974
 
496
- # Resize canvas to selection size; resizeEvent will apply _pendingResizeApply
497
975
  self.cropping = False
498
976
  self._selecting = False
499
977
  self._selectionRect = QRect()
500
978
  self.unsetCursor()
501
979
 
502
- # Perform canvas resize (custom)
503
980
  self.window.controller.painter.common.change_canvas_size(f"{sel.width()}x{sel.height()}")
504
981
  self.update()
505
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
+
506
1087
  # ---------- Events ----------
507
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
+
508
1106
  def mousePressEvent(self, event):
509
1107
  """
510
1108
  Mouse press event
@@ -516,15 +1114,16 @@ class PainterWidget(QWidget):
516
1114
  if self.cropping:
517
1115
  self.saveForUndo()
518
1116
  self._selecting = True
519
- self._selectionStart = event.pos()
1117
+ self._selectionStart = self._to_canvas_point(event.position())
520
1118
  self._selectionRect = QRect(self._selectionStart, self._selectionStart)
521
1119
  self.update()
1120
+ self.grabMouse()
1121
+ self._start_autoscroll()
522
1122
  return
523
1123
 
524
- # painting
525
1124
  self._ensure_layers()
526
1125
  self.drawing = True
527
- self.lastPoint = event.pos()
1126
+ self.lastPointCanvas = self._to_canvas_point(event.position())
528
1127
  self.saveForUndo()
529
1128
 
530
1129
  p = QPainter(self.drawingLayer)
@@ -536,7 +1135,7 @@ class PainterWidget(QWidget):
536
1135
  else:
537
1136
  p.setCompositionMode(QPainter.CompositionMode_SourceOver)
538
1137
  p.setPen(self._pen)
539
- p.drawPoint(self.lastPoint)
1138
+ p.drawPoint(self.lastPointCanvas)
540
1139
  p.end()
541
1140
  self._recompose()
542
1141
  self.update()
@@ -548,12 +1147,13 @@ class PainterWidget(QWidget):
548
1147
  :param event: Event
549
1148
  """
550
1149
  if self.cropping and self._selecting and (event.buttons() & Qt.LeftButton):
551
- self._selectionRect = QRect(self._selectionStart, event.pos())
1150
+ self._selectionRect = QRect(self._selectionStart, self._to_canvas_point(event.position()))
552
1151
  self.update()
553
1152
  return
554
1153
 
555
1154
  if (event.buttons() & Qt.LeftButton) and self.drawing:
556
1155
  self._ensure_layers()
1156
+ cur = self._to_canvas_point(event.position())
557
1157
  p = QPainter(self.drawingLayer)
558
1158
  p.setRenderHint(QPainter.Antialiasing, True)
559
1159
  if self._mode == "erase":
@@ -563,9 +1163,9 @@ class PainterWidget(QWidget):
563
1163
  else:
564
1164
  p.setCompositionMode(QPainter.CompositionMode_SourceOver)
565
1165
  p.setPen(self._pen)
566
- p.drawLine(self.lastPoint, event.pos())
1166
+ p.drawLine(self.lastPointCanvas, cur)
567
1167
  p.end()
568
- self.lastPoint = event.pos()
1168
+ self.lastPointCanvas = cur
569
1169
  self._recompose()
570
1170
  self.update()
571
1171
 
@@ -592,11 +1192,9 @@ class PainterWidget(QWidget):
592
1192
  elif event.key() == Qt.Key_V and QApplication.keyboardModifiers() == Qt.ControlModifier:
593
1193
  self.handle_paste()
594
1194
  elif event.key() in (Qt.Key_Return, Qt.Key_Enter):
595
- # finalize crop with Enter
596
1195
  if self.cropping and self._selecting:
597
1196
  self._finalize_crop()
598
1197
  elif event.key() == Qt.Key_Escape:
599
- # cancel crop
600
1198
  if self.cropping:
601
1199
  self.cancel_crop()
602
1200
 
@@ -606,64 +1204,79 @@ class PainterWidget(QWidget):
606
1204
 
607
1205
  :param event: Event
608
1206
  """
609
- # Ensure final composition is valid
610
- if self.image.size() != self.size():
1207
+ if self.image.size() != self._canvasSize:
611
1208
  self._ensure_layers()
612
1209
  self._rescale_base_from_source()
613
1210
  self._recompose()
614
1211
 
615
1212
  p = QPainter(self)
1213
+ # Draw composited canvas scaled to display rect
616
1214
  p.drawImage(self.rect(), self.image, self.image.rect())
617
1215
 
618
- # Draw crop overlay if active
1216
+ # Draw crop overlay if active (convert canvas selection to display coords)
619
1217
  if self.cropping and not self._selectionRect.isNull():
620
1218
  sel = self._selectionRect.normalized()
1219
+ sel_view = self._from_canvas_rect(sel)
621
1220
  overlay = QColor(0, 0, 0, 120)
622
1221
  W, H = self.width(), self.height()
623
1222
 
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
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
+
638
1232
  p.setPen(QPen(QColor(255, 255, 255, 200), 1, Qt.DashLine))
639
- p.drawRect(sel.adjusted(0, 0, -1, -1))
1233
+ p.drawRect(sel_view.adjusted(0, 0, -1, -1))
640
1234
 
641
1235
  p.end()
642
1236
  self.originalImage = self.image
643
1237
 
644
1238
  def resizeEvent(self, event):
645
1239
  """
646
- Update layers on resize
1240
+ Update layers on canvas size change; ignore display-only resizes from zoom.
647
1241
 
648
1242
  :param event: Event
649
1243
  """
650
- if self._ignoreResizeOnce:
651
- 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)
652
1262
 
653
- old_size = event.oldSize()
654
- 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.
655
1266
 
656
- # Allocate new layers and recompose
1267
+ :param old_size: Previous canvas size
1268
+ :param new_size: New canvas size
1269
+ """
657
1270
  self._ensure_layers()
658
1271
 
659
1272
  if self._pendingResizeApply is not None:
660
- # Apply exact cropped pixels to new canvas size; center if differs
661
1273
  new_base = self._pendingResizeApply.get('base')
662
1274
  new_draw = self._pendingResizeApply.get('draw')
663
1275
 
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.
1276
+ # Reset layers to new canvas size
1277
+ self.baseCanvas = QImage(new_size, QImage.Format_RGB32)
666
1278
  self.baseCanvas.fill(Qt.white)
1279
+ self.drawingLayer = QImage(new_size, QImage.Format_ARGB32_Premultiplied)
667
1280
  self.drawingLayer.fill(Qt.transparent)
668
1281
 
669
1282
  if new_base is not None:
@@ -687,20 +1300,18 @@ class PainterWidget(QWidget):
687
1300
  p.end()
688
1301
 
689
1302
  self._pendingResizeApply = None
690
- # baseTargetRect becomes entire canvas if new_base filled it; otherwise keep unknown
691
1303
  self.baseTargetRect = QRect(0, 0, self.baseCanvas.width(), self.baseCanvas.height())
692
1304
  else:
693
- # Standard path: rebuild base from original source to avoid quality loss
1305
+ # Rebuild background from original source
694
1306
  self._rescale_base_from_source()
695
1307
 
696
- # 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
697
1309
  if old_size.isValid() and (old_size.width() > 0 and old_size.height() > 0) and \
698
1310
  (self.drawingLayer is not None) and (self.drawingLayer.size() != new_size):
699
1311
  self.drawingLayer = self.drawingLayer.scaled(new_size, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
700
1312
 
701
1313
  self._recompose()
702
1314
  self.update()
703
- super().resizeEvent(event)
704
1315
 
705
1316
  def eventFilter(self, source, event):
706
1317
  """