pygpt-net 2.6.67__py3-none-any.whl → 2.7.1__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 +20 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/controller/assistant/assistant.py +13 -8
- pygpt_net/controller/assistant/batch.py +29 -15
- pygpt_net/controller/assistant/files.py +19 -14
- pygpt_net/controller/assistant/store.py +63 -41
- pygpt_net/controller/attachment/attachment.py +45 -35
- pygpt_net/controller/chat/attachment.py +50 -39
- pygpt_net/controller/config/field/dictionary.py +26 -14
- pygpt_net/controller/ctx/common.py +27 -17
- pygpt_net/controller/ctx/ctx.py +185 -101
- pygpt_net/controller/files/files.py +101 -41
- pygpt_net/controller/idx/indexer.py +87 -31
- pygpt_net/controller/kernel/kernel.py +13 -2
- pygpt_net/controller/mode/mode.py +3 -3
- pygpt_net/controller/model/editor.py +70 -15
- pygpt_net/controller/model/importer.py +153 -54
- pygpt_net/controller/painter/common.py +43 -11
- pygpt_net/controller/painter/painter.py +2 -2
- pygpt_net/controller/presets/experts.py +68 -15
- pygpt_net/controller/presets/presets.py +72 -36
- pygpt_net/controller/settings/profile.py +76 -35
- pygpt_net/controller/settings/workdir.py +70 -39
- pygpt_net/core/assistants/files.py +20 -18
- pygpt_net/core/filesystem/actions.py +111 -10
- pygpt_net/core/filesystem/filesystem.py +72 -1
- pygpt_net/core/filesystem/packer.py +161 -1
- pygpt_net/core/idx/idx.py +12 -11
- pygpt_net/core/idx/worker.py +13 -1
- pygpt_net/core/image/image.py +2 -2
- pygpt_net/core/models/models.py +4 -4
- pygpt_net/core/profile/profile.py +13 -3
- pygpt_net/core/video/video.py +2 -3
- pygpt_net/data/config/config.json +3 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/css/style.dark.css +45 -0
- pygpt_net/data/css/style.light.css +46 -0
- pygpt_net/data/locale/locale.de.ini +5 -1
- pygpt_net/data/locale/locale.en.ini +5 -1
- pygpt_net/data/locale/locale.es.ini +5 -1
- pygpt_net/data/locale/locale.fr.ini +5 -1
- pygpt_net/data/locale/locale.it.ini +5 -1
- pygpt_net/data/locale/locale.pl.ini +6 -2
- pygpt_net/data/locale/locale.uk.ini +5 -1
- pygpt_net/data/locale/locale.zh.ini +5 -1
- pygpt_net/provider/api/openai/__init__.py +4 -2
- pygpt_net/provider/core/config/patch.py +17 -1
- pygpt_net/tools/image_viewer/tool.py +17 -0
- pygpt_net/tools/text_editor/tool.py +9 -0
- pygpt_net/ui/__init__.py +2 -2
- pygpt_net/ui/dialog/preset.py +1 -0
- pygpt_net/ui/layout/ctx/ctx_list.py +16 -6
- pygpt_net/ui/layout/toolbox/image.py +2 -1
- pygpt_net/ui/layout/toolbox/indexes.py +2 -0
- pygpt_net/ui/layout/toolbox/video.py +5 -1
- pygpt_net/ui/main.py +3 -1
- pygpt_net/ui/widget/calendar/select.py +3 -3
- pygpt_net/ui/widget/draw/painter.py +238 -51
- pygpt_net/ui/widget/filesystem/explorer.py +1164 -142
- pygpt_net/ui/widget/lists/assistant.py +185 -24
- pygpt_net/ui/widget/lists/assistant_store.py +245 -42
- pygpt_net/ui/widget/lists/attachment.py +230 -47
- pygpt_net/ui/widget/lists/attachment_ctx.py +189 -33
- pygpt_net/ui/widget/lists/base_list_combo.py +2 -2
- pygpt_net/ui/widget/lists/context.py +1253 -70
- pygpt_net/ui/widget/lists/experts.py +110 -8
- pygpt_net/ui/widget/lists/model_editor.py +217 -14
- pygpt_net/ui/widget/lists/model_importer.py +125 -6
- pygpt_net/ui/widget/lists/preset.py +460 -71
- pygpt_net/ui/widget/lists/profile.py +149 -27
- pygpt_net/ui/widget/lists/uploaded.py +230 -38
- pygpt_net/ui/widget/option/combo.py +1211 -33
- pygpt_net/ui/widget/option/dictionary.py +35 -7
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/METADATA +22 -57
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/RECORD +78 -78
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/entry_points.txt +0 -0
pygpt_net/ui/main.py
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
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.
|
|
9
|
+
# Updated Date: 2025.12.28 00:00:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
12
|
import os
|
|
@@ -335,6 +335,8 @@ class MainWindow(QMainWindow, QtStyleTools):
|
|
|
335
335
|
self.controller.plugins.save_all()
|
|
336
336
|
print("Saving tools...")
|
|
337
337
|
self.tools.on_exit()
|
|
338
|
+
print("Closing clients...")
|
|
339
|
+
self.controller.kernel.close_clients()
|
|
338
340
|
print("Saving layout state...")
|
|
339
341
|
self.controller.layout.save()
|
|
340
342
|
print("Stopping timers...")
|
|
@@ -6,7 +6,7 @@
|
|
|
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.
|
|
9
|
+
# Updated Date: 2025.12.27 23:00:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
12
|
from typing import Tuple
|
|
@@ -98,10 +98,10 @@ class CalendarSelect(QCalendarWidget):
|
|
|
98
98
|
if theme != self._theme_cached:
|
|
99
99
|
self._theme_cached = theme
|
|
100
100
|
if isinstance(theme, str) and theme.startswith('dark'):
|
|
101
|
-
self._counter_bg = QColor(
|
|
101
|
+
self._counter_bg = QColor(70, 70, 70)
|
|
102
102
|
self._counter_font = QColor(255, 255, 255)
|
|
103
103
|
else:
|
|
104
|
-
self._counter_bg = QColor(
|
|
104
|
+
self._counter_bg = QColor(200, 200, 200)
|
|
105
105
|
self._counter_font = QColor(0, 0, 0)
|
|
106
106
|
|
|
107
107
|
def paintCell(self, painter, rect, date: QDate):
|
|
@@ -6,12 +6,13 @@
|
|
|
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.
|
|
9
|
+
# Updated Date: 2025.12.28 14:30:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
12
|
import datetime
|
|
13
13
|
import os
|
|
14
14
|
import bisect
|
|
15
|
+
import math
|
|
15
16
|
from collections import deque
|
|
16
17
|
|
|
17
18
|
from PySide6.QtCore import Qt, QPoint, QPointF, QRect, QSize, QSaveFile, QIODevice, QTimer, Signal
|
|
@@ -42,6 +43,9 @@ class PainterWidget(QWidget):
|
|
|
42
43
|
self._zoomSteps = [0.10, 0.25, 0.50, 0.75, 1.00, 1.50, 2.00, 5.00, 10.00]
|
|
43
44
|
self._zoomResizeInProgress = False # guard used during display-size updates caused by zoom
|
|
44
45
|
|
|
46
|
+
# Guard to mark an explicit logical canvas resize (controller-driven)
|
|
47
|
+
self._canvasResizeInProgress = False
|
|
48
|
+
|
|
45
49
|
# Final composited image (canvas-sized). Kept for API compatibility.
|
|
46
50
|
self.image = QImage(self._canvasSize, QImage.Format_RGB32)
|
|
47
51
|
|
|
@@ -152,6 +156,9 @@ class PainterWidget(QWidget):
|
|
|
152
156
|
self._ctx_menu.addAction(self._act_save)
|
|
153
157
|
self._ctx_menu.addAction(self._act_clear)
|
|
154
158
|
|
|
159
|
+
# Composite state: mark when self.image is out-of-date relative to layers
|
|
160
|
+
self._compositeDirty = True # True => recomposition needed before exporting/copying
|
|
161
|
+
|
|
155
162
|
# Allocate initial buffers
|
|
156
163
|
self._ensure_layers()
|
|
157
164
|
self._recompose()
|
|
@@ -166,6 +173,43 @@ class PainterWidget(QWidget):
|
|
|
166
173
|
"""
|
|
167
174
|
self.tab = tab
|
|
168
175
|
|
|
176
|
+
# ---------- Canvas public API (explicit, zoom-independent) ----------
|
|
177
|
+
|
|
178
|
+
def set_canvas_size_pixels(self, width: int, height: int):
|
|
179
|
+
"""
|
|
180
|
+
Explicitly set logical canvas size in pixels.
|
|
181
|
+
This never depends on view zoom and never uses parent/layout resizes.
|
|
182
|
+
|
|
183
|
+
:param width: canvas width in pixels
|
|
184
|
+
:param height: canvas height in pixels
|
|
185
|
+
"""
|
|
186
|
+
w = max(1, int(width))
|
|
187
|
+
h = max(1, int(height))
|
|
188
|
+
|
|
189
|
+
if self._canvasSize.width() == w and self._canvasSize.height() == h:
|
|
190
|
+
# Keep display size consistent with current zoom
|
|
191
|
+
self._update_widget_size_from_zoom()
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
old_canvas = QSize(self._canvasSize)
|
|
195
|
+
self._canvasSize = QSize(w, h)
|
|
196
|
+
|
|
197
|
+
self._canvasResizeInProgress = True
|
|
198
|
+
try:
|
|
199
|
+
self._handle_canvas_resized(old_canvas, self._canvasSize)
|
|
200
|
+
# After logical resize, update the displayed size according to zoom
|
|
201
|
+
self._update_widget_size_from_zoom()
|
|
202
|
+
finally:
|
|
203
|
+
self._canvasResizeInProgress = False
|
|
204
|
+
|
|
205
|
+
def get_canvas_size(self) -> QSize:
|
|
206
|
+
"""
|
|
207
|
+
Return current logical canvas size (pixels).
|
|
208
|
+
|
|
209
|
+
:return: QSize of canvas
|
|
210
|
+
"""
|
|
211
|
+
return QSize(self._canvasSize)
|
|
212
|
+
|
|
169
213
|
# ---------- Zoom public API ----------
|
|
170
214
|
|
|
171
215
|
def on_zoom_combo_changed(self, text: str):
|
|
@@ -371,6 +415,26 @@ class PainterWidget(QWidget):
|
|
|
371
415
|
h = int(round(rc.height() * self.zoom))
|
|
372
416
|
return QRect(x, y, w, h)
|
|
373
417
|
|
|
418
|
+
def _widget_rect_to_canvas_rect(self, rc: QRect) -> QRect:
|
|
419
|
+
"""
|
|
420
|
+
Map a widget rect (in display pixels) to a canvas rect (in canvas pixels).
|
|
421
|
+
Uses floor/ceil to ensure coverage and clamps to canvas bounds.
|
|
422
|
+
"""
|
|
423
|
+
if rc.isNull() or rc.width() <= 0 or rc.height() <= 0:
|
|
424
|
+
return QRect()
|
|
425
|
+
inv = 1.0 / max(1e-6, self.zoom)
|
|
426
|
+
x1 = int(math.floor(rc.x() * inv))
|
|
427
|
+
y1 = int(math.floor(rc.y() * inv))
|
|
428
|
+
x2 = int(math.ceil((rc.x() + rc.width()) * inv))
|
|
429
|
+
y2 = int(math.ceil((rc.y() + rc.height()) * inv))
|
|
430
|
+
x1 = max(0, min(self._canvasSize.width(), x1))
|
|
431
|
+
y1 = max(0, min(self._canvasSize.height(), y1))
|
|
432
|
+
x2 = max(0, min(self._canvasSize.width(), x2))
|
|
433
|
+
y2 = max(0, min(self._canvasSize.height(), y2))
|
|
434
|
+
w = max(0, x2 - x1)
|
|
435
|
+
h = max(0, y2 - y1)
|
|
436
|
+
return QRect(x1, y1, w, h)
|
|
437
|
+
|
|
374
438
|
def _parse_percent(self, text: str) -> int | None:
|
|
375
439
|
"""
|
|
376
440
|
Parse '150%' -> 150.
|
|
@@ -392,6 +456,19 @@ class PainterWidget(QWidget):
|
|
|
392
456
|
|
|
393
457
|
# ---------- Layer & composition helpers ----------
|
|
394
458
|
|
|
459
|
+
def _mark_composite_dirty(self):
|
|
460
|
+
"""Mark the composited image cache as dirty."""
|
|
461
|
+
self._compositeDirty = True
|
|
462
|
+
|
|
463
|
+
def _ensure_composited_image(self):
|
|
464
|
+
"""
|
|
465
|
+
Ensure that self.image reflects current baseCanvas + drawingLayer.
|
|
466
|
+
This is used for exporting/copying, not for on-screen painting.
|
|
467
|
+
"""
|
|
468
|
+
if self._compositeDirty:
|
|
469
|
+
self._recompose()
|
|
470
|
+
self._compositeDirty = False
|
|
471
|
+
|
|
395
472
|
def _ensure_layers(self):
|
|
396
473
|
"""Ensure baseCanvas, drawingLayer, and image are allocated to current canvas size."""
|
|
397
474
|
sz = self._canvasSize
|
|
@@ -401,14 +478,17 @@ class PainterWidget(QWidget):
|
|
|
401
478
|
if self.baseCanvas is None or self.baseCanvas.size() != sz:
|
|
402
479
|
self.baseCanvas = QImage(sz, QImage.Format_RGB32)
|
|
403
480
|
self.baseCanvas.fill(Qt.white)
|
|
481
|
+
self._mark_composite_dirty()
|
|
404
482
|
|
|
405
483
|
if self.drawingLayer is None or self.drawingLayer.size() != sz:
|
|
406
484
|
self.drawingLayer = QImage(sz, QImage.Format_ARGB32_Premultiplied)
|
|
407
485
|
self.drawingLayer.fill(Qt.transparent)
|
|
486
|
+
self._mark_composite_dirty()
|
|
408
487
|
|
|
409
488
|
if self.image.size() != sz:
|
|
410
489
|
self.image = QImage(sz, QImage.Format_RGB32)
|
|
411
490
|
self.image.fill(Qt.white)
|
|
491
|
+
self._mark_composite_dirty()
|
|
412
492
|
|
|
413
493
|
def _rescale_base_from_source(self):
|
|
414
494
|
"""Rebuild baseCanvas from sourceImageOriginal to fit current canvas, preserving aspect ratio."""
|
|
@@ -416,6 +496,7 @@ class PainterWidget(QWidget):
|
|
|
416
496
|
self.baseCanvas.fill(Qt.white)
|
|
417
497
|
self.baseTargetRect = QRect()
|
|
418
498
|
if self.sourceImageOriginal is None or self.sourceImageOriginal.isNull():
|
|
499
|
+
self._mark_composite_dirty()
|
|
419
500
|
return
|
|
420
501
|
|
|
421
502
|
canvas_size = self._canvasSize
|
|
@@ -429,6 +510,7 @@ class PainterWidget(QWidget):
|
|
|
429
510
|
p.setRenderHint(QPainter.SmoothPixmapTransform, True)
|
|
430
511
|
p.drawImage(self.baseTargetRect, src)
|
|
431
512
|
p.end()
|
|
513
|
+
self._mark_composite_dirty()
|
|
432
514
|
|
|
433
515
|
def _recompose(self):
|
|
434
516
|
"""Compose final canvas image from baseCanvas + drawingLayer."""
|
|
@@ -486,49 +568,62 @@ class PainterWidget(QWidget):
|
|
|
486
568
|
self.sourceImageOriginal = QImage(state['src']) if state['src'] is not None else None
|
|
487
569
|
self.baseTargetRect = QRect(state['baseRect']) if state['baseRect'] is not None else QRect()
|
|
488
570
|
|
|
489
|
-
self.
|
|
571
|
+
self._mark_composite_dirty()
|
|
490
572
|
self._update_widget_size_from_zoom()
|
|
491
573
|
self.update()
|
|
492
574
|
|
|
493
575
|
def _is_fit_available(self) -> bool:
|
|
494
576
|
"""
|
|
495
577
|
Return True if there are letterbox margins that can be trimmed.
|
|
578
|
+
Uses lightweight checks to avoid heavy full-image scans during menu opening.
|
|
496
579
|
|
|
497
580
|
:return: True if fit action is available
|
|
498
581
|
"""
|
|
499
|
-
|
|
500
|
-
|
|
582
|
+
# If the scaled source does not cover the whole canvas, trimming is possible
|
|
501
583
|
if self.baseTargetRect.isValid() and not self.baseTargetRect.isNull():
|
|
502
584
|
if self.baseTargetRect.width() < self._canvasSize.width() or self.baseTargetRect.height() < self._canvasSize.height():
|
|
503
585
|
return True
|
|
504
586
|
|
|
505
|
-
|
|
587
|
+
# Otherwise, if there is any non-transparent stroke content that doesn't span entire canvas, fit may trim
|
|
588
|
+
bounds = self._detect_nontransparent_bounds(self.drawingLayer)
|
|
506
589
|
if bounds is not None:
|
|
507
590
|
return bounds.width() < self._canvasSize.width() or bounds.height() < self._canvasSize.height()
|
|
508
591
|
return False
|
|
509
592
|
|
|
510
|
-
def
|
|
511
|
-
"""
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
self.
|
|
516
|
-
|
|
517
|
-
self.
|
|
593
|
+
def _compute_fit_rect(self) -> QRect | None:
|
|
594
|
+
"""
|
|
595
|
+
Compute a fit rectangle based on the scaled source rect and drawn content.
|
|
596
|
+
This avoids recomposing a full image and scanning all pixels in RGB.
|
|
597
|
+
"""
|
|
598
|
+
if self._canvasSize.isEmpty():
|
|
599
|
+
return None
|
|
600
|
+
canvas_rect = QRect(0, 0, self._canvasSize.width(), self._canvasSize.height())
|
|
601
|
+
result = None
|
|
518
602
|
|
|
519
|
-
fit_rect = None
|
|
520
603
|
if self.baseTargetRect.isValid() and not self.baseTargetRect.isNull():
|
|
521
|
-
|
|
522
|
-
fit_rect = self.baseTargetRect.intersected(canvas_rect)
|
|
604
|
+
result = self.baseTargetRect.intersected(canvas_rect)
|
|
523
605
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if
|
|
527
|
-
|
|
606
|
+
draw_bounds = self._detect_nontransparent_bounds(self.drawingLayer)
|
|
607
|
+
if draw_bounds is not None and not draw_bounds.isNull():
|
|
608
|
+
result = draw_bounds if result is None else result.united(draw_bounds)
|
|
609
|
+
|
|
610
|
+
if result is None or result.isNull():
|
|
611
|
+
return None
|
|
612
|
+
return result
|
|
613
|
+
|
|
614
|
+
def action_fit(self):
|
|
615
|
+
"""Trim white letterbox margins and resize canvas to the scaled image area. Undo-safe."""
|
|
616
|
+
# Use lightweight fit computation
|
|
617
|
+
fit_rect = self._compute_fit_rect()
|
|
618
|
+
if fit_rect is None:
|
|
619
|
+
return
|
|
528
620
|
|
|
529
621
|
if fit_rect.width() == self._canvasSize.width() and fit_rect.height() == self._canvasSize.height():
|
|
530
622
|
return
|
|
531
623
|
|
|
624
|
+
self.saveForUndo()
|
|
625
|
+
self._ensure_layers()
|
|
626
|
+
|
|
532
627
|
new_base = self.baseCanvas.copy(fit_rect)
|
|
533
628
|
new_draw = self.drawingLayer.copy(fit_rect)
|
|
534
629
|
|
|
@@ -536,6 +631,7 @@ class PainterWidget(QWidget):
|
|
|
536
631
|
'base': QImage(new_base),
|
|
537
632
|
'draw': QImage(new_draw),
|
|
538
633
|
}
|
|
634
|
+
self._mark_composite_dirty()
|
|
539
635
|
|
|
540
636
|
self.window.controller.painter.common.change_canvas_size(f"{fit_rect.width()}x{fit_rect.height()}")
|
|
541
637
|
self.update()
|
|
@@ -611,6 +707,61 @@ class PainterWidget(QWidget):
|
|
|
611
707
|
|
|
612
708
|
return QRect(left, top, right - left + 1, bottom - top + 1)
|
|
613
709
|
|
|
710
|
+
def _detect_nontransparent_bounds(self, img: QImage) -> QRect | None:
|
|
711
|
+
"""
|
|
712
|
+
Fast bounds detection for drawing layer: scans alpha channel only.
|
|
713
|
+
|
|
714
|
+
:param img: ARGB image
|
|
715
|
+
:return: QRect of non-transparent content or None
|
|
716
|
+
"""
|
|
717
|
+
if img is None or img.isNull():
|
|
718
|
+
return None
|
|
719
|
+
w, h = img.width(), img.height()
|
|
720
|
+
if w <= 0 or h <= 0:
|
|
721
|
+
return None
|
|
722
|
+
|
|
723
|
+
left = -1
|
|
724
|
+
for x in range(w):
|
|
725
|
+
for y in range(h):
|
|
726
|
+
if img.pixelColor(x, y).alpha() > 0:
|
|
727
|
+
left = x
|
|
728
|
+
break
|
|
729
|
+
if left != -1:
|
|
730
|
+
break
|
|
731
|
+
if left == -1:
|
|
732
|
+
return None
|
|
733
|
+
|
|
734
|
+
right = -1
|
|
735
|
+
for x in range(w - 1, -1, -1):
|
|
736
|
+
for y in range(h):
|
|
737
|
+
if img.pixelColor(x, y).alpha() > 0:
|
|
738
|
+
right = x
|
|
739
|
+
break
|
|
740
|
+
if right != -1:
|
|
741
|
+
break
|
|
742
|
+
|
|
743
|
+
top = -1
|
|
744
|
+
for y in range(h):
|
|
745
|
+
for x in range(left, right + 1):
|
|
746
|
+
if img.pixelColor(x, y).alpha() > 0:
|
|
747
|
+
top = y
|
|
748
|
+
break
|
|
749
|
+
if top != -1:
|
|
750
|
+
break
|
|
751
|
+
|
|
752
|
+
bottom = -1
|
|
753
|
+
for y in range(h - 1, -1, -1):
|
|
754
|
+
for x in range(left, right + 1):
|
|
755
|
+
if img.pixelColor(x, y).alpha() > 0:
|
|
756
|
+
bottom = y
|
|
757
|
+
break
|
|
758
|
+
if bottom != -1:
|
|
759
|
+
break
|
|
760
|
+
|
|
761
|
+
if right < left or bottom < top:
|
|
762
|
+
return None
|
|
763
|
+
return QRect(left, top, right - left + 1, bottom - top + 1)
|
|
764
|
+
|
|
614
765
|
# ---------- Public API (clipboard, file, actions) ----------
|
|
615
766
|
|
|
616
767
|
def handle_paste(self):
|
|
@@ -624,7 +775,7 @@ class PainterWidget(QWidget):
|
|
|
624
775
|
|
|
625
776
|
def handle_copy(self):
|
|
626
777
|
"""Handle clipboard copy"""
|
|
627
|
-
self.
|
|
778
|
+
self._ensure_composited_image()
|
|
628
779
|
clipboard = QApplication.clipboard()
|
|
629
780
|
clipboard.setImage(self.image)
|
|
630
781
|
|
|
@@ -636,12 +787,15 @@ class PainterWidget(QWidget):
|
|
|
636
787
|
"""
|
|
637
788
|
self._act_undo.setEnabled(self.has_undo())
|
|
638
789
|
self._act_redo.setEnabled(self.has_redo())
|
|
639
|
-
self._act_fit.setEnabled(self._is_fit_available())
|
|
640
790
|
|
|
791
|
+
# Enable paste based on clipboard; avoid heavy 'fit' checks here to keep menu snappy
|
|
641
792
|
clipboard = QApplication.clipboard()
|
|
642
793
|
mime_data = clipboard.mimeData()
|
|
643
794
|
self._act_paste.setEnabled(bool(mime_data.hasImage()))
|
|
644
795
|
|
|
796
|
+
# Keep Fit enabled; the action validates availability when executed
|
|
797
|
+
self._act_fit.setEnabled(True)
|
|
798
|
+
|
|
645
799
|
self._ctx_menu.exec(event.globalPos())
|
|
646
800
|
|
|
647
801
|
def action_open(self):
|
|
@@ -662,7 +816,7 @@ class PainterWidget(QWidget):
|
|
|
662
816
|
|
|
663
817
|
def action_save(self):
|
|
664
818
|
"""Save image to file"""
|
|
665
|
-
self.
|
|
819
|
+
self._ensure_composited_image()
|
|
666
820
|
name = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + ".png"
|
|
667
821
|
path, _ = QFileDialog.getSaveFileName(
|
|
668
822
|
self,
|
|
@@ -706,7 +860,7 @@ class PainterWidget(QWidget):
|
|
|
706
860
|
self._ensure_layers()
|
|
707
861
|
self._rescale_base_from_source()
|
|
708
862
|
self.drawingLayer.fill(Qt.transparent)
|
|
709
|
-
self.
|
|
863
|
+
self._mark_composite_dirty()
|
|
710
864
|
else:
|
|
711
865
|
pass
|
|
712
866
|
|
|
@@ -728,7 +882,7 @@ class PainterWidget(QWidget):
|
|
|
728
882
|
self._ensure_layers()
|
|
729
883
|
self._rescale_base_from_source()
|
|
730
884
|
self.drawingLayer.fill(Qt.transparent)
|
|
731
|
-
self.
|
|
885
|
+
self._mark_composite_dirty()
|
|
732
886
|
self.update()
|
|
733
887
|
|
|
734
888
|
def scale_to_fit(self, image):
|
|
@@ -744,7 +898,7 @@ class PainterWidget(QWidget):
|
|
|
744
898
|
def saveForUndo(self):
|
|
745
899
|
"""Save current state for undo"""
|
|
746
900
|
self._ensure_layers()
|
|
747
|
-
self.
|
|
901
|
+
self._ensure_composited_image()
|
|
748
902
|
self.undoStack.append(self._snapshot_state())
|
|
749
903
|
self.redoStack.clear()
|
|
750
904
|
|
|
@@ -831,7 +985,7 @@ class PainterWidget(QWidget):
|
|
|
831
985
|
|
|
832
986
|
return self._save_image_atomic(result, path)
|
|
833
987
|
|
|
834
|
-
self.
|
|
988
|
+
self._ensure_composited_image()
|
|
835
989
|
return self._save_image_atomic(self.image, path)
|
|
836
990
|
|
|
837
991
|
def _save_image_atomic(self, img: QImage, path: str, fmt: str = None, quality: int = -1) -> bool:
|
|
@@ -911,7 +1065,7 @@ class PainterWidget(QWidget):
|
|
|
911
1065
|
self.sourceImageOriginal = None
|
|
912
1066
|
self.baseCanvas.fill(Qt.white)
|
|
913
1067
|
self.drawingLayer.fill(Qt.transparent)
|
|
914
|
-
self.
|
|
1068
|
+
self._mark_composite_dirty()
|
|
915
1069
|
self.update()
|
|
916
1070
|
|
|
917
1071
|
# ---------- Crop tool ----------
|
|
@@ -950,6 +1104,7 @@ class PainterWidget(QWidget):
|
|
|
950
1104
|
'base': QImage(new_base),
|
|
951
1105
|
'draw': QImage(new_draw),
|
|
952
1106
|
}
|
|
1107
|
+
self._mark_composite_dirty()
|
|
953
1108
|
|
|
954
1109
|
if self.sourceImageOriginal is not None and not self.baseTargetRect.isNull():
|
|
955
1110
|
inter = sel.intersected(self.baseTargetRect)
|
|
@@ -1171,6 +1326,20 @@ class PainterWidget(QWidget):
|
|
|
1171
1326
|
return
|
|
1172
1327
|
super().wheelEvent(event)
|
|
1173
1328
|
|
|
1329
|
+
def _dirty_canvas_rect_for_point(self, pt_canvas: QPoint, pen_width: int) -> QRect:
|
|
1330
|
+
"""Compute dirty canvas rect around a single painted point."""
|
|
1331
|
+
r = max(1, int(math.ceil(pen_width / 2))) + 2
|
|
1332
|
+
return QRect(pt_canvas.x() - r, pt_canvas.y() - r, 2 * r + 1, 2 * r + 1)
|
|
1333
|
+
|
|
1334
|
+
def _dirty_canvas_rect_for_segment(self, a: QPoint, b: QPoint, pen_width: int) -> QRect:
|
|
1335
|
+
"""Compute dirty canvas rect for a line segment between two canvas points."""
|
|
1336
|
+
x1 = min(a.x(), b.x())
|
|
1337
|
+
y1 = min(a.y(), b.y())
|
|
1338
|
+
x2 = max(a.x(), b.x())
|
|
1339
|
+
y2 = max(a.y(), b.y())
|
|
1340
|
+
pad = max(1, int(math.ceil(pen_width / 2))) + 2
|
|
1341
|
+
return QRect(x1 - pad, y1 - pad, (x2 - x1) + 2 * pad + 1, (y2 - y1) + 2 * pad + 1)
|
|
1342
|
+
|
|
1174
1343
|
def mousePressEvent(self, event):
|
|
1175
1344
|
"""
|
|
1176
1345
|
Mouse press event
|
|
@@ -1213,8 +1382,11 @@ class PainterWidget(QWidget):
|
|
|
1213
1382
|
p.setPen(self._pen)
|
|
1214
1383
|
p.drawPoint(self.lastPointCanvas)
|
|
1215
1384
|
p.end()
|
|
1216
|
-
self.
|
|
1217
|
-
|
|
1385
|
+
self._mark_composite_dirty()
|
|
1386
|
+
|
|
1387
|
+
# Update only the affected region
|
|
1388
|
+
dirty_canvas = self._dirty_canvas_rect_for_point(self.lastPointCanvas, self.brushSize)
|
|
1389
|
+
self.update(self._from_canvas_rect(dirty_canvas))
|
|
1218
1390
|
|
|
1219
1391
|
def mouseMoveEvent(self, event):
|
|
1220
1392
|
"""
|
|
@@ -1248,9 +1420,12 @@ class PainterWidget(QWidget):
|
|
|
1248
1420
|
p.setPen(self._pen)
|
|
1249
1421
|
p.drawLine(self.lastPointCanvas, cur)
|
|
1250
1422
|
p.end()
|
|
1423
|
+
self._mark_composite_dirty()
|
|
1424
|
+
|
|
1425
|
+
# Update only the affected region for this segment
|
|
1426
|
+
dirty_canvas = self._dirty_canvas_rect_for_segment(self.lastPointCanvas, cur, self.brushSize)
|
|
1251
1427
|
self.lastPointCanvas = cur
|
|
1252
|
-
self.
|
|
1253
|
-
self.update()
|
|
1428
|
+
self.update(self._from_canvas_rect(dirty_canvas))
|
|
1254
1429
|
|
|
1255
1430
|
def mouseReleaseEvent(self, event):
|
|
1256
1431
|
"""
|
|
@@ -1293,14 +1468,22 @@ class PainterWidget(QWidget):
|
|
|
1293
1468
|
|
|
1294
1469
|
:param event: Event
|
|
1295
1470
|
"""
|
|
1296
|
-
|
|
1471
|
+
# Ensure layers are valid; avoid recomposing the full image here.
|
|
1472
|
+
if self.baseCanvas is None or self.drawingLayer is None:
|
|
1297
1473
|
self._ensure_layers()
|
|
1298
|
-
self._rescale_base_from_source()
|
|
1299
|
-
self._recompose()
|
|
1300
1474
|
|
|
1301
1475
|
p = QPainter(self)
|
|
1302
|
-
|
|
1303
|
-
|
|
1476
|
+
|
|
1477
|
+
# Paint only the region requested by Qt; map it to canvas to avoid scaling the whole image.
|
|
1478
|
+
target_rect = event.rect()
|
|
1479
|
+
if not target_rect.isNull():
|
|
1480
|
+
src_rect = self._widget_rect_to_canvas_rect(target_rect)
|
|
1481
|
+
if not src_rect.isNull():
|
|
1482
|
+
# Draw base
|
|
1483
|
+
p.drawImage(target_rect, self.baseCanvas, src_rect)
|
|
1484
|
+
# Draw strokes on top
|
|
1485
|
+
p.setCompositionMode(QPainter.CompositionMode_SourceOver)
|
|
1486
|
+
p.drawImage(target_rect, self.drawingLayer, src_rect)
|
|
1304
1487
|
|
|
1305
1488
|
# Draw crop overlay if active (convert canvas selection to display coords)
|
|
1306
1489
|
if self.cropping and not self._selectionRect.isNull():
|
|
@@ -1322,30 +1505,33 @@ class PainterWidget(QWidget):
|
|
|
1322
1505
|
p.drawRect(sel_view.adjusted(0, 0, -1, -1))
|
|
1323
1506
|
|
|
1324
1507
|
p.end()
|
|
1325
|
-
self.
|
|
1508
|
+
# Leave self.image stale until explicitly requested; avoids recomposition on every frame.
|
|
1326
1509
|
|
|
1327
1510
|
def resizeEvent(self, event):
|
|
1328
1511
|
"""
|
|
1329
|
-
Update layers on canvas size change; ignore display
|
|
1330
|
-
|
|
1331
|
-
|
|
1512
|
+
Update layers on canvas size change; ignore layout/display resizes unless explicitly requested.
|
|
1513
|
+
Only two kinds of resizes are acted upon:
|
|
1514
|
+
- canvas resize requested via set_canvas_size_pixels() -> _canvasResizeInProgress
|
|
1515
|
+
- display-only resizes initiated by zoom -> _zoomResizeInProgress
|
|
1516
|
+
Any other widget/layout resize will be ignored for canvas logic.
|
|
1332
1517
|
"""
|
|
1333
1518
|
new_widget_size = event.size()
|
|
1334
|
-
expected_display = QSize(max(1, int(round(self._canvasSize.width() * self.zoom))),
|
|
1335
|
-
max(1, int(round(self._canvasSize.height() * self.zoom))))
|
|
1336
1519
|
|
|
1337
|
-
#
|
|
1338
|
-
if
|
|
1339
|
-
|
|
1340
|
-
# Adopt widget size as the new logical canvas size
|
|
1341
|
-
self._canvasSize = QSize(new_widget_size)
|
|
1342
|
-
self._handle_canvas_resized(old_canvas, self._canvasSize)
|
|
1343
|
-
# After canvas change, enforce current zoom on the display size
|
|
1520
|
+
# Explicit logical canvas resize requested by controller
|
|
1521
|
+
if self._canvasResizeInProgress:
|
|
1522
|
+
# Already updated _canvasSize in setter; ensure display size is in sync
|
|
1344
1523
|
self._update_widget_size_from_zoom()
|
|
1345
1524
|
super().resizeEvent(event)
|
|
1346
1525
|
return
|
|
1347
1526
|
|
|
1348
1527
|
# Display-only resize caused by zoom update: nothing to do with buffers
|
|
1528
|
+
if self._zoomResizeInProgress:
|
|
1529
|
+
self.update()
|
|
1530
|
+
super().resizeEvent(event)
|
|
1531
|
+
return
|
|
1532
|
+
|
|
1533
|
+
# Ignore stray layout-driven resizes; enforce current display size from zoom
|
|
1534
|
+
self._update_widget_size_from_zoom()
|
|
1349
1535
|
self.update()
|
|
1350
1536
|
super().resizeEvent(event)
|
|
1351
1537
|
|
|
@@ -1390,6 +1576,7 @@ class PainterWidget(QWidget):
|
|
|
1390
1576
|
|
|
1391
1577
|
self._pendingResizeApply = None
|
|
1392
1578
|
self.baseTargetRect = QRect(0, 0, self.baseCanvas.width(), self.baseCanvas.height())
|
|
1579
|
+
self._mark_composite_dirty()
|
|
1393
1580
|
else:
|
|
1394
1581
|
# Rebuild background from original source
|
|
1395
1582
|
self._rescale_base_from_source()
|
|
@@ -1398,8 +1585,8 @@ class PainterWidget(QWidget):
|
|
|
1398
1585
|
if old_size.isValid() and (old_size.width() > 0 and old_size.height() > 0) and \
|
|
1399
1586
|
(self.drawingLayer is not None) and (self.drawingLayer.size() != new_size):
|
|
1400
1587
|
self.drawingLayer = self.drawingLayer.scaled(new_size, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
|
|
1588
|
+
self._mark_composite_dirty()
|
|
1401
1589
|
|
|
1402
|
-
self._recompose()
|
|
1403
1590
|
self.update()
|
|
1404
1591
|
|
|
1405
1592
|
def eventFilter(self, source, event):
|