pygpt-net 2.7.0__py3-none-any.whl → 2.7.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. pygpt_net/CHANGELOG.txt +14 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/ctx/ctx.py +4 -1
  4. pygpt_net/controller/painter/common.py +43 -11
  5. pygpt_net/core/filesystem/filesystem.py +70 -0
  6. pygpt_net/core/filesystem/packer.py +161 -1
  7. pygpt_net/core/image/image.py +2 -2
  8. pygpt_net/core/platforms/platforms.py +14 -1
  9. pygpt_net/core/updater/updater.py +24 -12
  10. pygpt_net/core/video/video.py +2 -3
  11. pygpt_net/data/config/config.json +3 -3
  12. pygpt_net/data/config/models.json +3 -3
  13. pygpt_net/data/css/style.dark.css +13 -6
  14. pygpt_net/data/css/style.light.css +13 -5
  15. pygpt_net/data/locale/locale.de.ini +2 -0
  16. pygpt_net/data/locale/locale.en.ini +2 -0
  17. pygpt_net/data/locale/locale.es.ini +2 -0
  18. pygpt_net/data/locale/locale.fr.ini +2 -0
  19. pygpt_net/data/locale/locale.it.ini +2 -0
  20. pygpt_net/data/locale/locale.pl.ini +3 -1
  21. pygpt_net/data/locale/locale.uk.ini +2 -0
  22. pygpt_net/data/locale/locale.zh.ini +2 -0
  23. pygpt_net/provider/core/config/patch.py +16 -0
  24. pygpt_net/ui/dialog/preset.py +1 -0
  25. pygpt_net/ui/layout/toolbox/image.py +2 -1
  26. pygpt_net/ui/layout/toolbox/indexes.py +2 -0
  27. pygpt_net/ui/layout/toolbox/video.py +5 -1
  28. pygpt_net/ui/main.py +1 -0
  29. pygpt_net/ui/widget/dialog/update.py +18 -7
  30. pygpt_net/ui/widget/draw/painter.py +238 -51
  31. pygpt_net/ui/widget/filesystem/explorer.py +82 -0
  32. pygpt_net/ui/widget/option/combo.py +179 -13
  33. {pygpt_net-2.7.0.dist-info → pygpt_net-2.7.2.dist-info}/METADATA +44 -4
  34. {pygpt_net-2.7.0.dist-info → pygpt_net-2.7.2.dist-info}/RECORD +37 -37
  35. {pygpt_net-2.7.0.dist-info → pygpt_net-2.7.2.dist-info}/LICENSE +0 -0
  36. {pygpt_net-2.7.0.dist-info → pygpt_net-2.7.2.dist-info}/WHEEL +0 -0
  37. {pygpt_net-2.7.0.dist-info → pygpt_net-2.7.2.dist-info}/entry_points.txt +0 -0
@@ -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.09.26 12:00:00 #
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._recompose()
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
- self._recompose()
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
- bounds = self._detect_nonwhite_bounds(self.image)
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 action_fit(self):
511
- """Trim white letterbox margins and resize canvas to the scaled image area. Undo-safe."""
512
- if not self._is_fit_available():
513
- return
514
-
515
- self.saveForUndo()
516
- self._ensure_layers()
517
- self._recompose()
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
- canvas_rect = QRect(0, 0, self._canvasSize.width(), self._canvasSize.height())
522
- fit_rect = self.baseTargetRect.intersected(canvas_rect)
604
+ result = self.baseTargetRect.intersected(canvas_rect)
523
605
 
524
- if fit_rect is None or fit_rect.isNull() or fit_rect.width() <= 0 or fit_rect.height() <= 0:
525
- fit_rect = self._detect_nonwhite_bounds(self.image)
526
- if fit_rect is None or fit_rect.isNull() or fit_rect.width() <= 0 or fit_rect.height() <= 0:
527
- return
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._recompose()
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._recompose()
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._recompose()
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._recompose()
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._recompose()
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._recompose()
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._recompose()
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._recompose()
1217
- self.update()
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._recompose()
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
- if self.image.size() != self._canvasSize:
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
- # Draw composited canvas scaled to display rect
1303
- p.drawImage(self.rect(), self.image, self.image.rect())
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.originalImage = self.image
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-only resizes from zoom.
1330
-
1331
- :param event: Event
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
- # External canvas resize (e.g. controller.change_canvas_size -> setFixedSize(canvas))
1338
- if new_widget_size != expected_display and not self._zoomResizeInProgress:
1339
- old_canvas = QSize(self._canvasSize)
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):
@@ -723,6 +723,8 @@ class FileExplorer(QWidget):
723
723
  'paste': QIcon(":/icons/paste.svg"),
724
724
  'read': QIcon(":/icons/view.svg"),
725
725
  'db': QIcon(":/icons/db.svg"),
726
+ 'pack': QIcon(":/icons/upload.svg"),
727
+ 'unpack': QIcon(":/icons/download.svg"),
726
728
  }
727
729
 
728
730
  try:
@@ -996,6 +998,16 @@ class FileExplorer(QWidget):
996
998
  actions['paste'].triggered.connect(lambda: self.action_paste_into(parent))
997
999
  actions['paste'].setEnabled(self._can_paste())
998
1000
 
1001
+ # Pack / Unpack availability
1002
+ try:
1003
+ can_unpack_all = all(
1004
+ os.path.isfile(p) and self.window.core.filesystem.packer.can_unpack(p)
1005
+ for p in paths
1006
+ )
1007
+ except Exception:
1008
+ can_unpack_all = False
1009
+
1010
+ # Build menu
999
1011
  menu = QMenu(self)
1000
1012
  if preview_actions:
1001
1013
  for action in preview_actions:
@@ -1076,6 +1088,23 @@ class FileExplorer(QWidget):
1076
1088
  menu.addAction(actions['paste'])
1077
1089
  menu.addSeparator()
1078
1090
 
1091
+ # Pack submenu (available for any selection)
1092
+ pack_menu = QMenu(trans("action.pack"), self)
1093
+ a_zip = QAction(self._icons['pack'], "ZIP (.zip)", self)
1094
+ a_zip.triggered.connect(lambda: self.action_pack(target_multi, 'zip'))
1095
+ a_tgz = QAction(self._icons['pack'], "Tar GZip (.tar.gz)", self)
1096
+ a_tgz.triggered.connect(lambda: self.action_pack(target_multi, 'tar.gz'))
1097
+ pack_menu.addAction(a_zip)
1098
+ pack_menu.addAction(a_tgz)
1099
+ menu.addMenu(pack_menu)
1100
+
1101
+ # Unpack (only when all selected are supported archives)
1102
+ if can_unpack_all:
1103
+ a_unpack = QAction(self._icons['unpack'], trans("action.unpack"), self)
1104
+ a_unpack.triggered.connect(lambda: self.action_unpack(target_multi))
1105
+ menu.addAction(a_unpack)
1106
+
1107
+ menu.addSeparator()
1079
1108
  menu.addAction(actions['download'])
1080
1109
  menu.addAction(actions['touch'])
1081
1110
  menu.addAction(actions['mkdir'])
@@ -1180,6 +1209,59 @@ class FileExplorer(QWidget):
1180
1209
  """
1181
1210
  self.window.controller.files.delete(path)
1182
1211
 
1212
+ def action_pack(self, path: Union[str, list], fmt: str):
1213
+ """
1214
+ Pack selected items into an archive.
1215
+
1216
+ :param path: path or list of paths to include
1217
+ :param fmt: 'zip' or 'tar.gz'
1218
+ """
1219
+ paths = path if isinstance(path, list) else [path]
1220
+ try:
1221
+ dst = self.window.core.filesystem.packer.pack_paths(paths, fmt)
1222
+ except Exception as e:
1223
+ try:
1224
+ self.window.core.debug.log(e)
1225
+ except Exception:
1226
+ pass
1227
+ dst = None
1228
+
1229
+ try:
1230
+ self.window.controller.files.update_explorer()
1231
+ except Exception:
1232
+ self.update_view()
1233
+
1234
+ if dst and os.path.exists(dst):
1235
+ self._reveal_paths([dst], select_first=True)
1236
+
1237
+ def action_unpack(self, path: Union[str, list]):
1238
+ """
1239
+ Unpack selected archives to sibling directories named after archives.
1240
+
1241
+ :param path: path or list of paths to archives
1242
+ """
1243
+ paths = path if isinstance(path, list) else [path]
1244
+ created = []
1245
+ for p in paths:
1246
+ try:
1247
+ if self.window.core.filesystem.packer.can_unpack(p):
1248
+ out_dir = self.window.core.filesystem.packer.unpack_to_sibling_dir(p)
1249
+ if out_dir:
1250
+ created.append(out_dir)
1251
+ except Exception as e:
1252
+ try:
1253
+ self.window.core.debug.log(e)
1254
+ except Exception:
1255
+ pass
1256
+
1257
+ try:
1258
+ self.window.controller.files.update_explorer()
1259
+ except Exception:
1260
+ self.update_view()
1261
+
1262
+ if created:
1263
+ self._reveal_paths(created, select_first=True)
1264
+
1183
1265
  # ===== Copy / Cut / Paste API =====
1184
1266
 
1185
1267
  def _selected_paths(self) -> list: