pygpt-net 2.6.33__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 +14 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/controller/assistant/batch.py +14 -4
- pygpt_net/controller/assistant/files.py +1 -0
- pygpt_net/controller/assistant/store.py +195 -1
- pygpt_net/controller/camera/camera.py +1 -1
- pygpt_net/controller/chat/common.py +58 -48
- pygpt_net/controller/chat/handler/stream_worker.py +55 -43
- pygpt_net/controller/config/placeholder.py +95 -75
- pygpt_net/controller/dialogs/confirm.py +3 -1
- pygpt_net/controller/media/media.py +11 -3
- pygpt_net/controller/painter/common.py +243 -13
- pygpt_net/controller/painter/painter.py +11 -2
- pygpt_net/core/assistants/files.py +18 -0
- 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/camera/camera.py +31 -402
- pygpt_net/core/camera/worker.py +430 -0
- 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/filesystem/url.py +3 -0
- pygpt_net/core/render/web/body.py +454 -40
- pygpt_net/core/render/web/pid.py +39 -24
- pygpt_net/core/render/web/renderer.py +146 -40
- pygpt_net/core/text/utils.py +3 -0
- pygpt_net/data/config/config.json +4 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/config/settings.json +10 -5
- 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 +9 -7
- pygpt_net/data/locale/locale.en.ini +10 -6
- pygpt_net/data/locale/locale.es.ini +9 -7
- pygpt_net/data/locale/locale.fr.ini +9 -7
- pygpt_net/data/locale/locale.it.ini +9 -7
- pygpt_net/data/locale/locale.pl.ini +9 -7
- pygpt_net/data/locale/locale.uk.ini +9 -7
- pygpt_net/data/locale/locale.zh.ini +9 -7
- pygpt_net/item/assistant.py +13 -1
- pygpt_net/provider/api/google/__init__.py +46 -28
- pygpt_net/provider/api/openai/__init__.py +13 -10
- pygpt_net/provider/api/openai/store.py +45 -1
- pygpt_net/provider/core/config/patch.py +9 -0
- pygpt_net/provider/llms/google.py +4 -0
- pygpt_net/ui/dialog/assistant_store.py +213 -203
- pygpt_net/ui/layout/chat/input.py +3 -3
- pygpt_net/ui/layout/chat/painter.py +63 -4
- pygpt_net/ui/widget/draw/painter.py +715 -104
- pygpt_net/ui/widget/option/combo.py +5 -1
- pygpt_net/ui/widget/textarea/input.py +273 -3
- pygpt_net/ui/widget/textarea/web.py +2 -0
- {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.35.dist-info}/METADATA +16 -2
- {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.35.dist-info}/RECORD +64 -63
- {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.35.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.35.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.35.dist-info}/entry_points.txt +0 -0
|
@@ -1,23 +1,49 @@
|
|
|
1
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
'
|
|
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
|
-
"""
|
|
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
|
-
|
|
192
|
-
if
|
|
193
|
-
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)
|
|
194
464
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
1166
|
+
p.drawLine(self.lastPointCanvas, cur)
|
|
567
1167
|
p.end()
|
|
568
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
if
|
|
629
|
-
p.fillRect(
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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(
|
|
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
|
|
1240
|
+
Update layers on canvas size change; ignore display-only resizes from zoom.
|
|
647
1241
|
|
|
648
1242
|
:param event: Event
|
|
649
1243
|
"""
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
654
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
665
|
-
|
|
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
|
-
#
|
|
1305
|
+
# Rebuild background from original source
|
|
694
1306
|
self._rescale_base_from_source()
|
|
695
1307
|
|
|
696
|
-
# Scale drawing
|
|
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
|
"""
|