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.
- pygpt_net/CHANGELOG.txt +7 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/controller/chat/common.py +8 -2
- pygpt_net/controller/chat/handler/stream_worker.py +55 -43
- pygpt_net/controller/painter/common.py +13 -1
- pygpt_net/controller/painter/painter.py +11 -2
- pygpt_net/core/bridge/bridge.py +1 -5
- pygpt_net/core/bridge/context.py +81 -36
- pygpt_net/core/bridge/worker.py +3 -1
- pygpt_net/core/ctx/bag.py +4 -0
- pygpt_net/core/events/app.py +10 -17
- pygpt_net/core/events/base.py +17 -25
- pygpt_net/core/events/control.py +9 -17
- pygpt_net/core/events/event.py +9 -62
- pygpt_net/core/events/kernel.py +8 -17
- pygpt_net/core/events/realtime.py +8 -17
- pygpt_net/core/events/render.py +9 -17
- pygpt_net/core/render/web/body.py +394 -36
- pygpt_net/core/render/web/pid.py +39 -24
- pygpt_net/core/render/web/renderer.py +146 -40
- pygpt_net/data/config/config.json +4 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/css/web-blocks.css +3 -2
- pygpt_net/data/css/web-chatgpt.css +3 -1
- pygpt_net/data/css/web-chatgpt_wide.css +3 -1
- pygpt_net/data/locale/locale.de.ini +1 -0
- pygpt_net/data/locale/locale.en.ini +3 -2
- pygpt_net/data/locale/locale.es.ini +1 -0
- pygpt_net/data/locale/locale.fr.ini +1 -0
- pygpt_net/data/locale/locale.it.ini +1 -0
- pygpt_net/data/locale/locale.pl.ini +2 -1
- pygpt_net/data/locale/locale.uk.ini +1 -0
- pygpt_net/data/locale/locale.zh.ini +1 -0
- pygpt_net/provider/api/google/__init__.py +14 -5
- pygpt_net/provider/api/openai/__init__.py +13 -10
- pygpt_net/provider/core/config/patch.py +9 -0
- pygpt_net/ui/layout/chat/painter.py +63 -4
- pygpt_net/ui/widget/draw/painter.py +702 -106
- pygpt_net/ui/widget/textarea/web.py +2 -0
- {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/METADATA +9 -2
- {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/RECORD +44 -44
- {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/WHEEL +0 -0
- {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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
'
|
|
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
|
-
"""
|
|
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
|
-
|
|
201
|
-
if
|
|
202
|
-
self.
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
222
|
-
self.
|
|
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.
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
1166
|
+
p.drawLine(self.lastPointCanvas, cur)
|
|
582
1167
|
p.end()
|
|
583
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
if
|
|
644
|
-
p.fillRect(
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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(
|
|
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
|
|
1240
|
+
Update layers on canvas size change; ignore display-only resizes from zoom.
|
|
662
1241
|
|
|
663
1242
|
:param event: Event
|
|
664
1243
|
"""
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
669
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
680
|
-
|
|
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
|
-
#
|
|
1305
|
+
# Rebuild background from original source
|
|
709
1306
|
self._rescale_base_from_source()
|
|
710
1307
|
|
|
711
|
-
# Scale drawing
|
|
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
|
"""
|